diff --git a/packages/SystemUI/res/layout/connected_display_chip.xml b/packages/SystemUI/res/layout/connected_display_chip.xml
new file mode 100644
index 0000000000000..d9df91ee0a968
--- /dev/null
+++ b/packages/SystemUI/res/layout/connected_display_chip.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/ConnectedDisplayChip.kt b/packages/SystemUI/src/com/android/systemui/statusbar/ConnectedDisplayChip.kt
new file mode 100644
index 0000000000000..76636ab8e5f35
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/ConnectedDisplayChip.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2023 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.statusbar
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.Configuration
+import android.util.AttributeSet
+import android.widget.FrameLayout
+import com.android.systemui.R
+import com.android.systemui.statusbar.events.BackgroundAnimatableView
+
+/** Chip that appears in the status bar when an external display is connected. */
+class ConnectedDisplayChip
+@JvmOverloads
+constructor(context: Context, attrs: AttributeSet? = null) :
+ FrameLayout(context, attrs), BackgroundAnimatableView {
+
+ private val iconContainer: FrameLayout
+ init {
+ inflate(context, R.layout.connected_display_chip, this)
+ iconContainer = requireViewById(R.id.icons_rounded_container)
+ }
+
+ /**
+ * When animating as a chip in the status bar, we want to animate the width for the rounded
+ * container. We have to subtract our own top and left offset because the bounds come to us as
+ * absolute on-screen bounds.
+ */
+ override fun setBoundsForAnimation(l: Int, t: Int, r: Int, b: Int) {
+ iconContainer.setLeftTopRightBottom(l - left, t - top, r - left, b - top)
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ updateResources()
+ }
+
+ @SuppressLint("UseCompatLoadingForDrawables")
+ private fun updateResources() {
+ iconContainer.background = context.getDrawable(R.drawable.statusbar_chip_bg)
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt
index e5849c05a5344..bde298d7a33d4 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/StatusEvent.kt
@@ -24,6 +24,7 @@ import android.widget.ImageView
import com.android.systemui.privacy.OngoingPrivacyChip
import com.android.systemui.privacy.PrivacyItem
import com.android.systemui.statusbar.BatteryStatusChip
+import com.android.systemui.statusbar.ConnectedDisplayChip
typealias ViewCreator = (context: Context) -> BackgroundAnimatableView
@@ -87,6 +88,23 @@ class BatteryEvent(@IntRange(from = 0, to = 100) val batteryLevel: Int) : Status
}
}
+/** Event that triggers a connected display chip in the status bar. */
+class ConnectedDisplayEvent : StatusEvent {
+ /** Priority is set higher than [BatteryEvent]. */
+ override val priority = 60
+ override var forceVisible = false
+ override val showAnimation = true
+ override var contentDescription: String? = ""
+
+ override val viewCreator: ViewCreator = { context ->
+ ConnectedDisplayChip(context)
+ }
+
+ override fun toString(): String {
+ return javaClass.simpleName
+ }
+}
+
/** open only for testing purposes. (See [FakeStatusEvent.kt]) */
open class PrivacyEvent(override val showAnimation: Boolean = true) : StatusEvent {
override var contentDescription: String? = null
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt
index 26fd2307c59d7..23edf1787649a 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/events/SystemEventCoordinator.kt
@@ -22,6 +22,9 @@ import android.provider.DeviceConfig
import android.provider.DeviceConfig.NAMESPACE_PRIVACY
import com.android.systemui.R
import com.android.systemui.dagger.SysUISingleton
+import com.android.systemui.dagger.qualifiers.Application
+import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor
+import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor.State
import com.android.systemui.flags.FeatureFlags
import com.android.systemui.flags.Flags
import com.android.systemui.privacy.PrivacyChipBuilder
@@ -30,29 +33,45 @@ import com.android.systemui.privacy.PrivacyItemController
import com.android.systemui.statusbar.policy.BatteryController
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
/**
- * Listens for system events (battery, privacy, connectivity) and allows listeners
- * to show status bar animations when they happen
+ * Listens for system events (battery, privacy, connectivity) and allows listeners to show status
+ * bar animations when they happen
*/
@SysUISingleton
-class SystemEventCoordinator @Inject constructor(
+class SystemEventCoordinator
+@Inject
+constructor(
private val systemClock: SystemClock,
private val batteryController: BatteryController,
private val privacyController: PrivacyItemController,
private val context: Context,
- private val featureFlags: FeatureFlags
+ private val featureFlags: FeatureFlags,
+ @Application private val appScope: CoroutineScope,
+ connectedDisplayInteractor: ConnectedDisplayInteractor
) {
+ private val onDisplayConnectedFlow =
+ connectedDisplayInteractor.connectedDisplayState
+ .filter { it != State.DISCONNECTED }
+
+ private var connectedDisplayCollectionJob: Job? = null
private lateinit var scheduler: SystemStatusAnimationScheduler
fun startObserving() {
batteryController.addCallback(batteryStateListener)
privacyController.addCallback(privacyStateListener)
+ startConnectedDisplayCollection()
}
fun stopObserving() {
batteryController.removeCallback(batteryStateListener)
privacyController.removeCallback(privacyStateListener)
+ connectedDisplayCollectionJob?.cancel()
}
fun attachScheduler(s: SystemStatusAnimationScheduler) {
@@ -80,6 +99,13 @@ class SystemEventCoordinator @Inject constructor(
scheduler.onStatusEvent(event)
}
+ private fun startConnectedDisplayCollection() {
+ connectedDisplayCollectionJob =
+ onDisplayConnectedFlow
+ .onEach { scheduler.onStatusEvent(ConnectedDisplayEvent()) }
+ .launchIn(appScope)
+ }
+
private val batteryStateListener = object : BatteryController.BatteryStateChangeCallback {
private var plugged = false
private var stateKnown = false
diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/SystemEventCoordinatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/SystemEventCoordinatorTest.kt
new file mode 100644
index 0000000000000..786856b0baa6b
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/events/SystemEventCoordinatorTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 2023 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.statusbar.events
+
+import android.testing.AndroidTestingRunner
+import android.testing.TestableLooper
+import androidx.test.filters.SmallTest
+import com.android.systemui.SysuiTestCase
+import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor
+import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor.State.CONNECTED
+import com.android.systemui.flags.FakeFeatureFlags
+import com.android.systemui.privacy.PrivacyItemController
+import com.android.systemui.statusbar.policy.BatteryController
+import com.android.systemui.util.mockito.any
+import com.android.systemui.util.time.FakeSystemClock
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import org.mockito.MockitoAnnotations
+
+@RunWith(AndroidTestingRunner::class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+@SmallTest
+@OptIn(ExperimentalCoroutinesApi::class)
+class SystemEventCoordinatorTest : SysuiTestCase() {
+
+ private val fakeSystemClock = FakeSystemClock()
+ private val featureFlags = FakeFeatureFlags()
+ private val testScope = TestScope(UnconfinedTestDispatcher())
+ private val connectedDisplayInteractor = FakeConnectedDisplayInteractor()
+
+ @Mock lateinit var batteryController: BatteryController
+ @Mock lateinit var privacyController: PrivacyItemController
+ @Mock lateinit var scheduler: SystemStatusAnimationScheduler
+
+ private lateinit var systemEventCoordinator: SystemEventCoordinator
+ @Before
+ fun setup() {
+ MockitoAnnotations.initMocks(this)
+ systemEventCoordinator =
+ SystemEventCoordinator(
+ fakeSystemClock,
+ batteryController,
+ privacyController,
+ context,
+ featureFlags,
+ TestScope(UnconfinedTestDispatcher()),
+ connectedDisplayInteractor
+ )
+ .apply { attachScheduler(scheduler) }
+ }
+
+ @Test
+ fun startObserving_propagatesConnectedDisplayStatusEvents() =
+ testScope.runTest {
+ systemEventCoordinator.startObserving()
+
+ connectedDisplayInteractor.emit(CONNECTED)
+ connectedDisplayInteractor.emit(CONNECTED)
+
+ verify(scheduler, times(2)).onStatusEvent(any())
+ }
+
+ @Test
+ fun stopObserving_doesNotPropagateConnectedDisplayStatusEvents() =
+ testScope.runTest {
+ systemEventCoordinator.startObserving()
+
+ connectedDisplayInteractor.emit(CONNECTED)
+
+ verify(scheduler).onStatusEvent(any())
+
+ systemEventCoordinator.stopObserving()
+
+ connectedDisplayInteractor.emit(CONNECTED)
+
+ verifyNoMoreInteractions(scheduler)
+ }
+
+ class FakeConnectedDisplayInteractor : ConnectedDisplayInteractor {
+ private val flow = MutableSharedFlow()
+ suspend fun emit(value: ConnectedDisplayInteractor.State) = flow.emit(value)
+ override val connectedDisplayState: Flow
+ get() = flow
+ }
+}