[Ongoing Call Chip] Add an ongoing call chip in the status bar.

Light mode: https://screenshot.googleplex.com/3uehhoJ4EYHQr47
Dark mode: https://screenshot.googleplex.com/7ymFjTNUhQmUhZA

Bug: 183229367
Test: New tests added, atest, and manual
Change-Id: Id9d9e8f0436e228beb3f2c5f5a9a81f6a07aa033
This commit is contained in:
Caitlin Cassidy
2021-03-24 15:26:00 +00:00
parent dcc1a906cf
commit 2cbafc1941
11 changed files with 391 additions and 10 deletions

View File

@@ -0,0 +1,20 @@
<!--
~ Copyright (C) 2021 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.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="?android:attr/colorAccent" />
<corners android:radius="@dimen/ongoing_call_chip_corner_radius" />
</shape>

View File

@@ -0,0 +1,50 @@
<!--
~ Copyright (C) 2021 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.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/ongoing_call_chip"
android:layout_width="wrap_content"
android:layout_height="@dimen/ongoing_appops_chip_height"
android:layout_gravity="center_vertical|start"
android:gravity="center_vertical"
android:background="@drawable/ongoing_call_chip_bg"
android:paddingStart="@dimen/ongoing_call_chip_side_padding"
android:paddingEnd="@dimen/ongoing_call_chip_side_padding"
android:visibility="gone"
>
<ImageView
android:src="@*android:drawable/ic_phone"
android:layout_width="@dimen/ongoing_call_chip_icon_size"
android:layout_height="@dimen/ongoing_call_chip_icon_size"
android:paddingEnd="@dimen/ongoing_call_chip_icon_text_padding"
android:tint="?android:attr/colorPrimary"
/>
<!-- TODO(b/183229367): The text in this view isn't quite centered within the chip. -->
<!-- TODO(b/183229367): This text view's width shouldn't change as the time increases. -->
<Chronometer
android:id="@+id/ongoing_call_chip_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:gravity="center"
android:textAppearance="@android:style/TextAppearance.Material.Small"
android:fontFamily="@*android:string/config_headlineFontFamily"
android:textColor="?android:attr/colorPrimary"
/>
</LinearLayout>

View File

@@ -82,6 +82,8 @@
android:gravity="center_vertical|start"
/>
<include layout="@layout/ongoing_call_chip" />
<com.android.systemui.statusbar.AlphaOptimizedFrameLayout
android:id="@+id/notification_icon_area"
android:layout_width="0dp"

View File

@@ -1441,4 +1441,11 @@
<dimen name="wallet_empty_state_corner_radius">24dp</dimen>
<dimen name="wallet_tile_card_view_height">32dp</dimen>
<dimen name="wallet_tile_card_view_width">50dp</dimen>
<!-- Ongoing call chip -->
<dimen name="ongoing_call_chip_side_padding">12dp</dimen>
<dimen name="ongoing_call_chip_icon_size">16dp</dimen>
<!-- The padding between the icon and the text. -->
<dimen name="ongoing_call_chip_icon_text_padding">4dp</dimen>
<dimen name="ongoing_call_chip_corner_radius">28dp</dimen>
</resources>

View File

@@ -49,7 +49,7 @@
<bool name="flag_charging_ripple">false</bool>
<bool name="flag_ongoing_call_status_bar_chip">false</bool>
<bool name="flag_ongoing_call_status_bar_chip">true</bool>
<bool name="flag_smartspace">false</bool>
</resources>

View File

