diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 2f53cc1e7c8..9260201af6a 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -3258,6 +3258,11 @@ android:permission="android.permission.MANAGE_SLICE_PERMISSIONS" android:exported="true" /> + + stopBackgroundWorker(sliceUri)); } @@ -390,7 +394,13 @@ public class SettingsSliceProvider extends SliceProvider { final IntentFilter filter = controller.getIntentFilter(); if (filter != null) { - registerIntentToUri(filter, uri); + if (controller instanceof VolumeSeekBarPreferenceController) { + // Register volume slices to a broadcast relay to reduce unnecessary UI updates + VolumeSliceHelper.registerIntentToUri(getContext(), filter, uri, + ((VolumeSeekBarPreferenceController) controller).getAudioStream()); + } else { + registerIntentToUri(filter, uri); + } } ThreadUtils.postOnMainThread(() -> startBackgroundWorker(controller, uri)); diff --git a/src/com/android/settings/slices/VolumeSliceHelper.java b/src/com/android/settings/slices/VolumeSliceHelper.java new file mode 100644 index 00000000000..bcf02e50d7f --- /dev/null +++ b/src/com/android/settings/slices/VolumeSliceHelper.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2020 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.settings.slices; + +import static com.android.settings.slices.CustomSliceRegistry.VOLUME_SLICES_URI; + +import android.content.ContentProvider; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.net.Uri; +import android.util.ArrayMap; +import android.util.Log; + +import androidx.annotation.VisibleForTesting; + +import com.android.settingslib.SliceBroadcastRelay; + +import java.util.Map; + +/** + * This helper is to handle the broadcasts of volume slices + */ +public class VolumeSliceHelper { + + private static final String TAG = "VolumeSliceHelper"; + + @VisibleForTesting + static Map sRegisteredUri = new ArrayMap<>(); + @VisibleForTesting + static IntentFilter sIntentFilter; + + static void registerIntentToUri(Context context, IntentFilter intentFilter, Uri sliceUri, + int audioStream) { + Log.d(TAG, "Registering uri for broadcast relay: " + sliceUri); + synchronized (sRegisteredUri) { + if (sRegisteredUri.isEmpty()) { + SliceBroadcastRelay.registerReceiver(context, VOLUME_SLICES_URI, + VolumeSliceRelayReceiver.class, intentFilter); + sIntentFilter = intentFilter; + } + sRegisteredUri.put(sliceUri, audioStream); + } + } + + static boolean unregisterUri(Context context, Uri sliceUri) { + if (!sRegisteredUri.containsKey(sliceUri)) { + return false; + } + + Log.d(TAG, "Unregistering uri broadcast relay: " + sliceUri); + synchronized (sRegisteredUri) { + sRegisteredUri.remove(sliceUri); + if (sRegisteredUri.isEmpty()) { + sIntentFilter = null; + SliceBroadcastRelay.unregisterReceivers(context, VOLUME_SLICES_URI); + } + } + return true; + } + + static void onReceive(Context context, Intent intent) { + final String action = intent.getAction(); + if (sIntentFilter == null || action == null || !sIntentFilter.hasAction(action)) { + return; + } + + final String uriString = intent.getStringExtra(SliceBroadcastRelay.EXTRA_URI); + if (uriString == null) { + return; + } + + final Uri uri = Uri.parse(uriString); + if (!VOLUME_SLICES_URI.equals(ContentProvider.getUriWithoutUserId(uri))) { + Log.w(TAG, "Invalid uri: " + uriString); + return; + } + + if (AudioManager.VOLUME_CHANGED_ACTION.equals(action)) { + handleVolumeChanged(context, intent); + } else if (AudioManager.STREAM_MUTE_CHANGED_ACTION.equals(action) + || AudioManager.STREAM_DEVICES_CHANGED_ACTION.equals(action)) { + handleStreamChanged(context, intent); + } else { + notifyAllStreamsChanged(context); + } + } + + private static void handleVolumeChanged(Context context, Intent intent) { + final int vol = intent.getIntExtra(AudioManager.EXTRA_VOLUME_STREAM_VALUE, -1); + final int prevVol = intent.getIntExtra(AudioManager.EXTRA_PREV_VOLUME_STREAM_VALUE, -1); + if (vol != prevVol) { + handleStreamChanged(context, intent); + } + } + + private static void handleStreamChanged(Context context, Intent intent) { + final int inputType = intent.getIntExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, -1); + for (Map.Entry entry : sRegisteredUri.entrySet()) { + if (entry.getValue() == inputType) { + context.getContentResolver().notifyChange(entry.getKey(), null /* observer */); + break; + } + } + } + + private static void notifyAllStreamsChanged(Context context) { + sRegisteredUri.forEach((uri, audioStream) -> { + context.getContentResolver().notifyChange(uri, null /* observer */); + }); + } +} diff --git a/src/com/android/settings/slices/VolumeSliceRelayReceiver.java b/src/com/android/settings/slices/VolumeSliceRelayReceiver.java new file mode 100644 index 00000000000..f6088d07142 --- /dev/null +++ b/src/com/android/settings/slices/VolumeSliceRelayReceiver.java @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2020 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.settings.slices; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * Receives broadcasts to notify that Settings volume Slices are potentially stale. + */ +public class VolumeSliceRelayReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + VolumeSliceHelper.onReceive(context, intent); + } +} diff --git a/tests/robotests/src/com/android/settings/slices/VolumeSliceHelperTest.java b/tests/robotests/src/com/android/settings/slices/VolumeSliceHelperTest.java new file mode 100644 index 00000000000..5e22adfb10a --- /dev/null +++ b/tests/robotests/src/com/android/settings/slices/VolumeSliceHelperTest.java @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2020 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.settings.slices; + +import static com.android.settings.slices.CustomSliceRegistry.VOLUME_SLICES_URI; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.content.BroadcastReceiver; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; +import android.net.Uri; + +import com.android.settings.notification.MediaVolumePreferenceController; +import com.android.settings.notification.RingVolumePreferenceController; +import com.android.settings.notification.VolumeSeekBarPreferenceController; +import com.android.settingslib.SliceBroadcastRelay; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.Resetter; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = VolumeSliceHelperTest.ShadowSliceBroadcastRelay.class) +public class VolumeSliceHelperTest { + + @Mock + private ContentResolver mResolver; + + private Context mContext; + private Intent mIntent; + private VolumeSeekBarPreferenceController mMediaController; + private VolumeSeekBarPreferenceController mRingController; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = spy(RuntimeEnvironment.application); + when(mContext.getContentResolver()).thenReturn(mResolver); + + mMediaController = new MediaVolumePreferenceController(mContext); + mRingController = new RingVolumePreferenceController(mContext); + + mIntent = createIntent(AudioManager.VOLUME_CHANGED_ACTION) + .putExtra(AudioManager.EXTRA_VOLUME_STREAM_VALUE, 1) + .putExtra(AudioManager.EXTRA_PREV_VOLUME_STREAM_VALUE, 2) + .putExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, mMediaController.getAudioStream()); + } + + @After + public void cleanUp() { + ShadowSliceBroadcastRelay.reset(); + VolumeSliceHelper.sRegisteredUri.clear(); + VolumeSliceHelper.sIntentFilter = null; + } + + @Test + public void registerIntentToUri_volumeController_shouldRegisterReceiver() { + registerIntentToUri(mMediaController); + + assertThat(ShadowSliceBroadcastRelay.getRegisteredCount()).isEqualTo(1); + assertThat(VolumeSliceHelper.sRegisteredUri) + .containsKey((mMediaController.getSliceUri())); + } + + @Test + public void registerIntentToUri_doubleVolumeControllers_shouldRegisterReceiverOnce() { + registerIntentToUri(mMediaController); + + registerIntentToUri(mRingController); + + assertThat(ShadowSliceBroadcastRelay.getRegisteredCount()).isEqualTo(1); + assertThat(VolumeSliceHelper.sRegisteredUri) + .containsKey((mRingController.getSliceUri())); + } + + @Test + public void unregisterUri_notFinalUri_shouldNotUnregisterReceiver() { + registerIntentToUri(mMediaController); + registerIntentToUri(mRingController); + + VolumeSliceHelper.unregisterUri(mContext, mMediaController.getSliceUri()); + + assertThat(ShadowSliceBroadcastRelay.getRegisteredCount()).isEqualTo(1); + assertThat(VolumeSliceHelper.sRegisteredUri) + .doesNotContainKey((mMediaController.getSliceUri())); + } + + @Test + public void unregisterUri_finalUri_shouldUnregisterReceiver() { + registerIntentToUri(mMediaController); + + VolumeSliceHelper.unregisterUri(mContext, mMediaController.getSliceUri()); + + assertThat(ShadowSliceBroadcastRelay.getRegisteredCount()).isEqualTo(0); + assertThat(VolumeSliceHelper.sRegisteredUri) + .doesNotContainKey((mMediaController.getSliceUri())); + } + + @Test + public void unregisterUri_unregisterTwice_shouldUnregisterReceiverOnce() { + registerIntentToUri(mMediaController); + + VolumeSliceHelper.unregisterUri(mContext, mMediaController.getSliceUri()); + VolumeSliceHelper.unregisterUri(mContext, mMediaController.getSliceUri()); + + assertThat(ShadowSliceBroadcastRelay.getRegisteredCount()).isEqualTo(0); + } + + @Test + public void unregisterUri_notRegistered_shouldNotUnregisterReceiver() { + registerIntentToUri(mMediaController); + + VolumeSliceHelper.unregisterUri(mContext, mRingController.getSliceUri()); + + assertThat(ShadowSliceBroadcastRelay.getRegisteredCount()).isEqualTo(1); + assertThat(VolumeSliceHelper.sRegisteredUri) + .containsKey((mMediaController.getSliceUri())); + } + + @Test + public void onReceive_audioStreamRegistered_shouldNotifyChange() { + registerIntentToUri(mMediaController); + + VolumeSliceHelper.onReceive(mContext, mIntent); + + verify(mResolver).notifyChange(mMediaController.getSliceUri(), null); + } + + @Test + public void onReceive_audioStreamNotRegistered_shouldNotNotifyChange() { + VolumeSliceHelper.onReceive(mContext, mIntent); + + verify(mResolver, never()).notifyChange(mMediaController.getSliceUri(), null); + } + + @Test + public void onReceive_audioStreamNotMatched_shouldNotNotifyChange() { + registerIntentToUri(mMediaController); + mIntent.putExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, AudioManager.STREAM_DTMF); + + VolumeSliceHelper.onReceive(mContext, mIntent); + + verify(mResolver, never()).notifyChange(mMediaController.getSliceUri(), null); + } + + @Test + public void onReceive_mediaVolumeNotChanged_shouldNotNotifyChange() { + mIntent.putExtra(AudioManager.EXTRA_VOLUME_STREAM_VALUE, 1) + .putExtra(AudioManager.EXTRA_PREV_VOLUME_STREAM_VALUE, 1); + registerIntentToUri(mMediaController); + + VolumeSliceHelper.onReceive(mContext, mIntent); + + verify(mResolver, never()).notifyChange(mMediaController.getSliceUri(), null); + } + + @Test + public void onReceive_streamVolumeMuted_shouldNotifyChange() { + final Intent intent = createIntent(AudioManager.STREAM_MUTE_CHANGED_ACTION) + .putExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, mMediaController.getAudioStream()); + registerIntentToUri(mMediaController); + registerIntentToUri(mRingController); + + VolumeSliceHelper.onReceive(mContext, intent); + + verify(mResolver).notifyChange(mMediaController.getSliceUri(), null); + } + + @Test + public void onReceive_streamDevicesChanged_shouldNotifyChange() { + final Intent intent = createIntent(AudioManager.STREAM_DEVICES_CHANGED_ACTION) + .putExtra(AudioManager.EXTRA_VOLUME_STREAM_TYPE, mRingController.getAudioStream()); + registerIntentToUri(mMediaController); + registerIntentToUri(mRingController); + + VolumeSliceHelper.onReceive(mContext, intent); + + verify(mResolver).notifyChange(mRingController.getSliceUri(), null); + } + + @Test + public void onReceive_primaryMutedChanged_shouldNotifyChangeAll() { + final Intent intent = createIntent(AudioManager.MASTER_MUTE_CHANGED_ACTION); + registerIntentToUri(mMediaController); + registerIntentToUri(mRingController); + + VolumeSliceHelper.onReceive(mContext, intent); + + verify(mResolver).notifyChange(mMediaController.getSliceUri(), null); + verify(mResolver).notifyChange(mRingController.getSliceUri(), null); + } + + private void registerIntentToUri(VolumeSeekBarPreferenceController controller) { + VolumeSliceHelper.registerIntentToUri(mContext, controller.getIntentFilter(), + controller.getSliceUri(), controller.getAudioStream()); + } + + private Intent createIntent(String action) { + return new Intent(action) + .putExtra(SliceBroadcastRelay.EXTRA_URI, VOLUME_SLICES_URI.toString()); + } + + @Implements(SliceBroadcastRelay.class) + public static class ShadowSliceBroadcastRelay { + + private static int sRegisteredCount; + + @Implementation + public static void registerReceiver(Context context, Uri sliceUri, + Class receiver, IntentFilter filter) { + sRegisteredCount++; + } + + @Implementation + public static void unregisterReceivers(Context context, Uri sliceUri) { + sRegisteredCount--; + } + + @Resetter + static void reset() { + sRegisteredCount = 0; + } + + static int getRegisteredCount() { + return sRegisteredCount; + } + } +}