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 bd0ad3a4480e5..85e9792fc99b3 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -3100,6 +3100,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/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/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/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
+ }
+}
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
+ }
}