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
This commit is contained in:
48
packages/SystemUI/res/layout/connected_display_chip.xml
Normal file
48
packages/SystemUI/res/layout/connected_display_chip.xml
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
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.
|
||||
-->
|
||||
|
||||
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/connected_display_chip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center_vertical|end"
|
||||
tools:parentTag="com.android.systemui.statusbar.ConnectedDisplayChip">
|
||||
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/icons_rounded_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/ongoing_appops_chip_height"
|
||||
android:layout_gravity="center"
|
||||
android:background="@drawable/statusbar_chip_bg"
|
||||
android:clipToOutline="true"
|
||||
android:clipToPadding="false"
|
||||
android:gravity="center"
|
||||
android:maxWidth="@dimen/ongoing_appops_chip_max_width"
|
||||
android:minWidth="@dimen/ongoing_appops_chip_min_width">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="10dp"
|
||||
android:scaleType="centerInside"
|
||||
android:src="@drawable/stat_sys_connected_display"
|
||||
android:tint="@android:color/black" />
|
||||
</FrameLayout>
|
||||
</merge>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ConnectedDisplayEvent>())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopObserving_doesNotPropagateConnectedDisplayStatusEvents() =
|
||||
testScope.runTest {
|
||||
systemEventCoordinator.startObserving()
|
||||
|
||||
connectedDisplayInteractor.emit(CONNECTED)
|
||||
|
||||
verify(scheduler).onStatusEvent(any<ConnectedDisplayEvent>())
|
||||
|
||||
systemEventCoordinator.stopObserving()
|
||||
|
||||
connectedDisplayInteractor.emit(CONNECTED)
|
||||
|
||||
verifyNoMoreInteractions(scheduler)
|
||||
}
|
||||
|
||||
class FakeConnectedDisplayInteractor : ConnectedDisplayInteractor {
|
||||
private val flow = MutableSharedFlow<ConnectedDisplayInteractor.State>()
|
||||
suspend fun emit(value: ConnectedDisplayInteractor.State) = flow.emit(value)
|
||||
override val connectedDisplayState: Flow<ConnectedDisplayInteractor.State>
|
||||
get() = flow
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user