@@ -63,6 +63,7 @@ import com.android.systemui.statusbar.policy.RemoteInputUriController;
import com.android.systemui.tracing.ProtoTracer;
import com.android.systemui.util.DeviceConfigProxy;
import com.android.systemui.util.concurrency.DelayableExecutor;
import com.android.systemui.util.time.SystemClock;
import com.android.wm.shell.bubbles.Bubbles;
import java.util.Optional;
@@ -234,9 +235,11 @@ public interface StatusBarDependenciesModule {
@Provides
@SysUISingleton
static OngoingCallController provideOngoingCallController(
CommonNotifCollection notifCollection, FeatureFlags featureFlags) {
CommonNotifCollection notifCollection,
FeatureFlags featureFlags,
SystemClock systemClock) {
OngoingCallController ongoingCallController =
new OngoingCallController(notifCollection, featureFlags);
new OngoingCallController(notifCollection, featureFlags, systemClock);
ongoingCallController.init();
return ongoingCallController;
}

View File

@@ -38,6 +38,7 @@ import com.android.systemui.statusbar.CommandQueue;
import com.android.systemui.statusbar.StatusBarState;
import com.android.systemui.statusbar.phone.StatusBarIconController.DarkIconManager;
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallController;
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallListener;
import com.android.systemui.statusbar.policy.EncryptionHelper;
import com.android.systemui.statusbar.policy.KeyguardStateController;
import com.android.systemui.statusbar.policy.NetworkController;
@@ -67,6 +68,7 @@ public class CollapsedStatusBarFragment extends Fragment implements CommandQueue
private NetworkController mNetworkController;
private LinearLayout mSystemIconArea;
private View mClockView;
private ViewGroup mOngoingCallChip;
private View mNotificationIconAreaInner;
private View mCenteredIconArea;
private int mDisabled1;
@@ -86,6 +88,20 @@ public class CollapsedStatusBarFragment extends Fragment implements CommandQueue
}
};
private final OngoingCallListener mOngoingCallListener = new OngoingCallListener() {
@Override
public void onOngoingCallStarted(boolean animate) {
disable(getContext().getDisplayId(), mDisabled1, mDisabled2, animate);
animateShow(mOngoingCallChip, animate);
}
@Override
public void onOngoingCallEnded(boolean animate) {
disable(getContext().getDisplayId(), mDisabled1, mDisabled2, animate);
animateHiddenState(mOngoingCallChip, View.GONE, animate);
}
};
@Inject
public CollapsedStatusBarFragment(OngoingCallController ongoingCallController) {
mOngoingCallController = ongoingCallController;
@@ -142,6 +158,7 @@ public class CollapsedStatusBarFragment extends Fragment implements CommandQueue
super.onResume();
mCommandQueue.addCallback(this);
mStatusBarStateController.addCallback(this);
mOngoingCallController.addCallback(mOngoingCallListener);
}
@Override
@@ -149,6 +166,7 @@ public class CollapsedStatusBarFragment extends Fragment implements CommandQueue
super.onPause();
mCommandQueue.removeCallback(this);
mStatusBarStateController.removeCallback(this);
mOngoingCallController.removeCallback(mOngoingCallListener);
}
@Override
@@ -179,6 +197,8 @@ public class CollapsedStatusBarFragment extends Fragment implements CommandQueue
}
statusBarCenteredIconArea.addView(mCenteredIconArea);
initOngoingCallChip();
// Default to showing until we know otherwise.
showNotificationIconArea(false);
}
@@ -228,6 +248,10 @@ public class CollapsedStatusBarFragment extends Fragment implements CommandQueue
state |= DISABLE_CLOCK;
}
if (mOngoingCallController.getHasOngoingCall()) {
state |= DISABLE_NOTIFICATION_ICONS;
}
if (!mKeyguardStateController.isLaunchTransitionFadingAway()
&& !mKeyguardStateController.isKeyguardFadingAway()
&& shouldHideNotificationIcons()
@@ -395,6 +419,11 @@ public class CollapsedStatusBarFragment extends Fragment implements CommandQueue
}
}
private void initOngoingCallChip() {
mOngoingCallChip = mStatusBar.findViewById(R.id.ongoing_call_chip);
mOngoingCallController.setChipView(mOngoingCallChip);
}
@Override
public void onStateChanged(int newState) {

View File

@@ -19,11 +19,16 @@ package com.android.systemui.statusbar.phone.ongoingcall
import android.app.Notification
import android.app.Notification.CallStyle.CALL_TYPE_ONGOING
import android.util.Log
import android.view.ViewGroup
import android.widget.Chronometer
import com.android.systemui.R
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.statusbar.FeatureFlags
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
import com.android.systemui.statusbar.policy.CallbackController
import com.android.systemui.util.time.SystemClock
import javax.inject.Inject
/**
@@ -32,19 +37,39 @@ import javax.inject.Inject
@SysUISingleton
class OngoingCallController @Inject constructor(
private val notifCollection: CommonNotifCollection,
private val featureFlags: FeatureFlags
) {
private val featureFlags: FeatureFlags,
private val systemClock: SystemClock
) : CallbackController<OngoingCallListener> {
var hasOngoingCall = false
private set
private var chipView: ViewGroup? = null
private val mListeners: MutableList<OngoingCallListener> = mutableListOf()
private val notifListener = object : NotifCollectionListener {
override fun onEntryUpdated(entry: NotificationEntry) {
if (isOngoingCallNotification(entry) && DEBUG) {
Log.d(TAG, "Ongoing call notification updated")
if (isOngoingCallNotification(entry)) {
val timeView = chipView?.findViewById<Chronometer>(R.id.ongoing_call_chip_time)
if (timeView != null) {
hasOngoingCall = true
val callStartTime = entry.sbn.notification.`when`
timeView.base = callStartTime -
System.currentTimeMillis() +
systemClock.elapsedRealtime()
timeView.start()
mListeners.forEach { l -> l.onOngoingCallStarted(animate = true) }
} else if (DEBUG) {
Log.w(TAG, "Ongoing call chip view could not be found; " +
"Not displaying chip in status bar")
}
}
}
override fun onEntryRemoved(entry: NotificationEntry, reason: Int) {
if (isOngoingCallNotification(entry) && DEBUG) {
Log.d(TAG, "Ongoing call notification removed")
if (isOngoingCallNotification(entry)) {
hasOngoingCall = false
mListeners.forEach { l -> l.onOngoingCallEnded(animate = true) }
}
}
}
@@ -54,6 +79,24 @@ class OngoingCallController @Inject constructor(
notifCollection.addCollectionListener(notifListener)
}
}
fun setChipView(chipView: ViewGroup?) {
this.chipView = chipView
}
override fun addCallback(listener: OngoingCallListener) {
synchronized(mListeners) {
if (!mListeners.contains(listener)) {
mListeners.add(listener)
}
}
}
override fun removeCallback(listener: OngoingCallListener) {
synchronized(mListeners) {
mListeners.remove(listener)
}
}
}
private fun isOngoingCallNotification(entry: NotificationEntry): Boolean {

View File

@@ -0,0 +1,26 @@
/*
* Copyright (C) 2021 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.phone.ongoingcall
/** A listener that's notified when an ongoing call is started or ended. */
interface OngoingCallListener {
/** Called when an ongoing call is started. */
fun onOngoingCallStarted(animate: Boolean)
/** Called when an ongoing call is ended. */
fun onOngoingCallEnded(animate: Boolean)
}

View File

@@ -38,12 +38,16 @@ import com.android.systemui.R;
import com.android.systemui.SysuiBaseFragmentTest;
import com.android.systemui.plugins.statusbar.StatusBarStateController;
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallController;
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallListener;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import java.util.Objects;
@RunWith(AndroidTestingRunner.class)
@RunWithLooper(setAsMainLooper = true)
@SmallTest
@@ -53,6 +57,7 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest {
private View mNotificationAreaInner;
private View mCenteredNotificationAreaView;
private StatusBarStateController mStatusBarStateController;
private OngoingCallController mOngoingCallController;
public CollapsedStatusBarFragmentTest() {
super(CollapsedStatusBarFragment.class);
@@ -162,8 +167,51 @@ public class CollapsedStatusBarFragmentTest extends SysuiBaseFragmentTest {
Mockito.verify(mNotificationAreaInner, atLeast(1)).setVisibility(eq(View.VISIBLE));
}
@Test
public void onOngoingCallStarted_notificationsHiddenAndOngoingCallChipDisplayed() {
mFragments.dispatchResume();
processAllMessages();
CollapsedStatusBarFragment fragment = (CollapsedStatusBarFragment) mFragment;
fragment.initNotificationIconArea(mMockNotificiationAreaController);
ArgumentCaptor<OngoingCallListener> ongoingCallListenerCaptor = ArgumentCaptor.forClass(
OngoingCallListener.class);
Mockito.verify(mOngoingCallController).addCallback(ongoingCallListenerCaptor.capture());
OngoingCallListener listener = Objects.requireNonNull(ongoingCallListenerCaptor.getValue());
when(mOngoingCallController.getHasOngoingCall()).thenReturn(true);
listener.onOngoingCallStarted(/* animate= */ false);
assertEquals(View.VISIBLE,
mFragment.getView().findViewById(R.id.ongoing_call_chip).getVisibility());
Mockito.verify(mNotificationAreaInner, atLeast(1)).setVisibility(eq(View.INVISIBLE));
}
@Test
public void onOngoingCallEnded_notificationsDisplayedAndOngoingCallChipHidden() {
mFragments.dispatchResume();
processAllMessages();
CollapsedStatusBarFragment fragment = (CollapsedStatusBarFragment) mFragment;
fragment.initNotificationIconArea(mMockNotificiationAreaController);
ArgumentCaptor<OngoingCallListener> ongoingCallListenerCaptor = ArgumentCaptor.forClass(
OngoingCallListener.class);
Mockito.verify(mOngoingCallController).addCallback(ongoingCallListenerCaptor.capture());
OngoingCallListener listener = Objects.requireNonNull(ongoingCallListenerCaptor.getValue());
when(mOngoingCallController.getHasOngoingCall()).thenReturn(false);
listener.onOngoingCallEnded(/* animate= */ false);
assertEquals(View.GONE,
mFragment.getView().findViewById(R.id.ongoing_call_chip).getVisibility());
Mockito.verify(mNotificationAreaInner, atLeast(1)).setVisibility(eq(View.VISIBLE));
}
@Override
protected Fragment instantiate(Context context, String className, Bundle arguments) {
return new CollapsedStatusBarFragment(mock(OngoingCallController.class));
mOngoingCallController = mock(OngoingCallController.class);
return new CollapsedStatusBarFragment(mOngoingCallController);
}
}

View File

@@ -0,0 +1,153 @@
/*
* Copyright (C) 2021 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.phone.ongoingcall
import android.app.Notification
import android.app.PendingIntent
import android.app.Person
import android.service.notification.NotificationListenerService.REASON_USER_STOPPED
import androidx.test.filters.SmallTest
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.view.LayoutInflater
import android.widget.LinearLayout
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.statusbar.FeatureFlags
import com.android.systemui.statusbar.notification.collection.NotificationEntry
import com.android.systemui.statusbar.notification.collection.NotificationEntryBuilder
import com.android.systemui.statusbar.notification.collection.notifcollection.CommonNotifCollection
import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener
import com.android.systemui.util.time.FakeSystemClock
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.Mock
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.never
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class OngoingCallControllerTest : SysuiTestCase() {
private lateinit var controller: OngoingCallController
private lateinit var notifCollectionListener: NotifCollectionListener
@Mock private lateinit var mockOngoingCallListener: OngoingCallListener
private lateinit var chipView: LinearLayout
@Before
fun setUp() {
allowTestableLooperAsMainThread()
TestableLooper.get(this).runWithLooper {
chipView = LayoutInflater.from(mContext)
.inflate(R.layout.ongoing_call_chip, null) as LinearLayout
}
MockitoAnnotations.initMocks(this)
val featureFlags = mock(FeatureFlags::class.java)
`when`(featureFlags.isOngoingCallStatusBarChipEnabled).thenReturn(true)
val notificationCollection = mock(CommonNotifCollection::class.java)
controller = OngoingCallController(notificationCollection, featureFlags, FakeSystemClock())
controller.init()
controller.addCallback(mockOngoingCallListener)
controller.setChipView(chipView)
val collectionListenerCaptor = ArgumentCaptor.forClass(NotifCollectionListener::class.java)
verify(notificationCollection).addCollectionListener(collectionListenerCaptor.capture())
notifCollectionListener = collectionListenerCaptor.value!!
}
@Test
fun onEntryUpdated_isOngoingCallNotif_listenerNotifiedWithRightCallTime() {
notifCollectionListener.onEntryUpdated(createOngoingCallNotifEntry())
verify(mockOngoingCallListener).onOngoingCallStarted(anyBoolean())
}
@Test
fun onEntryUpdated_notOngoingCallNotif_listenerNotNotified() {
notifCollectionListener.onEntryUpdated(createNotCallNotifEntry())
verify(mockOngoingCallListener, never()).onOngoingCallStarted(anyBoolean())
}
@Test
fun onEntryRemoved_ongoingCallNotif_listenerNotified() {
notifCollectionListener.onEntryRemoved(createOngoingCallNotifEntry(), REASON_USER_STOPPED)
verify(mockOngoingCallListener).onOngoingCallEnded(anyBoolean())
}
@Test
fun onEntryRemoved_notOngoingCallNotif_listenerNotNotified() {
notifCollectionListener.onEntryRemoved(createNotCallNotifEntry(), REASON_USER_STOPPED)
verify(mockOngoingCallListener, never()).onOngoingCallEnded(anyBoolean())
}
@Test
fun hasOngoingCall_noOngoingCallNotifSent_returnsFalse() {
assertThat(controller.hasOngoingCall).isFalse()
}
@Test
fun hasOngoingCall_ongoingCallNotifSentAndChipViewSet_returnsTrue() {
notifCollectionListener.onEntryUpdated(createOngoingCallNotifEntry())
assertThat(controller.hasOngoingCall).isTrue()
}
@Test
fun hasOngoingCall_ongoingCallNotifSentButNoChipView_returnsFalse() {
controller.setChipView(null)
notifCollectionListener.onEntryUpdated(createOngoingCallNotifEntry())
assertThat(controller.hasOngoingCall).isFalse()
}
@Test
fun hasOngoingCall_ongoingCallNotifSentThenRemoved_returnsFalse() {
val ongoingCallNotifEntry = createOngoingCallNotifEntry()
notifCollectionListener.onEntryUpdated(ongoingCallNotifEntry)
notifCollectionListener.onEntryRemoved(ongoingCallNotifEntry, REASON_USER_STOPPED)
assertThat(controller.hasOngoingCall).isFalse()
}
private fun createOngoingCallNotifEntry(): NotificationEntry {
val notificationEntryBuilder = NotificationEntryBuilder()
notificationEntryBuilder.modifyNotification(context).style = ongoingCallStyle
return notificationEntryBuilder.build()
}
private fun createNotCallNotifEntry() = NotificationEntryBuilder().build()
}
private val ongoingCallStyle = Notification.CallStyle.forOngoingCall(
Person.Builder().setName("name").build(),
/* hangUpIntent= */ mock(PendingIntent::class.java))