diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/ColorInversionRepositoryImplTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/ColorInversionRepositoryImplTest.kt index 3d8159e7006190a36fcb1f067ee218006ba2d490..9c9ee53d9c56fd163736a8c447c7eecdc26a470b 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/ColorInversionRepositoryImplTest.kt +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/ColorInversionRepositoryImplTest.kt @@ -24,7 +24,6 @@ import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue import com.android.systemui.coroutines.collectValues import com.android.systemui.util.settings.FakeSettings -import com.google.common.truth.Truth import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -66,7 +65,7 @@ class ColorInversionRepositoryImplTest : SysuiTestCase() { runCurrent() - Truth.assertThat(actualValue).isFalse() + assertThat(actualValue).isFalse() } @Test diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/NightDisplayRepositoryTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/NightDisplayRepositoryTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..ca824cbdd53b97fec18249329f356caf17275c1e --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/accessibility/data/repository/NightDisplayRepositoryTest.kt @@ -0,0 +1,203 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.accessibility.data.repository + +import android.hardware.display.ColorDisplayManager +import android.hardware.display.NightDisplayListener +import android.os.UserHandle +import android.provider.Settings +import android.testing.LeakCheck +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.dagger.NightDisplayListenerModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.util.mockito.argumentCaptor +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.settings.fakeGlobalSettings +import com.android.systemui.util.settings.fakeSettings +import com.android.systemui.utils.leaks.FakeLocationController +import com.google.common.truth.Truth.assertThat +import java.time.LocalTime +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.verify + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(AndroidJUnit4::class) +class NightDisplayRepositoryTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val testUser = UserHandle.of(1)!! + private val testStartTime = LocalTime.MIDNIGHT + private val testEndTime = LocalTime.NOON + private val colorDisplayManager = + mock<ColorDisplayManager> { + whenever(nightDisplayAutoMode).thenReturn(ColorDisplayManager.AUTO_MODE_DISABLED) + whenever(isNightDisplayActivated).thenReturn(false) + whenever(nightDisplayCustomStartTime).thenReturn(testStartTime) + whenever(nightDisplayCustomEndTime).thenReturn(testEndTime) + } + private val locationController = FakeLocationController(LeakCheck()) + private val nightDisplayListener = mock<NightDisplayListener>() + private val listenerBuilder = + mock<NightDisplayListenerModule.Builder> { + whenever(setUser(ArgumentMatchers.anyInt())).thenReturn(this) + whenever(build()).thenReturn(nightDisplayListener) + } + private val globalSettings = kosmos.fakeGlobalSettings + private val secureSettings = kosmos.fakeSettings + private val testDispatcher = StandardTestDispatcher() + private val scope = TestScope(testDispatcher) + private val userScopedColorDisplayManager = + mock<UserScopedService<ColorDisplayManager>> { + whenever(forUser(eq(testUser))).thenReturn(colorDisplayManager) + } + + private val underTest = + NightDisplayRepository( + testDispatcher, + scope.backgroundScope, + globalSettings, + secureSettings, + listenerBuilder, + userScopedColorDisplayManager, + locationController, + ) + + @Before + fun setup() { + enrollInForcedNightDisplayAutoMode(INITIALLY_FORCE_AUTO_MODE, testUser) + } + + @Test + fun nightDisplayState_matchesAutoMode() = + scope.runTest { + enrollInForcedNightDisplayAutoMode(INITIALLY_FORCE_AUTO_MODE, testUser) + val callbackCaptor = argumentCaptor<NightDisplayListener.Callback>() + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + + runCurrent() + + verify(nightDisplayListener).setCallback(callbackCaptor.capture()) + val callback = callbackCaptor.value + + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_DISABLED) + + callback.onAutoModeChanged(ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) + + callback.onCustomStartTimeChanged(testStartTime) + assertThat(lastState!!.startTime).isEqualTo(testStartTime) + + callback.onCustomEndTimeChanged(testEndTime) + assertThat(lastState!!.endTime).isEqualTo(testEndTime) + + callback.onAutoModeChanged(ColorDisplayManager.AUTO_MODE_TWILIGHT) + + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_TWILIGHT) + } + + @Test + fun nightDisplayState_matchesIsNightDisplayActivated() = + scope.runTest { + val callbackCaptor = argumentCaptor<NightDisplayListener.Callback>() + + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + runCurrent() + + verify(nightDisplayListener).setCallback(callbackCaptor.capture()) + val callback = callbackCaptor.value + assertThat(lastState!!.isActivated) + .isEqualTo(colorDisplayManager.isNightDisplayActivated) + + callback.onActivated(true) + assertThat(lastState!!.isActivated).isTrue() + + callback.onActivated(false) + assertThat(lastState!!.isActivated).isFalse() + } + + @Test + fun nightDisplayState_matchesController_initiallyCustomAutoMode() = + scope.runTest { + whenever(colorDisplayManager.nightDisplayAutoMode) + .thenReturn(ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) + + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + runCurrent() + + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) + } + + @Test + fun nightDisplayState_matchesController_initiallyTwilightAutoMode() = + scope.runTest { + whenever(colorDisplayManager.nightDisplayAutoMode) + .thenReturn(ColorDisplayManager.AUTO_MODE_TWILIGHT) + + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + runCurrent() + + assertThat(lastState!!.autoMode).isEqualTo(ColorDisplayManager.AUTO_MODE_TWILIGHT) + } + + @Test + fun nightDisplayState_matchesForceAutoMode() = + scope.runTest { + enrollInForcedNightDisplayAutoMode(false, testUser) + val lastState by collectLastValue(underTest.nightDisplayState(testUser)) + runCurrent() + + assertThat(lastState!!.shouldForceAutoMode).isEqualTo(false) + + enrollInForcedNightDisplayAutoMode(true, testUser) + assertThat(lastState!!.shouldForceAutoMode).isEqualTo(true) + } + + private fun enrollInForcedNightDisplayAutoMode(enroll: Boolean, userHandle: UserHandle) { + globalSettings.putString( + Settings.Global.NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE, + if (enroll) NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE + else NIGHT_DISPLAY_FORCED_AUTO_MODE_UNAVAILABLE + ) + secureSettings.putIntForUser( + Settings.Secure.NIGHT_DISPLAY_AUTO_MODE, + if (enroll) NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET else NIGHT_DISPLAY_AUTO_MODE_RAW_SET, + userHandle.identifier + ) + } + + private companion object { + const val INITIALLY_FORCE_AUTO_MODE = false + const val NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE = "1" + const val NIGHT_DISPLAY_FORCED_AUTO_MODE_UNAVAILABLE = "0" + const val NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET = -1 + const val NIGHT_DISPLAY_AUTO_MODE_RAW_SET = 0 + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..a0aa2d4a9a6cd9382444f757acd7800faf8c1da9 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractorTest.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.impl.night.domain.interactor + +import android.hardware.display.ColorDisplayManager +import android.hardware.display.NightDisplayListener +import android.os.UserHandle +import android.testing.LeakCheck +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.accessibility.data.repository.NightDisplayRepository +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.dagger.NightDisplayListenerModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.settings.fakeGlobalSettings +import com.android.systemui.util.settings.fakeSettings +import com.android.systemui.util.time.DateFormatUtil +import com.android.systemui.utils.leaks.FakeLocationController +import com.google.common.truth.Truth.assertThat +import java.time.LocalTime +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyInt + +@SmallTest +@RunWith(AndroidJUnit4::class) +class NightDisplayTileDataInteractorTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val testUser = UserHandle.of(1)!! + private val testStartTime = LocalTime.MIDNIGHT + private val testEndTime = LocalTime.NOON + private val colorDisplayManager = + mock<ColorDisplayManager> { + whenever(nightDisplayAutoMode).thenReturn(ColorDisplayManager.AUTO_MODE_DISABLED) + whenever(isNightDisplayActivated).thenReturn(false) + whenever(nightDisplayCustomStartTime).thenReturn(testStartTime) + whenever(nightDisplayCustomEndTime).thenReturn(testEndTime) + } + private val locationController = FakeLocationController(LeakCheck()) + private val nightDisplayListener = mock<NightDisplayListener>() + private val listenerBuilder = + mock<NightDisplayListenerModule.Builder> { + whenever(setUser(anyInt())).thenReturn(this) + whenever(build()).thenReturn(nightDisplayListener) + } + private val globalSettings = kosmos.fakeGlobalSettings + private val secureSettings = kosmos.fakeSettings + private val dateFormatUtil = mock<DateFormatUtil> { whenever(is24HourFormat).thenReturn(false) } + private val testDispatcher = StandardTestDispatcher() + private val scope = TestScope(testDispatcher) + private val userScopedColorDisplayManager = + mock<UserScopedService<ColorDisplayManager>> { + whenever(forUser(eq(testUser))).thenReturn(colorDisplayManager) + } + private val nightDisplayRepository = + NightDisplayRepository( + testDispatcher, + scope.backgroundScope, + globalSettings, + secureSettings, + listenerBuilder, + userScopedColorDisplayManager, + locationController, + ) + + private val underTest: NightDisplayTileDataInteractor = + NightDisplayTileDataInteractor(context, dateFormatUtil, nightDisplayRepository) + + @Test + fun availability_matchesColorDisplayManager() = runTest { + val availability by collectLastValue(underTest.availability(testUser)) + + val expectedAvailability = ColorDisplayManager.isNightDisplayAvailable(context) + assertThat(availability).isEqualTo(expectedAvailability) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractorTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..adc8bcba5a5c7fd48bc920899230ee1818c4a7c8 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractorTest.kt @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.impl.night.domain.interactor + +import android.hardware.display.ColorDisplayManager +import android.hardware.display.NightDisplayListener +import android.os.UserHandle +import android.provider.Settings +import android.testing.LeakCheck +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.accessibility.data.repository.NightDisplayRepository +import com.android.systemui.dagger.NightDisplayListenerModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.tiles.base.actions.FakeQSTileIntentUserInputHandler +import com.android.systemui.qs.tiles.base.actions.intentInputs +import com.android.systemui.qs.tiles.base.interactor.QSTileInputTestKtx +import com.android.systemui.qs.tiles.impl.custom.qsTileLogger +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.util.mockito.eq +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.android.systemui.util.settings.fakeGlobalSettings +import com.android.systemui.util.settings.fakeSettings +import com.android.systemui.utils.leaks.FakeLocationController +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers +import org.mockito.Mockito.verify + +@SmallTest +@RunWith(AndroidJUnit4::class) +class NightDisplayTileUserActionInteractorTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val qsTileIntentUserActionHandler = FakeQSTileIntentUserInputHandler() + private val testUser = UserHandle.of(1) + private val colorDisplayManager = + mock<ColorDisplayManager> { + whenever(nightDisplayAutoMode).thenReturn(ColorDisplayManager.AUTO_MODE_DISABLED) + whenever(isNightDisplayActivated).thenReturn(false) + } + private val locationController = FakeLocationController(LeakCheck()) + private val nightDisplayListener = mock<NightDisplayListener>() + private val listenerBuilder = + mock<NightDisplayListenerModule.Builder> { + whenever(setUser(ArgumentMatchers.anyInt())).thenReturn(this) + whenever(build()).thenReturn(nightDisplayListener) + } + private val globalSettings = kosmos.fakeGlobalSettings + private val secureSettings = kosmos.fakeSettings + private val testDispatcher = StandardTestDispatcher() + private val scope = TestScope(testDispatcher) + private val userScopedColorDisplayManager = + mock<UserScopedService<ColorDisplayManager>> { + whenever(forUser(eq(testUser))).thenReturn(colorDisplayManager) + } + private val nightDisplayRepository = + NightDisplayRepository( + testDispatcher, + scope.backgroundScope, + globalSettings, + secureSettings, + listenerBuilder, + userScopedColorDisplayManager, + locationController, + ) + + private val underTest = + NightDisplayTileUserActionInteractor( + nightDisplayRepository, + qsTileIntentUserActionHandler, + kosmos.qsTileLogger + ) + + @Test + fun handleClick_inactive_activates() = + scope.runTest { + val startingModel = NightDisplayTileModel.AutoModeOff(false, false) + + underTest.handleInput(QSTileInputTestKtx.click(startingModel, testUser)) + + verify(colorDisplayManager).setNightDisplayActivated(true) + } + + @Test + fun handleClick_active_disables() = + scope.runTest { + val startingModel = NightDisplayTileModel.AutoModeOff(true, false) + + underTest.handleInput(QSTileInputTestKtx.click(startingModel, testUser)) + + verify(colorDisplayManager).setNightDisplayActivated(false) + } + + @Test + fun handleClick_whenAutoModeTwilight_flipsState() = + scope.runTest { + val originalState = true + val startingModel = NightDisplayTileModel.AutoModeTwilight(originalState, false, false) + + underTest.handleInput(QSTileInputTestKtx.click(startingModel, testUser)) + + verify(colorDisplayManager).setNightDisplayActivated(!originalState) + } + + @Test + fun handleClick_whenAutoModeCustom_flipsState() = + scope.runTest { + val originalState = true + val startingModel = + NightDisplayTileModel.AutoModeCustom(originalState, false, null, null, false) + + underTest.handleInput(QSTileInputTestKtx.click(startingModel, testUser)) + + verify(colorDisplayManager).setNightDisplayActivated(!originalState) + } + + @Test + fun handleLongClickWhenEnabled() = + scope.runTest { + val enabledState = true + + underTest.handleInput( + QSTileInputTestKtx.longClick( + NightDisplayTileModel.AutoModeOff(enabledState, false), + testUser + ) + ) + + assertThat(qsTileIntentUserActionHandler.handledInputs).hasSize(1) + + val intentInput = qsTileIntentUserActionHandler.intentInputs.last() + val actualIntentAction = intentInput.intent.action + val expectedIntentAction = Settings.ACTION_NIGHT_DISPLAY_SETTINGS + assertThat(actualIntentAction).isEqualTo(expectedIntentAction) + } + + @Test + fun handleLongClickWhenDisabled() = + scope.runTest { + val enabledState = false + + underTest.handleInput( + QSTileInputTestKtx.longClick( + NightDisplayTileModel.AutoModeOff(enabledState, false), + testUser + ) + ) + + assertThat(qsTileIntentUserActionHandler.handledInputs).hasSize(1) + + val intentInput = qsTileIntentUserActionHandler.intentInputs.last() + val actualIntentAction = intentInput.intent.action + val expectedIntentAction = Settings.ACTION_NIGHT_DISPLAY_SETTINGS + assertThat(actualIntentAction).isEqualTo(expectedIntentAction) + } +} diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapperTest.kt b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapperTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..5d2e7013c2f40ae979d234795a7832b64d7dc554 --- /dev/null +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapperTest.kt @@ -0,0 +1,315 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.impl.night.ui + +import android.graphics.drawable.TestStubDrawable +import android.service.quicksettings.Tile +import android.text.TextUtils +import android.widget.Switch +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.tiles.base.logging.QSTileLogger +import com.android.systemui.qs.tiles.impl.custom.QSTileStateSubject +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.qs.tiles.impl.night.qsNightDisplayTileConfig +import com.android.systemui.qs.tiles.viewmodel.QSTileState +import com.android.systemui.res.R +import com.android.systemui.util.mockito.mock +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class NightDisplayTileMapperTest : SysuiTestCase() { + private val kosmos = Kosmos() + private val config = kosmos.qsNightDisplayTileConfig + + private val testStartTime = LocalTime.MIDNIGHT + private val testEndTime = LocalTime.NOON + + private lateinit var mapper: NightDisplayTileMapper + + @Before + fun setup() { + mapper = + NightDisplayTileMapper( + context.orCreateTestableResources + .apply { + addOverride(R.drawable.qs_nightlight_icon_on, TestStubDrawable()) + addOverride(R.drawable.qs_nightlight_icon_off, TestStubDrawable()) + } + .resources, + context.theme, + mock<QSTileLogger>(), + ) + } + + @Test + fun disabledModel_whenAutoModeOff() { + val inputModel = NightDisplayTileModel.AutoModeOff(false, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.resources.getStringArray(R.array.tile_states_night)[Tile.STATE_INACTIVE] + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + /** Force enable does not change the mode by itself. */ + @Test + fun disabledModel_whenAutoModeOff_whenForceEnable() { + val inputModel = NightDisplayTileModel.AutoModeOff(false, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.resources.getStringArray(R.array.tile_states_night)[Tile.STATE_INACTIVE] + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_whenAutoModeOff() { + val inputModel = NightDisplayTileModel.AutoModeOff(true, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.resources.getStringArray(R.array.tile_states_night)[Tile.STATE_ACTIVE] + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_forceAutoMode_whenAutoModeOff() { + val inputModel = NightDisplayTileModel.AutoModeOff(true, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.resources.getStringArray(R.array.tile_states_night)[Tile.STATE_ACTIVE] + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_autoModeTwilight_locationOff() { + val inputModel = NightDisplayTileModel.AutoModeTwilight(true, false, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = createNightDisplayTileState(QSTileState.ActivationState.ACTIVE, null) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_autoModeTwilight_locationOn() { + val inputModel = NightDisplayTileModel.AutoModeTwilight(true, false, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.getString(R.string.quick_settings_night_secondary_label_until_sunrise) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun disabledModel_autoModeTwilight_locationOn() { + val inputModel = NightDisplayTileModel.AutoModeTwilight(false, false, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.getString(R.string.quick_settings_night_secondary_label_on_at_sunset) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun disabledModel_autoModeTwilight_locationOff() { + val inputModel = NightDisplayTileModel.AutoModeTwilight(false, false, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = createNightDisplayTileState(QSTileState.ActivationState.INACTIVE, null) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun disabledModel_autoModeCustom_24Hour() { + val inputModel = + NightDisplayTileModel.AutoModeCustom(false, false, testStartTime, null, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.getString( + R.string.quick_settings_night_secondary_label_on_at, + formatter24Hour.format(testStartTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun disabledModel_autoModeCustom_12Hour() { + val inputModel = + NightDisplayTileModel.AutoModeCustom(false, false, testStartTime, null, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.getString( + R.string.quick_settings_night_secondary_label_on_at, + formatter12Hour.format(testStartTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + /** Should have the same outcome as [disabledModel_autoModeCustom_12Hour] */ + @Test + fun disabledModel_autoModeCustom_12Hour_isEnrolledForcedAutoMode() { + val inputModel = + NightDisplayTileModel.AutoModeCustom(false, true, testStartTime, null, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.INACTIVE, + context.getString( + R.string.quick_settings_night_secondary_label_on_at, + formatter12Hour.format(testStartTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_autoModeCustom_24Hour() { + val inputModel = NightDisplayTileModel.AutoModeCustom(true, false, null, testEndTime, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.getString( + R.string.quick_settings_secondary_label_until, + formatter24Hour.format(testEndTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + @Test + fun enabledModel_autoModeCustom_12Hour() { + val inputModel = NightDisplayTileModel.AutoModeCustom(true, false, null, testEndTime, false) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.getString( + R.string.quick_settings_secondary_label_until, + formatter12Hour.format(testEndTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + /** Should have the same state as [enabledModel_autoModeCustom_24Hour] */ + @Test + fun enabledModel_autoModeCustom_24Hour_forceEnabled() { + val inputModel = NightDisplayTileModel.AutoModeCustom(true, true, null, testEndTime, true) + + val outputState = mapper.map(config, inputModel) + + val expectedState = + createNightDisplayTileState( + QSTileState.ActivationState.ACTIVE, + context.getString( + R.string.quick_settings_secondary_label_until, + formatter24Hour.format(testEndTime) + ) + ) + QSTileStateSubject.assertThat(outputState).isEqualTo(expectedState) + } + + private fun createNightDisplayTileState( + activationState: QSTileState.ActivationState, + secondaryLabel: String? + ): QSTileState { + val label = context.getString(R.string.quick_settings_night_display_label) + + val contentDescription = + if (TextUtils.isEmpty(secondaryLabel)) label + else TextUtils.concat(label, ", ", secondaryLabel) + return QSTileState( + { + Icon.Loaded( + context.getDrawable( + if (activationState == QSTileState.ActivationState.ACTIVE) + R.drawable.qs_nightlight_icon_on + else R.drawable.qs_nightlight_icon_off + )!!, + null + ) + }, + label, + activationState, + secondaryLabel, + setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK), + contentDescription, + null, + QSTileState.SideViewIcon.None, + QSTileState.EnabledState.ENABLED, + Switch::class.qualifiedName + ) + } + + private companion object { + val formatter12Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("hh:mm a") + val formatter24Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + } +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayChangeEvent.kt b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayChangeEvent.kt new file mode 100644 index 0000000000000000000000000000000000000000..8f071e4bc8748e50edf37c3280d4cc79691f7825 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayChangeEvent.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.accessibility.data.model + +import java.time.LocalTime + +sealed interface NightDisplayChangeEvent { + data class OnAutoModeChanged(val autoMode: Int) : NightDisplayChangeEvent + data class OnActivatedChanged(val isActivated: Boolean) : NightDisplayChangeEvent + data class OnCustomStartTimeChanged(val startTime: LocalTime?) : NightDisplayChangeEvent + data class OnCustomEndTimeChanged(val endTime: LocalTime?) : NightDisplayChangeEvent + data class OnForceAutoModeChanged(val shouldForceAutoMode: Boolean) : NightDisplayChangeEvent + data class OnLocationEnabledChanged(val locationEnabled: Boolean) : NightDisplayChangeEvent +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayState.kt b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayState.kt new file mode 100644 index 0000000000000000000000000000000000000000..196876e541b18ddd13e1e936d3cae0160467e7c5 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/data/model/NightDisplayState.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.accessibility.data.model + +import java.time.LocalTime + +/** models the state of NightDisplayRepository */ +data class NightDisplayState( + val autoMode: Int = 0, + val isActivated: Boolean = true, + val startTime: LocalTime? = null, + val endTime: LocalTime? = null, + val shouldForceAutoMode: Boolean = false, + val locationEnabled: Boolean = false, +) diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/NightDisplayRepository.kt b/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/NightDisplayRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..bf44fabc31c48212c289053f3a2b59697695ffcb --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/data/repository/NightDisplayRepository.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.accessibility.data.repository + +import android.hardware.display.ColorDisplayManager +import android.hardware.display.NightDisplayListener +import android.os.UserHandle +import android.provider.Settings +import com.android.systemui.accessibility.data.model.NightDisplayChangeEvent +import com.android.systemui.accessibility.data.model.NightDisplayState +import com.android.systemui.dagger.NightDisplayListenerModule +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.dagger.qualifiers.Background +import com.android.systemui.statusbar.policy.LocationController +import com.android.systemui.user.utils.UserScopedService +import com.android.systemui.util.kotlin.isLocationEnabledFlow +import com.android.systemui.util.settings.GlobalSettings +import com.android.systemui.util.settings.SecureSettings +import com.android.systemui.util.settings.SettingsProxyExt.observerFlow +import java.time.LocalTime +import javax.inject.Inject +import kotlin.coroutines.CoroutineContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext + +class NightDisplayRepository +@Inject +constructor( + @Background private val bgCoroutineContext: CoroutineContext, + @Application private val scope: CoroutineScope, + private val globalSettings: GlobalSettings, + private val secureSettings: SecureSettings, + private val nightDisplayListenerBuilder: NightDisplayListenerModule.Builder, + private val colorDisplayManagerUserScopedService: UserScopedService<ColorDisplayManager>, + private val locationController: LocationController, +) { + private val stateFlowUserMap = mutableMapOf<Int, Flow<NightDisplayState>>() + + fun nightDisplayState(user: UserHandle): Flow<NightDisplayState> = + stateFlowUserMap.getOrPut(user.identifier) { + return merge( + colorDisplayManagerChangeEventFlow(user), + shouldForceAutoMode(user).map { + NightDisplayChangeEvent.OnForceAutoModeChanged(it) + }, + locationController.isLocationEnabledFlow().map { + NightDisplayChangeEvent.OnLocationEnabledChanged(it) + } + ) + .scan(initialState(user)) { state, event -> + when (event) { + is NightDisplayChangeEvent.OnActivatedChanged -> + state.copy(isActivated = event.isActivated) + is NightDisplayChangeEvent.OnAutoModeChanged -> + state.copy(autoMode = event.autoMode) + is NightDisplayChangeEvent.OnCustomStartTimeChanged -> + state.copy(startTime = event.startTime) + is NightDisplayChangeEvent.OnCustomEndTimeChanged -> + state.copy(endTime = event.endTime) + is NightDisplayChangeEvent.OnForceAutoModeChanged -> + state.copy(shouldForceAutoMode = event.shouldForceAutoMode) + is NightDisplayChangeEvent.OnLocationEnabledChanged -> + state.copy(locationEnabled = event.locationEnabled) + } + } + .conflate() + .onStart { emit(initialState(user)) } + .flowOn(bgCoroutineContext) + .stateIn(scope, SharingStarted.WhileSubscribed(), NightDisplayState()) + } + + /** Track changes in night display enabled state and its auto mode */ + private fun colorDisplayManagerChangeEventFlow(user: UserHandle) = callbackFlow { + val nightDisplayListener = nightDisplayListenerBuilder.setUser(user.identifier).build() + val nightDisplayCallback = + object : NightDisplayListener.Callback { + override fun onActivated(activated: Boolean) { + trySend(NightDisplayChangeEvent.OnActivatedChanged(activated)) + } + + override fun onAutoModeChanged(autoMode: Int) { + trySend(NightDisplayChangeEvent.OnAutoModeChanged(autoMode)) + } + + override fun onCustomStartTimeChanged(startTime: LocalTime?) { + trySend(NightDisplayChangeEvent.OnCustomStartTimeChanged(startTime)) + } + + override fun onCustomEndTimeChanged(endTime: LocalTime?) { + trySend(NightDisplayChangeEvent.OnCustomEndTimeChanged(endTime)) + } + } + nightDisplayListener.setCallback(nightDisplayCallback) + awaitClose { nightDisplayListener.setCallback(null) } + } + + /** @return true when the option to force auto mode is available and a value has not been set */ + private fun shouldForceAutoMode(userHandle: UserHandle): Flow<Boolean> = + combine(isForceAutoModeAvailable, isDisplayAutoModeRawNotSet(userHandle)) { + isForceAutoModeAvailable, + isDisplayAutoModeRawNotSet, + -> + isForceAutoModeAvailable && isDisplayAutoModeRawNotSet + } + + private val isForceAutoModeAvailable: Flow<Boolean> = + globalSettings + .observerFlow(IS_FORCE_AUTO_MODE_AVAILABLE_SETTING_NAME) + .onStart { emit(Unit) } + .map { + globalSettings.getString(IS_FORCE_AUTO_MODE_AVAILABLE_SETTING_NAME) == + NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE + } + .distinctUntilChanged() + + /** Inspired by [ColorDisplayService.getNightDisplayAutoModeRawInternal] */ + private fun isDisplayAutoModeRawNotSet(userHandle: UserHandle): Flow<Boolean> = + if (userHandle.identifier == UserHandle.USER_NULL) { + flowOf(IS_AUTO_MODE_RAW_NOT_SET_DEFAULT) + } else { + secureSettings + .observerFlow(userHandle.identifier, DISPLAY_AUTO_MODE_RAW_SETTING_NAME) + .onStart { emit(Unit) } + .map { + secureSettings.getIntForUser( + DISPLAY_AUTO_MODE_RAW_SETTING_NAME, + userHandle.identifier + ) == NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET + } + } + .distinctUntilChanged() + + suspend fun setNightDisplayAutoMode(autoMode: Int, user: UserHandle) { + withContext(bgCoroutineContext) { + colorDisplayManagerUserScopedService.forUser(user).nightDisplayAutoMode = autoMode + } + } + + suspend fun setNightDisplayActivated(activated: Boolean, user: UserHandle) { + withContext(bgCoroutineContext) { + colorDisplayManagerUserScopedService.forUser(user).isNightDisplayActivated = activated + } + } + + private fun initialState(user: UserHandle): NightDisplayState { + val colorDisplayManager = colorDisplayManagerUserScopedService.forUser(user) + return NightDisplayState( + colorDisplayManager.nightDisplayAutoMode, + colorDisplayManager.isNightDisplayActivated, + colorDisplayManager.nightDisplayCustomStartTime, + colorDisplayManager.nightDisplayCustomEndTime, + globalSettings.getString(IS_FORCE_AUTO_MODE_AVAILABLE_SETTING_NAME) == + NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE && + secureSettings.getIntForUser(DISPLAY_AUTO_MODE_RAW_SETTING_NAME, user.identifier) == + NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET, + locationController.isLocationEnabled, + ) + } + + private companion object { + const val NIGHT_DISPLAY_AUTO_MODE_RAW_NOT_SET = -1 + const val NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE = "1" + const val IS_AUTO_MODE_RAW_NOT_SET_DEFAULT = true + const val IS_FORCE_AUTO_MODE_AVAILABLE_SETTING_NAME = + Settings.Global.NIGHT_DISPLAY_FORCED_AUTO_MODE_AVAILABLE + const val DISPLAY_AUTO_MODE_RAW_SETTING_NAME = Settings.Secure.NIGHT_DISPLAY_AUTO_MODE + } +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt b/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt index 54dd6d00aa4833281a31f6655849ac5aca604e4f..ed9597ddf559029dc22e6c70eee4029f1fec4a71 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt +++ b/packages/SystemUI/src/com/android/systemui/accessibility/qs/QSAccessibilityModule.kt @@ -41,6 +41,10 @@ import com.android.systemui.qs.tiles.impl.inversion.domain.ColorInversionTileMap import com.android.systemui.qs.tiles.impl.inversion.domain.interactor.ColorInversionTileDataInteractor import com.android.systemui.qs.tiles.impl.inversion.domain.interactor.ColorInversionUserActionInteractor import com.android.systemui.qs.tiles.impl.inversion.domain.model.ColorInversionTileModel +import com.android.systemui.qs.tiles.impl.night.domain.interactor.NightDisplayTileDataInteractor +import com.android.systemui.qs.tiles.impl.night.domain.interactor.NightDisplayTileUserActionInteractor +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.qs.tiles.impl.night.ui.NightDisplayTileMapper import com.android.systemui.qs.tiles.impl.onehanded.domain.OneHandedModeTileDataInteractor import com.android.systemui.qs.tiles.impl.onehanded.domain.OneHandedModeTileUserActionInteractor import com.android.systemui.qs.tiles.impl.onehanded.domain.model.OneHandedModeTileModel @@ -117,6 +121,7 @@ interface QSAccessibilityModule { const val FONT_SCALING_TILE_SPEC = "font_scaling" const val REDUCE_BRIGHTNESS_TILE_SPEC = "reduce_brightness" const val ONE_HANDED_TILE_SPEC = "onehanded" + const val NIGHT_DISPLAY_TILE_SPEC = "night" @Provides @IntoMap @@ -279,5 +284,41 @@ interface QSAccessibilityModule { mapper, ) else StubQSTileViewModel + + @Provides + @IntoMap + @StringKey(NIGHT_DISPLAY_TILE_SPEC) + fun provideNightDisplayTileConfig(uiEventLogger: QsEventLogger): QSTileConfig = + QSTileConfig( + tileSpec = TileSpec.create(NIGHT_DISPLAY_TILE_SPEC), + uiConfig = + QSTileUIConfig.Resource( + iconRes = R.drawable.qs_nightlight_icon_off, + labelRes = R.string.quick_settings_night_display_label, + ), + instanceId = uiEventLogger.getNewInstanceId(), + ) + + /** + * Inject NightDisplay Tile into tileViewModelMap in QSModule. The tile is hidden behind a + * flag. + */ + @Provides + @IntoMap + @StringKey(NIGHT_DISPLAY_TILE_SPEC) + fun provideNightDisplayTileViewModel( + factory: QSTileViewModelFactory.Static<NightDisplayTileModel>, + mapper: NightDisplayTileMapper, + stateInteractor: NightDisplayTileDataInteractor, + userActionInteractor: NightDisplayTileUserActionInteractor + ): QSTileViewModel = + if (Flags.qsNewTilesFuture()) + factory.create( + TileSpec.create(NIGHT_DISPLAY_TILE_SPEC), + userActionInteractor, + stateInteractor, + mapper, + ) + else StubQSTileViewModel } } diff --git a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java index e00137e3045ec71502d8223e71880244b2c8da37..11e6f7a8c38c82ef974d94c164a163de6afa726b 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/FrameworkServicesModule.java @@ -223,6 +223,13 @@ public class FrameworkServicesModule { return context.getSystemService(DevicePolicyManager.class); } + @Provides + @Singleton + static UserScopedService<ColorDisplayManager> provideScopedColorDisplayManager( + Context context) { + return new UserScopedServiceImpl<>(context, ColorDisplayManager.class); + } + @Provides @Singleton static CrossWindowBlurListeners provideCrossWindowBlurListeners() { diff --git a/packages/SystemUI/src/com/android/systemui/qs/logging/QSLogger.kt b/packages/SystemUI/src/com/android/systemui/qs/logging/QSLogger.kt index b515ce07cc022dd3043fb7ce5b8fc75f5b3e7576..278352c6f69b1881ee9aa7554c702a825886fb9a 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/logging/QSLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/logging/QSLogger.kt @@ -28,6 +28,7 @@ import com.android.systemui.log.ConstantStringsLoggerImpl import com.android.systemui.log.LogBuffer import com.android.systemui.log.core.LogLevel.DEBUG import com.android.systemui.log.core.LogLevel.ERROR +import com.android.systemui.log.core.LogLevel.INFO import com.android.systemui.log.core.LogLevel.VERBOSE import com.android.systemui.log.dagger.QSConfigLog import com.android.systemui.log.dagger.QSLog @@ -56,6 +57,9 @@ constructor( fun d(@CompileTimeConstant msg: String, arg: Any) { buffer.log(TAG, DEBUG, { str1 = arg.toString() }, { "$msg: $str1" }) } + fun i(@CompileTimeConstant msg: String, arg: Any) { + buffer.log(TAG, INFO, { str1 = arg.toString() }, { "$msg: $str1" }) + } fun logTileAdded(tileSpec: String) { buffer.log(TAG, DEBUG, { str1 = tileSpec }, { "[$str1] Tile added" }) diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/base/logging/QSTileLogger.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/base/logging/QSTileLogger.kt index 065e89f10ef6cb132dcf636b97d2547013978820..f0d72065397db0933ca105533503cf3552d6d927 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/base/logging/QSTileLogger.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/base/logging/QSTileLogger.kt @@ -175,6 +175,26 @@ constructor( ) } + /** Log with level [LogLevel.WARNING] */ + fun logWarning( + tileSpec: TileSpec, + message: String, + ) { + tileSpec + .getLogBuffer() + .log(tileSpec.getLogTag(), LogLevel.WARNING, { str1 = message }, { str1!! }) + } + + /** Log with level [LogLevel.INFO] */ + fun logInfo( + tileSpec: TileSpec, + message: String, + ) { + tileSpec + .getLogBuffer() + .log(tileSpec.getLogTag(), LogLevel.INFO, { str1 = message }, { str1!! }) + } + fun logCustomTileUserActionDelivered(tileSpec: TileSpec) { tileSpec .getLogBuffer() diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/location/domain/interactor/LocationTileDataInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/location/domain/interactor/LocationTileDataInteractor.kt index d1c80309a1cc7cc8b04b425bf1faed2a3b32dafe..bd2f2c987ccf0f1d835b2541eaa14d940b45b4b4 100644 --- a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/location/domain/interactor/LocationTileDataInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/location/domain/interactor/LocationTileDataInteractor.kt @@ -17,15 +17,15 @@ package com.android.systemui.qs.tiles.impl.location.domain.interactor import android.os.UserHandle -import com.android.systemui.common.coroutine.ConflatedCallbackFlow import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor import com.android.systemui.qs.tiles.impl.location.domain.model.LocationTileModel import com.android.systemui.statusbar.policy.LocationController +import com.android.systemui.util.kotlin.isLocationEnabledFlow import javax.inject.Inject -import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map /** Observes location state changes providing the [LocationTileModel]. */ class LocationTileDataInteractor @@ -38,19 +38,7 @@ constructor( user: UserHandle, triggers: Flow<DataUpdateTrigger> ): Flow<LocationTileModel> = - ConflatedCallbackFlow.conflatedCallbackFlow { - val initialValue = locationController.isLocationEnabled - trySend(LocationTileModel(initialValue)) - - val callback = - object : LocationController.LocationChangeCallback { - override fun onLocationSettingsChanged(locationEnabled: Boolean) { - trySend(LocationTileModel(locationEnabled)) - } - } - locationController.addCallback(callback) - awaitClose { locationController.removeCallback(callback) } - } + locationController.isLocationEnabledFlow().map { LocationTileModel(it) } override fun availability(user: UserHandle): Flow<Boolean> = flowOf(true) } diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractor.kt new file mode 100644 index 0000000000000000000000000000000000000000..88bd224881b5c630d79c8f8e9dda41464090746b --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileDataInteractor.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.impl.night.domain.interactor + +import android.content.Context +import android.hardware.display.ColorDisplayManager +import android.os.UserHandle +import com.android.systemui.accessibility.data.repository.NightDisplayRepository +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.qs.tiles.base.interactor.DataUpdateTrigger +import com.android.systemui.qs.tiles.base.interactor.QSTileDataInteractor +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.util.time.DateFormatUtil +import java.time.LocalTime +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** Observes screen record state changes providing the [NightDisplayTileModel]. */ +class NightDisplayTileDataInteractor +@Inject +constructor( + @Application private val context: Context, + private val dateFormatUtil: DateFormatUtil, + private val nightDisplayRepository: NightDisplayRepository, +) : QSTileDataInteractor<NightDisplayTileModel> { + + override fun tileData( + user: UserHandle, + triggers: Flow<DataUpdateTrigger> + ): Flow<NightDisplayTileModel> = + nightDisplayRepository.nightDisplayState(user).map { + generateModel( + it.autoMode, + it.isActivated, + it.startTime, + it.endTime, + it.shouldForceAutoMode, + it.locationEnabled + ) + } + + /** This checks resources and there fore does not make a binder call. */ + override fun availability(user: UserHandle): Flow<Boolean> = + flowOf(ColorDisplayManager.isNightDisplayAvailable(context)) + + private fun generateModel( + autoMode: Int, + isNightDisplayActivated: Boolean, + customStartTime: LocalTime?, + customEndTime: LocalTime?, + shouldForceAutoMode: Boolean, + locationEnabled: Boolean, + ): NightDisplayTileModel { + if (autoMode == ColorDisplayManager.AUTO_MODE_TWILIGHT) { + return NightDisplayTileModel.AutoModeTwilight( + isNightDisplayActivated, + shouldForceAutoMode, + locationEnabled, + ) + } else if (autoMode == ColorDisplayManager.AUTO_MODE_CUSTOM_TIME) { + return NightDisplayTileModel.AutoModeCustom( + isNightDisplayActivated, + shouldForceAutoMode, + customStartTime, + customEndTime, + dateFormatUtil.is24HourFormat, + ) + } else { // auto mode off + return NightDisplayTileModel.AutoModeOff(isNightDisplayActivated, shouldForceAutoMode) + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractor.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractor.kt new file mode 100644 index 0000000000000000000000000000000000000000..5cee8c49527daa9c84964765f58a075c4305126f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/interactor/NightDisplayTileUserActionInteractor.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.impl.night.domain.interactor + +import android.content.Intent +import android.hardware.display.ColorDisplayManager.AUTO_MODE_CUSTOM_TIME +import android.provider.Settings +import com.android.systemui.accessibility.data.repository.NightDisplayRepository +import com.android.systemui.accessibility.qs.QSAccessibilityModule +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tiles.base.actions.QSTileIntentUserInputHandler +import com.android.systemui.qs.tiles.base.interactor.QSTileInput +import com.android.systemui.qs.tiles.base.interactor.QSTileUserActionInteractor +import com.android.systemui.qs.tiles.base.logging.QSTileLogger +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.qs.tiles.viewmodel.QSTileUserAction +import javax.inject.Inject + +/** Handles night display tile clicks. */ +class NightDisplayTileUserActionInteractor +@Inject +constructor( + private val nightDisplayRepository: NightDisplayRepository, + private val qsTileIntentUserActionHandler: QSTileIntentUserInputHandler, + private val qsLogger: QSTileLogger, +) : QSTileUserActionInteractor<NightDisplayTileModel> { + override suspend fun handleInput(input: QSTileInput<NightDisplayTileModel>): Unit = + with(input) { + when (action) { + is QSTileUserAction.Click -> { + // Enroll in forced auto mode if eligible. + if (data.isEnrolledInForcedNightDisplayAutoMode) { + nightDisplayRepository.setNightDisplayAutoMode(AUTO_MODE_CUSTOM_TIME, user) + qsLogger.logInfo(spec, "Enrolled in forced night display auto mode") + } + nightDisplayRepository.setNightDisplayActivated(!data.isActivated, user) + } + is QSTileUserAction.LongClick -> { + qsTileIntentUserActionHandler.handle( + action.expandable, + Intent(Settings.ACTION_NIGHT_DISPLAY_SETTINGS) + ) + } + } + } + + companion object { + val spec = TileSpec.create(QSAccessibilityModule.NIGHT_DISPLAY_TILE_SPEC) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/model/NightDisplayTileModel.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/model/NightDisplayTileModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..6b1bd5bc3512fbe30334b2e502003c9ed9f68588 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/domain/model/NightDisplayTileModel.kt @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.impl.night.domain.model + +import java.time.LocalTime + +/** Data model for night display tile */ +sealed interface NightDisplayTileModel { + val isActivated: Boolean + val isEnrolledInForcedNightDisplayAutoMode: Boolean + data class AutoModeTwilight( + override val isActivated: Boolean, + override val isEnrolledInForcedNightDisplayAutoMode: Boolean, + val isLocationEnabled: Boolean + ) : NightDisplayTileModel + data class AutoModeCustom( + override val isActivated: Boolean, + override val isEnrolledInForcedNightDisplayAutoMode: Boolean, + val startTime: LocalTime?, + val endTime: LocalTime?, + val is24HourFormat: Boolean + ) : NightDisplayTileModel + data class AutoModeOff( + override val isActivated: Boolean, + override val isEnrolledInForcedNightDisplayAutoMode: Boolean + ) : NightDisplayTileModel +} diff --git a/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapper.kt b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..5c2dcfcaf37cce05d031a11b7013a512cb97a55a --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/qs/tiles/impl/night/ui/NightDisplayTileMapper.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.impl.night.ui + +import android.content.res.Resources +import android.service.quicksettings.Tile +import android.text.TextUtils +import androidx.annotation.StringRes +import com.android.systemui.accessibility.qs.QSAccessibilityModule +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.qs.pipeline.shared.TileSpec +import com.android.systemui.qs.tiles.base.interactor.QSTileDataToStateMapper +import com.android.systemui.qs.tiles.base.logging.QSTileLogger +import com.android.systemui.qs.tiles.impl.night.domain.model.NightDisplayTileModel +import com.android.systemui.qs.tiles.viewmodel.QSTileConfig +import com.android.systemui.qs.tiles.viewmodel.QSTileState +import com.android.systemui.res.R +import java.time.DateTimeException +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +/** Maps [NightDisplayTileModel] to [QSTileState]. */ +class NightDisplayTileMapper +@Inject +constructor( + @Main private val resources: Resources, + private val theme: Resources.Theme, + private val logger: QSTileLogger, +) : QSTileDataToStateMapper<NightDisplayTileModel> { + override fun map(config: QSTileConfig, data: NightDisplayTileModel): QSTileState = + QSTileState.build(resources, theme, config.uiConfig) { + label = resources.getString(R.string.quick_settings_night_display_label) + supportedActions = + setOf(QSTileState.UserAction.CLICK, QSTileState.UserAction.LONG_CLICK) + sideViewIcon = QSTileState.SideViewIcon.None + + if (data.isActivated) { + activationState = QSTileState.ActivationState.ACTIVE + val loadedIcon = + Icon.Loaded( + resources.getDrawable(R.drawable.qs_nightlight_icon_on, theme), + contentDescription = null + ) + icon = { loadedIcon } + } else { + activationState = QSTileState.ActivationState.INACTIVE + val loadedIcon = + Icon.Loaded( + resources.getDrawable(R.drawable.qs_nightlight_icon_off, theme), + contentDescription = null + ) + icon = { loadedIcon } + } + + secondaryLabel = getSecondaryLabel(data, resources) + + contentDescription = + if (TextUtils.isEmpty(secondaryLabel)) label + else TextUtils.concat(label, ", ", secondaryLabel) + } + + private fun getSecondaryLabel( + data: NightDisplayTileModel, + resources: Resources + ): CharSequence? { + when (data) { + is NightDisplayTileModel.AutoModeTwilight -> { + if (!data.isLocationEnabled) { + return null + } else { + return resources.getString( + if (data.isActivated) + R.string.quick_settings_night_secondary_label_until_sunrise + else R.string.quick_settings_night_secondary_label_on_at_sunset + ) + } + } + is NightDisplayTileModel.AutoModeOff -> { + val subtitleArray = resources.getStringArray(R.array.tile_states_night) + return subtitleArray[ + if (data.isActivated) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE] + } + is NightDisplayTileModel.AutoModeCustom -> { + // User-specified time, approximated to the nearest hour. + @StringRes val toggleTimeStringRes: Int + val toggleTime: LocalTime + if (data.isActivated) { + toggleTime = data.endTime ?: return null + toggleTimeStringRes = R.string.quick_settings_secondary_label_until + } else { + toggleTime = data.startTime ?: return null + toggleTimeStringRes = R.string.quick_settings_night_secondary_label_on_at + } + + try { + val formatter = if (data.is24HourFormat) formatter24Hour else formatter12Hour + val formatArg = formatter.format(toggleTime) + return resources.getString(toggleTimeStringRes, formatArg) + } catch (exception: DateTimeException) { + logger.logWarning(spec, exception.message.toString()) + return null + } + } + } + } + + private companion object { + val formatter12Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("hh:mm a") + val formatter24Hour: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + val spec = TileSpec.create(QSAccessibilityModule.NIGHT_DISPLAY_TILE_SPEC) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/util/kotlin/LocationControllerExt.kt b/packages/SystemUI/src/com/android/systemui/util/kotlin/LocationControllerExt.kt new file mode 100644 index 0000000000000000000000000000000000000000..ee1b5655f7be6776ce6726c5d1a99c6299389936 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/util/kotlin/LocationControllerExt.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.util.kotlin + +import com.android.systemui.statusbar.policy.LocationController +import com.android.systemui.utils.coroutines.flow.conflatedCallbackFlow +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onStart + +fun LocationController.isLocationEnabledFlow(): Flow<Boolean> { + return conflatedCallbackFlow { + val locationCallback = + object : LocationController.LocationChangeCallback { + override fun onLocationSettingsChanged(locationEnabled: Boolean) { + trySend(locationEnabled) + } + } + addCallback(locationCallback) + awaitClose { removeCallback(locationCallback) } + } + .onStart { emit(isLocationEnabled) } +} diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/night/NightDisplayTileKosmos.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/night/NightDisplayTileKosmos.kt new file mode 100644 index 0000000000000000000000000000000000000000..5c21ab6e7fa8dac9bc5bfb248a0713d4ea4bfb86 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/qs/tiles/impl/night/NightDisplayTileKosmos.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.qs.tiles.impl.night + +import com.android.systemui.accessibility.qs.QSAccessibilityModule +import com.android.systemui.kosmos.Kosmos +import com.android.systemui.qs.qsEventLogger + +val Kosmos.qsNightDisplayTileConfig by + Kosmos.Fixture { QSAccessibilityModule.provideNightDisplayTileConfig(qsEventLogger) }