From 3214adb648f49cbce4b4ce51f8282ae4a337c7a2 Mon Sep 17 00:00:00 2001 From: Nicolo' Mazzucato Date: Thu, 22 Jun 2023 16:24:28 +0000 Subject: [PATCH 1/2] Introduce ConnectedDisplayInteractor This class provides the status of external connected devices though a Flow. Used in child cls to add a status bar icon when an external display is connected. Bug: 286186256 Test: ConnectedDisplayInteractorTest Change-Id: Ib31a9bcbd2062488eed4b4933b814929704a4ff2 --- .../systemui/dagger/SystemUIModule.java | 2 + .../android/systemui/display/DisplayModule.kt | 35 +++++ .../interactor/ConnectedDisplayInteractor.kt | 73 +++++++++ .../ConnectedDisplayInteractorTest.kt | 148 ++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 packages/SystemUI/src/com/android/systemui/display/DisplayModule.kt create mode 100644 packages/SystemUI/src/com/android/systemui/display/domain/interactor/ConnectedDisplayInteractor.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/display/domain/interactor/ConnectedDisplayInteractorTest.kt diff --git a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java index 9ab9e7a76a25e..8f3c3d6e1dd50 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/SystemUIModule.java @@ -47,6 +47,7 @@ import com.android.systemui.controls.dagger.ControlsModule; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dagger.qualifiers.SystemUser; import com.android.systemui.demomode.dagger.DemoModeModule; +import com.android.systemui.display.DisplayModule; import com.android.systemui.doze.dagger.DozeComponent; import com.android.systemui.dreams.dagger.DreamModule; import com.android.systemui.dump.DumpManager; @@ -163,6 +164,7 @@ import javax.inject.Named; ClipboardOverlayModule.class, ClockRegistryModule.class, CommonRepositoryModule.class, + DisplayModule.class, ConnectivityModule.class, CoroutinesModule.class, DreamModule.class, diff --git a/packages/SystemUI/src/com/android/systemui/display/DisplayModule.kt b/packages/SystemUI/src/com/android/systemui/display/DisplayModule.kt new file mode 100644 index 0000000000000..65cd84bc4da1f --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/display/DisplayModule.kt @@ -0,0 +1,35 @@ +/* + * 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.display + +import com.android.systemui.display.data.repository.DisplayRepository +import com.android.systemui.display.data.repository.DisplayRepositoryImpl +import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor +import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractorImpl +import dagger.Binds +import dagger.Module + +/** Module binding display related classes. */ +@Module +interface DisplayModule { + @Binds + fun bindConnectedDisplayInteractor( + provider: ConnectedDisplayInteractorImpl + ): ConnectedDisplayInteractor + + @Binds fun bindsDisplayRepository(displayRepository: DisplayRepositoryImpl): DisplayRepository +} diff --git a/packages/SystemUI/src/com/android/systemui/display/domain/interactor/ConnectedDisplayInteractor.kt b/packages/SystemUI/src/com/android/systemui/display/domain/interactor/ConnectedDisplayInteractor.kt new file mode 100644 index 0000000000000..4b957c7f435cd --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/display/domain/interactor/ConnectedDisplayInteractor.kt @@ -0,0 +1,73 @@ +/* + * 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.display.domain.interactor + +import android.view.Display +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.display.data.repository.DisplayRepository +import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor.State +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** Provides information about an external connected display. */ +interface ConnectedDisplayInteractor { + /** + * Provides the current external display state. + * + * The state is: + * - [State.CONNECTED] when there is at least one display with [TYPE_EXTERNAL]. + * - [State.CONNECTED_SECURE] when is at least one display with both [TYPE_EXTERNAL] AND + * [Display.FLAG_SECURE] set + */ + val connectedDisplayState: Flow + + /** Possible connected display state. */ + enum class State { + DISCONNECTED, + CONNECTED, + CONNECTED_SECURE, + } +} + +@SysUISingleton +class ConnectedDisplayInteractorImpl +@Inject +constructor( + displayRepository: DisplayRepository, +) : ConnectedDisplayInteractor { + + override val connectedDisplayState: Flow = + displayRepository.displays + .map { displays -> + val externalDisplays = + displays.filter { display -> display.type == Display.TYPE_EXTERNAL } + + val secureExternalDisplays = + externalDisplays.filter { it.flags and Display.FLAG_SECURE != 0 } + + if (externalDisplays.isEmpty()) { + State.DISCONNECTED + } else if (!secureExternalDisplays.isEmpty()) { + State.CONNECTED_SECURE + } else { + State.CONNECTED + } + } + .distinctUntilChanged() +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/display/domain/interactor/ConnectedDisplayInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/display/domain/interactor/ConnectedDisplayInteractorTest.kt new file mode 100644 index 0000000000000..1b597f44d23e7 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/display/domain/interactor/ConnectedDisplayInteractorTest.kt @@ -0,0 +1,148 @@ +/* + * 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.display.domain.interactor + +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.view.Display +import android.view.Display.TYPE_EXTERNAL +import android.view.Display.TYPE_INTERNAL +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.FlowValue +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.display.data.repository.DisplayRepository +import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor.State +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +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.Test +import org.junit.runner.RunWith + +@RunWith(AndroidTestingRunner::class) +@TestableLooper.RunWithLooper +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +class ConnectedDisplayInteractorTest : SysuiTestCase() { + + private val fakeDisplayRepository = FakeDisplayRepository() + private val connectedDisplayStateProvider: ConnectedDisplayInteractor = + ConnectedDisplayInteractorImpl(fakeDisplayRepository) + private val testScope = TestScope(UnconfinedTestDispatcher()) + + @Test + fun displayState_nullDisplays_disconnected() = + testScope.runTest { + val value by lastValue() + + fakeDisplayRepository.emit(emptySet()) + + assertThat(value).isEqualTo(State.DISCONNECTED) + } + + @Test + fun displayState_emptyDisplays_disconnected() = + testScope.runTest { + val value by lastValue() + + fakeDisplayRepository.emit(emptySet()) + + assertThat(value).isEqualTo(State.DISCONNECTED) + } + + @Test + fun displayState_internalDisplay_disconnected() = + testScope.runTest { + val value by lastValue() + + fakeDisplayRepository.emit(setOf(display(type = TYPE_INTERNAL))) + + assertThat(value).isEqualTo(State.DISCONNECTED) + } + + @Test + fun displayState_externalDisplay_connected() = + testScope.runTest { + val value by lastValue() + + fakeDisplayRepository.emit(setOf(display(type = TYPE_EXTERNAL))) + + assertThat(value).isEqualTo(State.CONNECTED) + } + + @Test + fun displayState_multipleExternalDisplays_connected() = + testScope.runTest { + val value by lastValue() + + fakeDisplayRepository.emit( + setOf(display(type = TYPE_EXTERNAL), display(type = TYPE_EXTERNAL)) + ) + + assertThat(value).isEqualTo(State.CONNECTED) + } + + @Test + fun displayState_externalSecure_connectedSecure() = + testScope.runTest { + val value by lastValue() + + fakeDisplayRepository.emit( + setOf(display(type = TYPE_EXTERNAL, flags = Display.FLAG_SECURE)) + ) + + assertThat(value).isEqualTo(State.CONNECTED_SECURE) + } + + @Test + fun displayState_multipleExternal_onlyOneSecure_connectedSecure() = + testScope.runTest { + val value by lastValue() + + fakeDisplayRepository.emit( + setOf( + display(type = TYPE_EXTERNAL, flags = Display.FLAG_SECURE), + display(type = TYPE_EXTERNAL, flags = 0) + ) + ) + + assertThat(value).isEqualTo(State.CONNECTED_SECURE) + } + + private fun TestScope.lastValue(): FlowValue = + collectLastValue(connectedDisplayStateProvider.connectedDisplayState) + + private fun display(type: Int, flags: Int = 0): Display { + return mock().also { mockDisplay -> + whenever(mockDisplay.type).thenReturn(type) + whenever(mockDisplay.flags).thenReturn(flags) + } + } + + private class FakeDisplayRepository : DisplayRepository { + private val flow = MutableSharedFlow>() + suspend fun emit(value: Set) = flow.emit(value) + override val displays: Flow> + get() = flow + } +} From cfd20f728ea5544771245a7a201df3bc203c6c0e Mon Sep 17 00:00:00 2001 From: Nicolo' Mazzucato Date: Thu, 22 Jun 2023 16:28:24 +0000 Subject: [PATCH 2/2] Add status bar icon when a connected display is attached Adds a "display" icon to the status bar when there is at least one external display attached. Bug: 286186256 Test: PhoneStatusBarPolicyTest Change-Id: I0eaf74732a5d97a8a694e9cad55ac529154ef47b --- core/res/res/values/config.xml | 2 + core/res/res/values/symbols.xml | 1 + .../drawable/stat_sys_connected_display.xml | 25 ++++++ .../statusbar/phone/PhoneStatusBarPolicy.java | 30 ++++++- .../phone/PhoneStatusBarPolicyTest.kt | 79 ++++++++++++++++++- 5 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 packages/SystemUI/res/drawable/stat_sys_connected_display.xml diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml index f27535c65fe49..3be0d7ff06afb 100644 --- a/core/res/res/values/config.xml +++ b/core/res/res/values/config.xml @@ -46,6 +46,7 @@ @string/status_bar_secure @string/status_bar_managed_profile @string/status_bar_cast + @string/status_bar_connected_display @string/status_bar_screen_record @string/status_bar_vpn @string/status_bar_bluetooth @@ -72,6 +73,7 @@ sync_failing sync_active cast + connected_display hotspot location bluetooth diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index 2e5da33537526..83e3cb0787084 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -3099,6 +3099,7 @@ + diff --git a/packages/SystemUI/res/drawable/stat_sys_connected_display.xml b/packages/SystemUI/res/drawable/stat_sys_connected_display.xml new file mode 100644 index 0000000000000..3f3d6f573f44c --- /dev/null +++ b/packages/SystemUI/res/drawable/stat_sys_connected_display.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicy.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicy.java index e6b76ad0e00cb..3b5aaeac6c211 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicy.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicy.java @@ -48,6 +48,7 @@ import com.android.systemui.broadcast.BroadcastDispatcher; import com.android.systemui.dagger.qualifiers.DisplayId; import com.android.systemui.dagger.qualifiers.Main; import com.android.systemui.dagger.qualifiers.UiBackground; +import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor; import com.android.systemui.privacy.PrivacyItem; import com.android.systemui.privacy.PrivacyItemController; import com.android.systemui.privacy.PrivacyType; @@ -74,6 +75,7 @@ import com.android.systemui.statusbar.policy.SensorPrivacyController; import com.android.systemui.statusbar.policy.UserInfoController; import com.android.systemui.statusbar.policy.ZenModeController; import com.android.systemui.util.RingerModeTracker; +import com.android.systemui.util.kotlin.JavaAdapter; import com.android.systemui.util.time.DateFormatUtil; import java.io.PrintWriter; @@ -121,9 +123,12 @@ public class PhoneStatusBarPolicy private final String mSlotCamera; private final String mSlotSensorsOff; private final String mSlotScreenRecord; + private final String mSlotConnectedDisplay; private final int mDisplayId; private final SharedPreferences mSharedPreferences; private final DateFormatUtil mDateFormatUtil; + private final JavaAdapter mJavaAdapter; + private final ConnectedDisplayInteractor mConnectedDisplayInteractor; private final TelecomManager mTelecomManager; private final Handler mHandler; @@ -182,9 +187,13 @@ public class PhoneStatusBarPolicy @Main SharedPreferences sharedPreferences, DateFormatUtil dateFormatUtil, RingerModeTracker ringerModeTracker, PrivacyItemController privacyItemController, - PrivacyLogger privacyLogger) { + PrivacyLogger privacyLogger, + ConnectedDisplayInteractor connectedDisplayInteractor, + JavaAdapter javaAdapter + ) { mIconController = iconController; mCommandQueue = commandQueue; + mConnectedDisplayInteractor = connectedDisplayInteractor; mBroadcastDispatcher = broadcastDispatcher; mHandler = new Handler(looper); mResources = resources; @@ -211,8 +220,11 @@ public class PhoneStatusBarPolicy mTelecomManager = telecomManager; mRingerModeTracker = ringerModeTracker; mPrivacyLogger = privacyLogger; + mJavaAdapter = javaAdapter; mSlotCast = resources.getString(com.android.internal.R.string.status_bar_cast); + mSlotConnectedDisplay = resources.getString( + com.android.internal.R.string.status_bar_connected_display); mSlotHotspot = resources.getString(com.android.internal.R.string.status_bar_hotspot); mSlotBluetooth = resources.getString(com.android.internal.R.string.status_bar_bluetooth); mSlotTty = resources.getString(com.android.internal.R.string.status_bar_tty); @@ -285,6 +297,10 @@ public class PhoneStatusBarPolicy mIconController.setIcon(mSlotCast, R.drawable.stat_sys_cast, null); mIconController.setIconVisibility(mSlotCast, false); + // connected display + mIconController.setIcon(mSlotConnectedDisplay, R.drawable.stat_sys_connected_display, null); + mIconController.setIconVisibility(mSlotConnectedDisplay, false); + // hotspot mIconController.setIcon(mSlotHotspot, R.drawable.stat_sys_hotspot, mResources.getString(R.string.accessibility_status_bar_hotspot)); @@ -342,6 +358,8 @@ public class PhoneStatusBarPolicy mSensorPrivacyController.addCallback(mSensorPrivacyListener); mLocationController.addCallback(this); mRecordingController.addCallback(this); + mJavaAdapter.alwaysCollectFlow(mConnectedDisplayInteractor.getConnectedDisplayState(), + this::onConnectedDisplayAvailabilityChanged); mCommandQueue.addCallback(this); } @@ -800,4 +818,14 @@ public class PhoneStatusBarPolicy if (DEBUG) Log.d(TAG, "screenrecord: hiding icon"); mHandler.post(() -> mIconController.setIconVisibility(mSlotScreenRecord, false)); } + + private void onConnectedDisplayAvailabilityChanged(ConnectedDisplayInteractor.State state) { + boolean visible = state != ConnectedDisplayInteractor.State.DISCONNECTED; + + if (DEBUG) { + Log.d(TAG, "connected_display: " + (visible ? "showing" : "hiding") + " icon"); + } + + mIconController.setIconVisibility(mSlotConnectedDisplay, visible); + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicyTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicyTest.kt index 6b18169bcd864..85fbef0d7bb6a 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicyTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/PhoneStatusBarPolicyTest.kt @@ -27,6 +27,8 @@ import android.testing.TestableLooper.RunWithLooper import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor +import com.android.systemui.display.domain.interactor.ConnectedDisplayInteractor.State import com.android.systemui.privacy.PrivacyItemController import com.android.systemui.privacy.logging.PrivacyLogger import com.android.systemui.screenrecord.RecordingController @@ -46,9 +48,17 @@ import com.android.systemui.statusbar.policy.UserInfoController import com.android.systemui.statusbar.policy.ZenModeController import com.android.systemui.util.RingerModeTracker import com.android.systemui.util.concurrency.FakeExecutor +import com.android.systemui.util.kotlin.JavaAdapter import com.android.systemui.util.mockito.capture import com.android.systemui.util.time.DateFormatUtil 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.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -57,6 +67,8 @@ import org.mockito.ArgumentCaptor import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito.anyInt +import org.mockito.Mockito.clearInvocations +import org.mockito.Mockito.inOrder import org.mockito.Mockito.never import org.mockito.Mockito.verify import org.mockito.Mockito.`when` as whenever @@ -64,11 +76,13 @@ import org.mockito.MockitoAnnotations @RunWith(AndroidTestingRunner::class) @RunWithLooper +@OptIn(ExperimentalCoroutinesApi::class) @SmallTest class PhoneStatusBarPolicyTest : SysuiTestCase() { companion object { private const val ALARM_SLOT = "alarm" + private const val CONNECTED_DISPLAY_SLOT = "connected_display" } @Mock private lateinit var iconController: StatusBarIconController @@ -102,6 +116,9 @@ class PhoneStatusBarPolicyTest : SysuiTestCase() { private lateinit var alarmCallbackCaptor: ArgumentCaptor + private val testScope = TestScope(UnconfinedTestDispatcher()) + private val fakeConnectedDisplayStateProvider = FakeConnectedDisplayStateProvider() + private lateinit var executor: FakeExecutor private lateinit var statusBarPolicy: PhoneStatusBarPolicy private lateinit var testableLooper: TestableLooper @@ -164,6 +181,57 @@ class PhoneStatusBarPolicyTest : SysuiTestCase() { verify(iconController).setIconVisibility(ALARM_SLOT, true) } + @Test + fun connectedDisplay_connected_iconShown() = + testScope.runTest { + statusBarPolicy.init() + clearInvocations(iconController) + + fakeConnectedDisplayStateProvider.emit(State.CONNECTED) + runCurrent() + + verify(iconController).setIconVisibility(CONNECTED_DISPLAY_SLOT, true) + } + + @Test + fun connectedDisplay_disconnected_iconHidden() = + testScope.runTest { + statusBarPolicy.init() + clearInvocations(iconController) + + fakeConnectedDisplayStateProvider.emit(State.DISCONNECTED) + + verify(iconController).setIconVisibility(CONNECTED_DISPLAY_SLOT, false) + } + + @Test + fun connectedDisplay_disconnectedThenConnected_iconShown() = + testScope.runTest { + statusBarPolicy.init() + clearInvocations(iconController) + + fakeConnectedDisplayStateProvider.emit(State.CONNECTED) + fakeConnectedDisplayStateProvider.emit(State.DISCONNECTED) + fakeConnectedDisplayStateProvider.emit(State.CONNECTED) + + inOrder(iconController).apply { + verify(iconController).setIconVisibility(CONNECTED_DISPLAY_SLOT, true) + verify(iconController).setIconVisibility(CONNECTED_DISPLAY_SLOT, false) + verify(iconController).setIconVisibility(CONNECTED_DISPLAY_SLOT, true) + } + } + + @Test + fun connectedDisplay_connectSecureDisplay_iconShown() = + testScope.runTest { + statusBarPolicy.init() + clearInvocations(iconController) + + fakeConnectedDisplayStateProvider.emit(State.CONNECTED_SECURE) + + verify(iconController).setIconVisibility(CONNECTED_DISPLAY_SLOT, true) + } + private fun createAlarmInfo(): AlarmManager.AlarmClockInfo { return AlarmManager.AlarmClockInfo(10L, null) } @@ -200,7 +268,16 @@ class PhoneStatusBarPolicyTest : SysuiTestCase() { dateFormatUtil, ringerModeTracker, privacyItemController, - privacyLogger + privacyLogger, + fakeConnectedDisplayStateProvider, + JavaAdapter(testScope.backgroundScope) ) } + + private class FakeConnectedDisplayStateProvider : ConnectedDisplayInteractor { + private val flow = MutableSharedFlow() + suspend fun emit(value: State) = flow.emit(value) + override val connectedDisplayState: Flow + get() = flow + } }