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) }