From 169389289bdb2eb8e1ea0328bb151fa5eb42bd23 Mon Sep 17 00:00:00 2001 From: Nicolo' Mazzucato Date: Thu, 6 Jul 2023 12:30:39 +0000 Subject: [PATCH] Add animated chip in statusbar on external display connected This adds an animated chip appearing for ~3s on the top right corner when an external display is connected, in a similar way as the battery charging animation. The battery event arrives much earlier than the display becomes available. For this reason, when a display is connected first the battery chip is shown, then it is animated away and after ~1s the connected display icon is animated in. Test: SystemEventCoordinatorTest Bug: 289883166 Change-Id: Ia689a6519fad70ae3157d1da2a69473be701c38f --- .../res/layout/connected_display_chip.xml | 48 ++++++++ .../statusbar/ConnectedDisplayChip.kt | 55 +++++++++ .../systemui/statusbar/events/StatusEvent.kt | 18 +++ .../events/SystemEventCoordinator.kt | 34 +++++- .../events/SystemEventCoordinatorTest.kt | 109 ++++++++++++++++++ 5 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 packages/SystemUI/res/layout/connected_display_chip.xml create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/ConnectedDisplayChip.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/events/SystemEventCoordinatorTest.kt 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 + } +}