From 602e761ba044794f629848e26b4884cfd1dbe765 Mon Sep 17 00:00:00 2001 From: Hugh Chen Date: Fri, 11 Sep 2020 14:19:35 +0800 Subject: [PATCH 1/4] Add permission to protect data when sending broadcast This CL before, DevicePickerFragment didn't check the whether 3rd-party app have Bluetooth permission before sending broadcast. It's will cause the 3rd-party app can get Bluetooth device information without request permission. This CL will send broadcast with Bluetooth permission that make sure the receiver who have Bluetooth permission can get this Bluetooth device infomation. Bug: 161716630 Test: verify on test apk to confirm that not showing mac address. Change-Id: I6662dc38b3491e5ee467058dd74863ecac27cdd7 --- src/com/android/settings/bluetooth/DevicePickerFragment.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/com/android/settings/bluetooth/DevicePickerFragment.java b/src/com/android/settings/bluetooth/DevicePickerFragment.java index 02625bbad2b..ab8eea5db68 100644 --- a/src/com/android/settings/bluetooth/DevicePickerFragment.java +++ b/src/com/android/settings/bluetooth/DevicePickerFragment.java @@ -18,6 +18,7 @@ package com.android.settings.bluetooth; import static android.os.UserManager.DISALLOW_CONFIG_BLUETOOTH; +import android.Manifest; import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; @@ -192,6 +193,6 @@ public final class DevicePickerFragment extends DeviceListPreferenceFragment { if (mLaunchPackage != null && mLaunchClass != null) { intent.setClassName(mLaunchPackage, mLaunchClass); } - getActivity().sendBroadcast(intent); + getActivity().sendBroadcast(intent, Manifest.permission.BLUETOOTH_ADMIN); } } From 7db71ac87afffd3328e1ca7dd2fd3d0b1be13d8d Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 6 Aug 2020 16:05:33 +0800 Subject: [PATCH 2/4] Fix the endless panel loading Re-launching volume panel continuously will trigger an endless panel loading, show a transparent unfinished UI, and then block the user's screen. Root cause: When the activity receives a new intent from user's clicking, it will call PanelFragment#createPanelContent to update the current fragment. The method triggers an animation and then loads the panel content. If multiple invocations run concurrently before the animation or the loading finish, the loader's countdown latch will be increased abnormally and lead to the endless loading. Solution: 1. Since the invocations are in UI thread, simply add a flag to avoid reentrance when the panel is animating or loading. 2. Filter out the same panel's creation request when the panel is still visible. 3. Do not force a panel's recreation when it's under construction. Fixes: 143889510 Fixes: 160491854 Test: robotest, manual Change-Id: I821faedeb62354929f3af9804cbbe44ee5bb8a53 Merged-In: I821faedeb62354929f3af9804cbbe44ee5bb8a53 (cherry picked from commit 6a8d2c5e553a1c63e28cb877f5b565159e42ed97) --- .../android/settings/panel/PanelFragment.java | 9 +++ .../settings/panel/SettingsPanelActivity.java | 37 ++++++++--- .../panel/SettingsPanelActivityTest.java | 64 +++++++++++++++++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/com/android/settings/panel/PanelFragment.java b/src/com/android/settings/panel/PanelFragment.java index 9cb626d03a9..d34a09f2ebd 100644 --- a/src/com/android/settings/panel/PanelFragment.java +++ b/src/com/android/settings/panel/PanelFragment.java @@ -97,6 +97,7 @@ public class PanelFragment extends Fragment { private TextView mHeaderSubtitle; private int mMaxHeight; private View mFooterDivider; + private boolean mPanelCreating; private final Map> mSliceLiveData = new LinkedHashMap<>(); @@ -127,6 +128,7 @@ public class PanelFragment extends Fragment { if (mPanelSlices != null) { mPanelSlices.getViewTreeObserver().removeOnGlobalLayoutListener(this); } + mPanelCreating = false; } }; @@ -140,6 +142,7 @@ public class PanelFragment extends Fragment { mLayoutView.getViewTreeObserver() .addOnGlobalLayoutListener(mPanelLayoutListener); mMaxHeight = getResources().getDimensionPixelSize(R.dimen.output_switcher_slice_max_height); + mPanelCreating = true; createPanelContent(); return mLayoutView; } @@ -153,6 +156,7 @@ public class PanelFragment extends Fragment { * Call createPanelContent() once animation end. */ void updatePanelWithAnimation() { + mPanelCreating = true; final View panelContent = mLayoutView.findViewById(R.id.panel_container); final AnimatorSet animatorSet = buildAnimatorSet(mLayoutView, 0.0f /* startY */, panelContent.getHeight() /* endY */, @@ -171,11 +175,16 @@ public class PanelFragment extends Fragment { animatorSet.start(); } + boolean isPanelCreating() { + return mPanelCreating; + } + private void createPanelContent() { final FragmentActivity activity = getActivity(); if (mLayoutView == null) { activity.finish(); } + final ViewGroup.LayoutParams params = mLayoutView.getLayoutParams(); params.height = ViewGroup.LayoutParams.WRAP_CONTENT; mLayoutView.setLayoutParams(params); diff --git a/src/com/android/settings/panel/SettingsPanelActivity.java b/src/com/android/settings/panel/SettingsPanelActivity.java index 68cb8d5163a..b7b15192354 100644 --- a/src/com/android/settings/panel/SettingsPanelActivity.java +++ b/src/com/android/settings/panel/SettingsPanelActivity.java @@ -21,6 +21,7 @@ import static com.android.settingslib.media.MediaOutputSliceConstants.EXTRA_PACK import android.content.Intent; import android.content.res.Configuration; import android.os.Bundle; +import android.text.TextUtils; import android.util.Log; import android.view.Gravity; import android.view.Window; @@ -41,12 +42,14 @@ import com.android.settings.core.HideNonSystemOverlayMixin; */ public class SettingsPanelActivity extends FragmentActivity { - private final String TAG = "panel_activity"; + private static final String TAG = "SettingsPanelActivity"; @VisibleForTesting final Bundle mBundle = new Bundle(); @VisibleForTesting boolean mForceCreation = false; + @VisibleForTesting + PanelFragment mPanelFragment; /** * Key specifying which Panel the app is requesting. @@ -87,7 +90,9 @@ public class SettingsPanelActivity extends FragmentActivity { @Override protected void onStop() { super.onStop(); - mForceCreation = true; + if (mPanelFragment != null && !mPanelFragment.isPanelCreating()) { + mForceCreation = true; + } } @Override @@ -104,10 +109,10 @@ public class SettingsPanelActivity extends FragmentActivity { return; } + final String action = callingIntent.getAction(); // We will use it once media output switch panel support remote device. final String mediaPackageName = callingIntent.getStringExtra(EXTRA_PACKAGE_NAME); - - mBundle.putString(KEY_PANEL_TYPE_ARGUMENT, callingIntent.getAction()); + mBundle.putString(KEY_PANEL_TYPE_ARGUMENT, action); mBundle.putString(KEY_CALLING_PACKAGE_NAME, getCallingPackage()); mBundle.putString(KEY_MEDIA_PACKAGE_NAME, mediaPackageName); @@ -116,9 +121,21 @@ public class SettingsPanelActivity extends FragmentActivity { // If fragment already exists and visible, we will need to update panel with animation. if (!shouldForceCreation && fragment != null && fragment instanceof PanelFragment) { - final PanelFragment panelFragment = (PanelFragment) fragment; - panelFragment.setArguments(mBundle); - panelFragment.updatePanelWithAnimation(); + mPanelFragment = (PanelFragment) fragment; + if (mPanelFragment.isPanelCreating()) { + Log.w(TAG, "A panel is creating, skip " + action); + return; + } + + final Bundle bundle = fragment.getArguments(); + if (bundle != null + && TextUtils.equals(action, bundle.getString(KEY_PANEL_TYPE_ARGUMENT))) { + Log.w(TAG, "Panel is showing the same action, skip " + action); + return; + } + + mPanelFragment.setArguments(new Bundle(mBundle)); + mPanelFragment.updatePanelWithAnimation(); } else { setContentView(R.layout.settings_panel); @@ -127,9 +144,9 @@ public class SettingsPanelActivity extends FragmentActivity { window.setGravity(Gravity.BOTTOM); window.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT); - final PanelFragment panelFragment = new PanelFragment(); - panelFragment.setArguments(mBundle); - fragmentManager.beginTransaction().add(R.id.main_content, panelFragment).commit(); + mPanelFragment = new PanelFragment(); + mPanelFragment.setArguments(new Bundle(mBundle)); + fragmentManager.beginTransaction().add(R.id.main_content, mPanelFragment).commit(); } } } diff --git a/tests/robotests/src/com/android/settings/panel/SettingsPanelActivityTest.java b/tests/robotests/src/com/android/settings/panel/SettingsPanelActivityTest.java index 833d510b7ac..4a14798b7d7 100644 --- a/tests/robotests/src/com/android/settings/panel/SettingsPanelActivityTest.java +++ b/tests/robotests/src/com/android/settings/panel/SettingsPanelActivityTest.java @@ -26,6 +26,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -36,6 +37,9 @@ import android.os.Build; import android.view.Window; import android.view.WindowManager; +import androidx.fragment.app.FragmentManager; + +import com.android.settings.R; import com.android.settings.core.HideNonSystemOverlayMixin; import com.android.settings.testutils.FakeFeatureFactory; @@ -43,6 +47,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; +import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; @@ -56,6 +61,10 @@ public class SettingsPanelActivityTest { private FakeSettingsPanelActivity mSettingsPanelActivity; private PanelFeatureProvider mPanelFeatureProvider; private FakePanelContent mFakePanelContent; + @Mock + private PanelFragment mPanelFragment; + @Mock + private FragmentManager mFragmentManager; @Before public void setUp() { @@ -67,6 +76,10 @@ public class SettingsPanelActivityTest { mFakeFeatureFactory.panelFeatureProvider = mPanelFeatureProvider; mFakePanelContent = new FakePanelContent(); doReturn(mFakePanelContent).when(mPanelFeatureProvider).getPanel(any(), any()); + + mSettingsPanelActivity.mPanelFragment = mPanelFragment; + when(mFragmentManager.findFragmentById(R.id.main_content)).thenReturn(mPanelFragment); + when(mSettingsPanelActivity.getSupportFragmentManager()).thenReturn(mFragmentManager); } @Test @@ -141,11 +154,62 @@ public class SettingsPanelActivityTest { & SYSTEM_FLAG_HIDE_NON_SYSTEM_OVERLAY_WINDOWS).isEqualTo(0); } + @Test + public void onStop_panelIsNotCreating_shouldForceUpdate() { + mSettingsPanelActivity.mForceCreation = false; + when(mPanelFragment.isPanelCreating()).thenReturn(false); + mSettingsPanelActivity.mPanelFragment = mPanelFragment; + + mSettingsPanelActivity.onStop(); + + assertThat(mSettingsPanelActivity.mForceCreation).isTrue(); + } + + @Test + public void onStop_panelIsCreating_shouldNotForceUpdate() { + mSettingsPanelActivity.mForceCreation = false; + when(mPanelFragment.isPanelCreating()).thenReturn(true); + mSettingsPanelActivity.mPanelFragment = mPanelFragment; + + mSettingsPanelActivity.onStop(); + + assertThat(mSettingsPanelActivity.mForceCreation).isFalse(); + } + @Test public void onConfigurationChanged_shouldForceUpdate() { mSettingsPanelActivity.mForceCreation = false; + mSettingsPanelActivity.onConfigurationChanged(new Configuration()); assertThat(mSettingsPanelActivity.mForceCreation).isTrue(); } + + @Test + public void onNewIntent_panelIsNotCreating_shouldUpdatePanel() { + when(mPanelFragment.isPanelCreating()).thenReturn(false); + + mSettingsPanelActivity.onNewIntent(mSettingsPanelActivity.getIntent()); + + verify(mPanelFragment).updatePanelWithAnimation(); + } + + @Test + public void onNewIntent_panelIsCreating_shouldNotUpdatePanel() { + when(mPanelFragment.isPanelCreating()).thenReturn(true); + + mSettingsPanelActivity.onNewIntent(mSettingsPanelActivity.getIntent()); + + verify(mPanelFragment, never()).updatePanelWithAnimation(); + } + + @Test + public void onNewIntent_panelIsShowingTheSameAction_shouldNotUpdatePanel() { + when(mPanelFragment.isPanelCreating()).thenReturn(false); + when(mPanelFragment.getArguments()).thenReturn(mSettingsPanelActivity.mBundle); + + mSettingsPanelActivity.onNewIntent(mSettingsPanelActivity.getIntent()); + + verify(mPanelFragment, never()).updatePanelWithAnimation(); + } } From f447cbbcb5bfa1d19e8953337574f101e136b5c9 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Thu, 20 Aug 2020 16:22:44 +0800 Subject: [PATCH 3/4] Fix the ANR in panel when changing volume continuously When users open volume panel and keep on changing the volume slider for a while, the panel starts to defer the slider updating, and finally gets stuck and causes an ANR. Root cause: Volume panel has four volume adjusting slices. Each of them registers a broadcast receiver to listen to the volume changed and muted events. However, when the media volume changes, AudioManager will send four broadcasts (music, assistant, accessibility, tts) to every receiver, and each of them will reload slice four times. Thus, one media volume changed event will lead to 16 (4*4) UI updates. Consequently, keeping on sliding the volume bar will trigger hundreds of broadcasts and UI updates, which makes the system busy and getting stuck. Solution: Introduce a VolumeSliceHelper to integrate the broadcasts of the volume slices specifically. 1. Only register one broadcast receiver to reduce the broadcast loading since the four slices are listening to the same signal. 2. Filter the only one eligible broadcast among the multiple concurrent ones, and then relay it to the registered slice. 3. Listen to one more action STREAM_DEVICES_CHANGED_ACTION to update the volume panel when audio output device changes. Test: robotest, visual Fixes: 144134209 Fixes: 160489394 Change-Id: I780b9eee35802b19a5f0ab0a7d07bd3e081f5556 Merged-In: I780b9eee35802b19a5f0ab0a7d07bd3e081f5556 (cherry picked from commit 2c7b77dad7f669d3568ea5ae95d6efcb86d53e90) --- AndroidManifest.xml | 5 + ...tVolumeRestrictedPreferenceController.java | 1 + .../VolumeSeekBarPreferenceController.java | 5 +- .../settings/slices/CustomSliceRegistry.java | 10 + .../slices/SettingsSliceProvider.java | 14 +- .../settings/slices/VolumeSliceHelper.java | 127 +++++++++ .../slices/VolumeSliceRelayReceiver.java | 32 +++ .../slices/VolumeSliceHelperTest.java | 260 ++++++++++++++++++ 8 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 src/com/android/settings/slices/VolumeSliceHelper.java create mode 100644 src/com/android/settings/slices/VolumeSliceRelayReceiver.java create mode 100644 tests/robotests/src/com/android/settings/slices/VolumeSliceHelperTest.java 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; + } + } +} From 19843031aef4219cc79ff2e5a494365ee49f4055 Mon Sep 17 00:00:00 2001 From: Mill Chen Date: Fri, 14 Aug 2020 17:43:32 +0800 Subject: [PATCH 4/4] Update battery saver preference key The updated preference key made slice auto conversion mechanism fail, so when this slice is requested by external apps, the UI won't be shown properly. So we need to revert the key to the original one. Bug: 131897855 Test: manual test 1. Open Settings app -> Settings search -> search battery saver 2. Observe battery saver result item has a toggle Change-Id: Ifb5b0a6786d60d1e67567272610cc3cb078f11a4 Merged-In: Ifb5b0a6786d60d1e67567272610cc3cb078f11a4 --- res/xml/battery_saver_settings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/xml/battery_saver_settings.xml b/res/xml/battery_saver_settings.xml index 966034ec482..29b82ef3e98 100644 --- a/res/xml/battery_saver_settings.xml +++ b/res/xml/battery_saver_settings.xml @@ -34,7 +34,7 @@ settings:controller="com.android.settings.fuelgauge.batterysaver.BatterySaverStickyPreferenceController"/>