From fec890eabded17cc37bb2285148da9231a1aee55 Mon Sep 17 00:00:00 2001 From: Yiyi Shen Date: Wed, 5 Jun 2024 18:21:59 +0800 Subject: [PATCH 01/15] [Audiosharing] Log action in audio sharing dialogs P3 for add audio sharing loggings Bug: 331515891 Test: atest Change-Id: Iea29e74e00c239e8cb8cddee6eae71ba902add01 --- .../AudioSharingDialogFragment.java | 53 +++- .../AudioSharingDialogHandler.java | 183 +++++++++----- .../AudioSharingDisconnectDialogFragment.java | 82 ++++-- .../AudioSharingJoinDialogFragment.java | 31 ++- .../AudioSharingStopDialogFragment.java | 67 ++++- .../AudioSharingSwitchBarController.java | 82 ++++-- .../audiosharing/AudioSharingUtils.java | 53 +++- .../AudioSharingDialogFragmentTest.java | 144 +++++++++-- .../AudioSharingDialogHandlerTest.java | 235 +++++++++++++++++- ...ioSharingDisconnectDialogFragmentTest.java | 112 +++++++-- .../AudioSharingJoinDialogFragmentTest.java | 91 +++++-- .../AudioSharingStopDialogFragmentTest.java | 152 +++++++++-- .../AudioSharingSwitchBarControllerTest.java | 113 +++++++-- 13 files changed, 1174 insertions(+), 224 deletions(-) diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java index 6f7de8ca74e..3d111fd8e3b 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragment.java @@ -20,9 +20,11 @@ import android.app.Dialog; import android.app.settings.SettingsEnums; import android.os.Bundle; import android.util.Log; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; @@ -48,13 +50,17 @@ public class AudioSharingDialogFragment extends InstrumentedDialogFragment { * @param item The device item clicked. */ void onItemClick(AudioSharingDeviceItem item); + + /** Called when users click the cancel button in the dialog. */ + void onCancelClick(); } @Nullable private static DialogEventListener sListener; + private static Pair[] sEventData = new Pair[0]; @Override public int getMetricsCategory() { - return SettingsEnums.DIALOG_START_AUDIO_SHARING; + return SettingsEnums.DIALOG_AUDIO_SHARING_ADD_DEVICE; } /** @@ -63,14 +69,17 @@ public class AudioSharingDialogFragment extends InstrumentedDialogFragment { * @param host The Fragment this dialog will be hosted. * @param deviceItems The connected device items eligible for audio sharing. * @param listener The callback to handle the user action on this dialog. + * @param eventData The eventData to log with for dialog onClick events. */ public static void show( @NonNull Fragment host, @NonNull List deviceItems, - @NonNull DialogEventListener listener) { + @NonNull DialogEventListener listener, + @NonNull Pair[] eventData) { if (!AudioSharingUtils.isFeatureEnabled()) return; final FragmentManager manager = host.getChildFragmentManager(); sListener = listener; + sEventData = eventData; AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); if (dialog != null) { Log.d(TAG, "Dialog is showing, return."); @@ -84,7 +93,19 @@ public class AudioSharingDialogFragment extends InstrumentedDialogFragment { dialogFrag.show(manager, TAG); } + /** Return the tag of {@link AudioSharingDialogFragment} dialog. */ + public static @NonNull String tag() { + return TAG; + } + + /** Test only: get the event data passed to the dialog. */ + @VisibleForTesting + protected @NonNull Pair[] getEventData() { + return sEventData; + } + @Override + @NonNull public Dialog onCreateDialog(Bundle savedInstanceState) { Bundle arguments = requireArguments(); List deviceItems = @@ -93,12 +114,17 @@ public class AudioSharingDialogFragment extends InstrumentedDialogFragment { AudioSharingDialogFactory.newBuilder(getActivity()) .setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing) .setIsCustomBodyEnabled(true); + if (deviceItems == null) { + Log.d(TAG, "Create dialog error: null deviceItems"); + return builder.build(); + } if (deviceItems.isEmpty()) { builder.setTitle(R.string.audio_sharing_share_dialog_title) .setCustomImage(R.drawable.audio_sharing_guidance) .setCustomMessage(R.string.audio_sharing_dialog_connect_device_content) .setNegativeButton( - R.string.audio_sharing_close_button_label, (dig, which) -> dismiss()); + R.string.audio_sharing_close_button_label, + (dig, which) -> onCancelClick()); } else if (deviceItems.size() == 1) { AudioSharingDeviceItem deviceItem = Iterables.getOnlyElement(deviceItems); builder.setTitle( @@ -111,11 +137,16 @@ public class AudioSharingDialogFragment extends InstrumentedDialogFragment { v -> { if (sListener != null) { sListener.onItemClick(deviceItem); + mMetricsFeatureProvider.action( + getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED, + sEventData); } dismiss(); }) .setCustomNegativeButton( - R.string.audio_sharing_no_thanks_button_label, v -> dismiss()); + R.string.audio_sharing_no_thanks_button_label, v -> onCancelClick()); } else { builder.setTitle(R.string.audio_sharing_share_with_more_dialog_title) .setCustomMessage(R.string.audio_sharing_dialog_share_more_content) @@ -130,8 +161,20 @@ public class AudioSharingDialogFragment extends InstrumentedDialogFragment { dismiss(); }, AudioSharingDeviceAdapter.ActionType.SHARE)) - .setCustomNegativeButton(com.android.settings.R.string.cancel, v -> dismiss()); + .setCustomNegativeButton( + com.android.settings.R.string.cancel, v -> onCancelClick()); } return builder.build(); } + + private void onCancelClick() { + if (sListener != null) { + sListener.onCancelClick(); + mMetricsFeatureProvider.action( + getContext(), + SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED, + sEventData); + } + dismiss(); + } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandler.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandler.java index c329e82cc7a..5458a9f259b 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandler.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandler.java @@ -24,6 +24,7 @@ import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothLeBroadcastReceiveState; import android.content.Context; import android.util.Log; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -33,15 +34,21 @@ import androidx.fragment.app.Fragment; import com.android.settings.bluetooth.Utils; import com.android.settings.core.SubSettingLauncher; import com.android.settings.dashboard.DashboardFragment; +import com.android.settings.overlay.FeatureFactory; import com.android.settingslib.bluetooth.BluetoothUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; +import com.android.settingslib.utils.ThreadUtils; + +import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.Executor; public class AudioSharingDialogHandler { @@ -51,6 +58,7 @@ public class AudioSharingDialogHandler { @Nullable private final LocalBluetoothManager mLocalBtManager; @Nullable private final LocalBluetoothLeBroadcast mBroadcast; @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant; + private final MetricsFeatureProvider mMetricsFeatureProvider; private List mTargetSinks = new ArrayList<>(); private final BluetoothLeBroadcast.Callback mBroadcastCallback = @@ -119,9 +127,7 @@ public class AudioSharingDialogHandler { new SubSettingLauncher(mContext) .setDestination(AudioSharingDashboardFragment.class.getName()) .setSourceMetricsCategory( - (mHostFragment != null - && mHostFragment - instanceof DashboardFragment) + (mHostFragment instanceof DashboardFragment) ? ((DashboardFragment) mHostFragment) .getMetricsCategory() : SettingsEnums.PAGE_UNKNOWN) @@ -146,6 +152,7 @@ public class AudioSharingDialogHandler { mLocalBtManager != null ? mLocalBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile() : null; + mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); } /** Register callbacks for dialog handler */ @@ -191,6 +198,18 @@ public class AudioSharingDialogHandler { List deviceItemsInSharingSession = AudioSharingUtils.buildOrderedConnectedLeadAudioSharingDeviceItem( mLocalBtManager, groupedDevices, /* filterByInSharing= */ true); + AudioSharingStopDialogFragment.DialogEventListener listener = + () -> { + cachedDevice.setActive(); + AudioSharingUtils.stopBroadcasting(mLocalBtManager); + }; + Pair[] eventData = + AudioSharingUtils.buildAudioSharingDialogEventData( + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY, + SettingsEnums.DIALOG_STOP_AUDIO_SHARING, + userTriggered, + deviceItemsInSharingSession.size(), + /* candidateDeviceCount= */ 0); postOnMainThread( () -> { closeOpeningDialogsOtherThan(AudioSharingStopDialogFragment.tag()); @@ -198,10 +217,8 @@ public class AudioSharingDialogHandler { mHostFragment, deviceItemsInSharingSession, cachedDevice, - () -> { - cachedDevice.setActive(); - AudioSharingUtils.stopBroadcasting(mLocalBtManager); - }); + listener, + eventData); }); } else { if (userTriggered) { @@ -252,6 +269,20 @@ public class AudioSharingDialogHandler { // Show audio sharing switch dialog when the third eligible (LE audio) remote device // connected during a sharing session. if (deviceItemsInSharingSession.size() >= 2) { + AudioSharingDisconnectDialogFragment.DialogEventListener listener = + (AudioSharingDeviceItem item) -> { + // Remove all sources from the device user clicked + removeSourceForGroup(item.getGroupId(), groupedDevices); + // Add current broadcast to the latest connected device + addSourceForGroup(groupId, groupedDevices); + }; + Pair[] eventData = + AudioSharingUtils.buildAudioSharingDialogEventData( + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY, + SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE, + userTriggered, + deviceItemsInSharingSession.size(), + /* candidateDeviceCount= */ 1); postOnMainThread( () -> { closeOpeningDialogsOtherThan( @@ -260,16 +291,29 @@ public class AudioSharingDialogHandler { mHostFragment, deviceItemsInSharingSession, cachedDevice, - (AudioSharingDeviceItem item) -> { - // Remove all sources from the device user clicked - removeSourceForGroup(item.getGroupId(), groupedDevices); - // Add current broadcast to the latest connected device - addSourceForGroup(groupId, groupedDevices); - }); + listener, + eventData); }); } else { // Show audio sharing join dialog when the first or second eligible (LE audio) // remote device connected during a sharing session. + AudioSharingJoinDialogFragment.DialogEventListener listener = + new AudioSharingJoinDialogFragment.DialogEventListener() { + @Override + public void onShareClick() { + addSourceForGroup(groupId, groupedDevices); + } + + @Override + public void onCancelClick() {} + }; + Pair[] eventData = + AudioSharingUtils.buildAudioSharingDialogEventData( + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY, + SettingsEnums.DIALOG_AUDIO_SHARING_ADD_DEVICE, + userTriggered, + deviceItemsInSharingSession.size(), + /* candidateDeviceCount= */ 1); postOnMainThread( () -> { closeOpeningDialogsOtherThan(AudioSharingJoinDialogFragment.tag()); @@ -277,15 +321,8 @@ public class AudioSharingDialogHandler { mHostFragment, deviceItemsInSharingSession, cachedDevice, - new AudioSharingJoinDialogFragment.DialogEventListener() { - @Override - public void onShareClick() { - addSourceForGroup(groupId, groupedDevices); - } - - @Override - public void onCancelClick() {} - }); + listener, + eventData); }); } } else { @@ -302,39 +339,43 @@ public class AudioSharingDialogHandler { // Show audio sharing join dialog when the second eligible (LE audio) remote // device connect and no sharing session. if (deviceItems.size() == 1) { + AudioSharingJoinDialogFragment.DialogEventListener listener = + new AudioSharingJoinDialogFragment.DialogEventListener() { + @Override + public void onShareClick() { + mTargetSinks = new ArrayList<>(); + for (List devices : + groupedDevices.values()) { + for (CachedBluetoothDevice device : devices) { + mTargetSinks.add(device.getDevice()); + } + } + Log.d(TAG, "Start broadcast with sinks = " + mTargetSinks.size()); + if (mBroadcast != null) { + mBroadcast.startPrivateBroadcast(); + } + } + + @Override + public void onCancelClick() { + if (userTriggered) { + cachedDevice.setActive(); + } + } + }; + + Pair[] eventData = + AudioSharingUtils.buildAudioSharingDialogEventData( + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY, + SettingsEnums.DIALOG_START_AUDIO_SHARING, + userTriggered, + /* deviceCountInSharing= */ 0, + /* candidateDeviceCount= */ 2); postOnMainThread( () -> { closeOpeningDialogsOtherThan(AudioSharingJoinDialogFragment.tag()); AudioSharingJoinDialogFragment.show( - mHostFragment, - deviceItems, - cachedDevice, - new AudioSharingJoinDialogFragment.DialogEventListener() { - @Override - public void onShareClick() { - mTargetSinks = new ArrayList<>(); - for (List devices : - groupedDevices.values()) { - for (CachedBluetoothDevice device : devices) { - mTargetSinks.add(device.getDevice()); - } - } - Log.d( - TAG, - "Start broadcast with sinks: " - + mTargetSinks.size()); - if (mBroadcast != null) { - mBroadcast.startPrivateBroadcast(); - } - } - - @Override - public void onCancelClick() { - if (userTriggered) { - cachedDevice.setActive(); - } - } - }); + mHostFragment, deviceItems, cachedDevice, listener, eventData); }); } else if (userTriggered) { cachedDevice.setActive(); @@ -346,9 +387,12 @@ public class AudioSharingDialogHandler { if (mHostFragment == null) return; List fragments = mHostFragment.getChildFragmentManager().getFragments(); for (Fragment fragment : fragments) { - if (fragment instanceof DialogFragment && !fragment.getTag().equals(tag)) { + if (fragment instanceof DialogFragment + && fragment.getTag() != null + && !fragment.getTag().equals(tag)) { Log.d(TAG, "Remove staled opening dialog " + fragment.getTag()); ((DialogFragment) fragment).dismiss(); + logDialogDismissEvent(fragment); } } } @@ -365,6 +409,7 @@ public class AudioSharingDialogHandler { && AudioSharingUtils.getGroupId(device) == groupId) { Log.d(TAG, "Remove staled opening dialog for group " + groupId); ((DialogFragment) fragment).dismiss(); + logDialogDismissEvent(fragment); } } } @@ -382,6 +427,7 @@ public class AudioSharingDialogHandler { "Remove staled opening dialog for device " + cachedDevice.getDevice().getAnonymizedAddress()); ((DialogFragment) fragment).dismiss(); + logDialogDismissEvent(fragment); } } } @@ -409,9 +455,9 @@ public class AudioSharingDialogHandler { Log.d(TAG, "Fail to remove source for group " + groupId); return; } - groupedDevices.get(groupId).stream() + groupedDevices.getOrDefault(groupId, ImmutableList.of()).stream() .map(CachedBluetoothDevice::getDevice) - .filter(device -> device != null) + .filter(Objects::nonNull) .forEach( device -> { for (BluetoothLeBroadcastReceiveState source : @@ -431,9 +477,9 @@ public class AudioSharingDialogHandler { Log.d(TAG, "Fail to add source due to invalid group id, group = " + groupId); return; } - groupedDevices.get(groupId).stream() + groupedDevices.getOrDefault(groupId, ImmutableList.of()).stream() .map(CachedBluetoothDevice::getDevice) - .filter(device -> device != null) + .filter(Objects::nonNull) .forEach( device -> mAssistant.addSource( @@ -449,4 +495,29 @@ public class AudioSharingDialogHandler { private boolean isBroadcasting() { return mBroadcast != null && mBroadcast.isEnabled(null); } + + private void logDialogDismissEvent(Fragment fragment) { + var unused = + ThreadUtils.postOnBackgroundThread( + () -> { + int pageId = SettingsEnums.PAGE_UNKNOWN; + if (fragment instanceof AudioSharingJoinDialogFragment) { + pageId = + ((AudioSharingJoinDialogFragment) fragment) + .getMetricsCategory(); + } else if (fragment instanceof AudioSharingStopDialogFragment) { + pageId = + ((AudioSharingStopDialogFragment) fragment) + .getMetricsCategory(); + } else if (fragment instanceof AudioSharingDisconnectDialogFragment) { + pageId = + ((AudioSharingDisconnectDialogFragment) fragment) + .getMetricsCategory(); + } + mMetricsFeatureProvider.action( + mContext, + SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS, + pageId); + }); + } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java index e859693a17d..5f6d84a1929 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragment.java @@ -20,16 +20,20 @@ import android.app.Dialog; import android.app.settings.SettingsEnums; import android.os.Bundle; import android.util.Log; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import com.android.settings.R; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; +import com.android.settings.overlay.FeatureFactory; import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.utils.ThreadUtils; import java.util.List; import java.util.Locale; @@ -55,6 +59,7 @@ public class AudioSharingDisconnectDialogFragment extends InstrumentedDialogFrag @Nullable private static DialogEventListener sListener; @Nullable private static CachedBluetoothDevice sNewDevice; + private static Pair[] sEventData = new Pair[0]; @Override public int getMetricsCategory() { @@ -70,12 +75,14 @@ public class AudioSharingDisconnectDialogFragment extends InstrumentedDialogFrag * @param deviceItems The existing connected device items in audio sharing session. * @param newDevice The latest connected device triggered this dialog. * @param listener The callback to handle the user action on this dialog. + * @param eventData The eventData to log with for dialog onClick events. */ public static void show( @NonNull Fragment host, @NonNull List deviceItems, @NonNull CachedBluetoothDevice newDevice, - @NonNull DialogEventListener listener) { + @NonNull DialogEventListener listener, + @NonNull Pair[] eventData) { if (!AudioSharingUtils.isFeatureEnabled()) return; FragmentManager manager = host.getChildFragmentManager(); AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); @@ -91,6 +98,7 @@ public class AudioSharingDisconnectDialogFragment extends InstrumentedDialogFrag newGroupId)); sListener = listener; sNewDevice = newDevice; + sEventData = eventData; return; } else { Log.d( @@ -101,10 +109,22 @@ public class AudioSharingDisconnectDialogFragment extends InstrumentedDialogFrag + "dismiss current dialog.", newGroupId)); dialog.dismiss(); + var unused = + ThreadUtils.postOnBackgroundThread( + () -> + FeatureFactory.getFeatureFactory() + .getMetricsFeatureProvider() + .action( + dialog.getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS, + SettingsEnums + .DIALOG_AUDIO_SHARING_SWITCH_DEVICE)); } } sListener = listener; sNewDevice = newDevice; + sEventData = eventData; Log.d(TAG, "Show up the dialog."); final Bundle bundle = new Bundle(); bundle.putParcelableList(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, deviceItems); @@ -125,28 +145,54 @@ public class AudioSharingDisconnectDialogFragment extends InstrumentedDialogFrag return sNewDevice; } + /** Test only: get the event data passed to the dialog. */ + @VisibleForTesting + protected @NonNull Pair[] getEventData() { + return sEventData; + } + @Override + @NonNull public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { Bundle arguments = requireArguments(); List deviceItems = arguments.getParcelable(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, List.class); - return AudioSharingDialogFactory.newBuilder(getActivity()) - .setTitle(R.string.audio_sharing_disconnect_dialog_title) - .setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing) - .setIsCustomBodyEnabled(true) - .setCustomMessage(R.string.audio_sharing_dialog_disconnect_content) - .setCustomDeviceActions( - new AudioSharingDeviceAdapter( - getContext(), - deviceItems, - (AudioSharingDeviceItem item) -> { - if (sListener != null) { - sListener.onItemClick(item); - } + AudioSharingDialogFactory.DialogBuilder builder = + AudioSharingDialogFactory.newBuilder(getActivity()) + .setTitle(R.string.audio_sharing_disconnect_dialog_title) + .setTitleIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing) + .setIsCustomBodyEnabled(true) + .setCustomMessage(R.string.audio_sharing_dialog_disconnect_content) + .setCustomNegativeButton( + com.android.settings.R.string.cancel, + v -> { + mMetricsFeatureProvider.action( + getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED, + sEventData); dismiss(); - }, - AudioSharingDeviceAdapter.ActionType.REMOVE)) - .setCustomNegativeButton(com.android.settings.R.string.cancel, v -> dismiss()) - .build(); + }); + if (deviceItems == null) { + Log.d(TAG, "Create dialog error: null deviceItems"); + return builder.build(); + } + builder.setCustomDeviceActions( + new AudioSharingDeviceAdapter( + getContext(), + deviceItems, + (AudioSharingDeviceItem item) -> { + if (sListener != null) { + sListener.onItemClick(item); + mMetricsFeatureProvider.action( + getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED, + sEventData); + } + dismiss(); + }, + AudioSharingDeviceAdapter.ActionType.REMOVE)); + return builder.build(); } } diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java index 4982179ccfc..7eebbcb2156 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragment.java @@ -20,9 +20,11 @@ import android.app.Dialog; import android.app.settings.SettingsEnums; import android.os.Bundle; import android.util.Log; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; @@ -52,6 +54,7 @@ public class AudioSharingJoinDialogFragment extends InstrumentedDialogFragment { @Nullable private static DialogEventListener sListener; @Nullable private static CachedBluetoothDevice sNewDevice; + private static Pair[] sEventData = new Pair[0]; @Override public int getMetricsCategory() { @@ -69,16 +72,19 @@ public class AudioSharingJoinDialogFragment extends InstrumentedDialogFragment { * @param deviceItems The existing connected device items eligible for audio sharing. * @param newDevice The latest connected device triggered this dialog. * @param listener The callback to handle the user action on this dialog. + * @param eventData The eventData to log with for dialog onClick events. */ public static void show( @NonNull Fragment host, @NonNull List deviceItems, @NonNull CachedBluetoothDevice newDevice, - @NonNull DialogEventListener listener) { + @NonNull DialogEventListener listener, + @NonNull Pair[] eventData) { if (!AudioSharingUtils.isFeatureEnabled()) return; final FragmentManager manager = host.getChildFragmentManager(); sListener = listener; sNewDevice = newDevice; + sEventData = eventData; AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); if (dialog != null) { Log.d(TAG, "Dialog is showing, update the content."); @@ -104,7 +110,14 @@ public class AudioSharingJoinDialogFragment extends InstrumentedDialogFragment { return sNewDevice; } + /** Test only: get the event data passed to the dialog. */ + @VisibleForTesting + protected @NonNull Pair[] getEventData() { + return sEventData; + } + @Override + @NonNull public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { Bundle arguments = requireArguments(); List deviceItems = @@ -121,6 +134,11 @@ public class AudioSharingJoinDialogFragment extends InstrumentedDialogFragment { v -> { if (sListener != null) { sListener.onShareClick(); + mMetricsFeatureProvider.action( + getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED, + sEventData); } dismiss(); }) @@ -129,11 +147,20 @@ public class AudioSharingJoinDialogFragment extends InstrumentedDialogFragment { v -> { if (sListener != null) { sListener.onCancelClick(); + mMetricsFeatureProvider.action( + getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED, + sEventData); } dismiss(); }) .build(); - updateDialog(deviceItems, newDeviceName, dialog); + if (deviceItems == null) { + Log.d(TAG, "Fail to create dialog: null deviceItems"); + } else { + updateDialog(deviceItems, newDeviceName, dialog); + } dialog.show(); AudioSharingDialogHelper.updateMessageStyle(dialog); return dialog; diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java index affd54acf5e..beac4b0fd33 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragment.java @@ -20,16 +20,20 @@ import android.app.Dialog; import android.app.settings.SettingsEnums; import android.os.Bundle; import android.util.Log; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import com.android.settings.R; import com.android.settings.core.instrumentation.InstrumentedDialogFragment; +import com.android.settings.overlay.FeatureFactory; import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.utils.ThreadUtils; import com.google.common.collect.Iterables; @@ -52,6 +56,7 @@ public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment { @Nullable private static DialogEventListener sListener; @Nullable private static CachedBluetoothDevice sCachedDevice; + private static Pair[] sEventData = new Pair[0]; @Override public int getMetricsCategory() { @@ -67,12 +72,14 @@ public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment { * @param deviceItems The existing connected device items in audio sharing session. * @param newDevice The latest connected device triggered this dialog. * @param listener The callback to handle the user action on this dialog. + * @param eventData The eventData to log with for dialog onClick events. */ public static void show( @NonNull Fragment host, @NonNull List deviceItems, @NonNull CachedBluetoothDevice newDevice, - @NonNull DialogEventListener listener) { + @NonNull DialogEventListener listener, + @NonNull Pair[] eventData) { if (!AudioSharingUtils.isFeatureEnabled()) return; final FragmentManager manager = host.getChildFragmentManager(); AlertDialog dialog = AudioSharingDialogHelper.getDialogIfShowing(manager, TAG); @@ -88,6 +95,7 @@ public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment { newGroupId)); sListener = listener; sCachedDevice = newDevice; + sEventData = eventData; return; } else { Log.d( @@ -98,10 +106,21 @@ public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment { + "dismiss current dialog.", newGroupId)); dialog.dismiss(); + var unused = + ThreadUtils.postOnBackgroundThread( + () -> + FeatureFactory.getFeatureFactory() + .getMetricsFeatureProvider() + .action( + dialog.getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS, + SettingsEnums.DIALOG_STOP_AUDIO_SHARING)); } } sListener = listener; sCachedDevice = newDevice; + sEventData = eventData; Log.d(TAG, "Show up the dialog."); final Bundle bundle = new Bundle(); bundle.putParcelableList(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, deviceItems); @@ -121,23 +140,34 @@ public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment { return sCachedDevice; } + /** Test only: get the event data passed to the dialog. */ + @VisibleForTesting + protected @NonNull Pair[] getEventData() { + return sEventData; + } + @Override + @NonNull public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { Bundle arguments = requireArguments(); List deviceItems = arguments.getParcelable(BUNDLE_KEY_DEVICE_TO_DISCONNECT_ITEMS, List.class); String newDeviceName = arguments.getString(BUNDLE_KEY_NEW_DEVICE_NAME); - String customMessage = - deviceItems.size() == 1 - ? getString( - R.string.audio_sharing_stop_dialog_content, - Iterables.getOnlyElement(deviceItems).getName()) - : (deviceItems.size() == 2 - ? getString( - R.string.audio_sharing_stop_dialog_with_two_content, - deviceItems.get(0).getName(), - deviceItems.get(1).getName()) - : getString(R.string.audio_sharing_stop_dialog_with_more_content)); + String customMessage = ""; + if (deviceItems != null) { + customMessage = + deviceItems.size() == 1 + ? getString( + R.string.audio_sharing_stop_dialog_content, + Iterables.getOnlyElement(deviceItems).getName()) + : (deviceItems.size() == 2 + ? getString( + R.string.audio_sharing_stop_dialog_with_two_content, + deviceItems.get(0).getName(), + deviceItems.get(1).getName()) + : getString( + R.string.audio_sharing_stop_dialog_with_more_content)); + } AlertDialog dialog = AudioSharingDialogFactory.newBuilder(getActivity()) .setTitle( @@ -150,10 +180,21 @@ public class AudioSharingStopDialogFragment extends InstrumentedDialogFragment { (dlg, which) -> { if (sListener != null) { sListener.onStopSharingClick(); + mMetricsFeatureProvider.action( + getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED, + sEventData); } }) .setNegativeButton( - com.android.settings.R.string.cancel, (dlg, which) -> dismiss()) + com.android.settings.R.string.cancel, + (dlg, which) -> + mMetricsFeatureProvider.action( + getContext(), + SettingsEnums + .ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED, + sEventData)) .build(); dialog.show(); AudioSharingDialogHelper.updateMessageStyle(dialog); diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java index 475be85a8a3..5022579ce65 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarController.java @@ -16,6 +16,7 @@ package com.android.settings.connecteddevice.audiosharing; +import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcast; @@ -29,24 +30,27 @@ import android.content.Intent; import android.content.IntentFilter; import android.util.FeatureFlagUtils; import android.util.Log; +import android.util.Pair; import android.widget.CompoundButton; import android.widget.CompoundButton.OnCheckedChangeListener; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; +import androidx.fragment.app.Fragment; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import com.android.settings.bluetooth.Utils; import com.android.settings.core.BasePreferenceController; -import com.android.settings.dashboard.DashboardFragment; +import com.android.settings.overlay.FeatureFactory; import com.android.settings.widget.SettingsMainSwitchBar; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast; import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant; import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; +import com.android.settingslib.core.instrumentation.MetricsFeatureProvider; import com.android.settingslib.utils.ThreadUtils; import com.google.common.collect.ImmutableList; @@ -56,6 +60,7 @@ import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; @@ -91,14 +96,15 @@ public class AudioSharingSwitchBarController extends BasePreferenceController @Nullable private final LocalBluetoothProfileManager mProfileManager; @Nullable private final LocalBluetoothLeBroadcast mBroadcast; @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant; - @Nullable private DashboardFragment mFragment; + @Nullable private Fragment mFragment; private final Executor mExecutor; + private final MetricsFeatureProvider mMetricsFeatureProvider; private final OnAudioSharingStateChangedListener mListener; private Map> mGroupedConnectedDevices = new HashMap<>(); private List mTargetActiveSinks = new ArrayList<>(); private List mDeviceItemsForSharing = new ArrayList<>(); @VisibleForTesting IntentFilter mIntentFilter; - private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false); + private final AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false); @VisibleForTesting BroadcastReceiver mReceiver = @@ -110,7 +116,8 @@ public class AudioSharingSwitchBarController extends BasePreferenceController } }; - private final BluetoothLeBroadcast.Callback mBroadcastCallback = + @VisibleForTesting + protected final BluetoothLeBroadcast.Callback mBroadcastCallback = new BluetoothLeBroadcast.Callback() { @Override public void onBroadcastStarted(int reason, int broadcastId) { @@ -182,7 +189,7 @@ public class AudioSharingSwitchBarController extends BasePreferenceController public void onPlaybackStopped(int reason, int broadcastId) {} }; - private BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback = + private final BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback = new BluetoothLeBroadcastAssistant.Callback() { @Override public void onSearchStarted(int reason) {} @@ -251,9 +258,9 @@ public class AudioSharingSwitchBarController extends BasePreferenceController @Override public void onReceiveStateChanged( - BluetoothDevice sink, + @NonNull BluetoothDevice sink, int sourceId, - BluetoothLeBroadcastReceiveState state) {} + @NonNull BluetoothLeBroadcastReceiveState state) {} }; AudioSharingSwitchBarController( @@ -273,6 +280,7 @@ public class AudioSharingSwitchBarController extends BasePreferenceController ? null : mProfileManager.getLeAudioBroadcastAssistantProfile(); mExecutor = Executors.newSingleThreadExecutor(); + mMetricsFeatureProvider = FeatureFactory.getFeatureFactory().getMetricsFeatureProvider(); } @Override @@ -378,7 +386,7 @@ public class AudioSharingSwitchBarController extends BasePreferenceController * * @param fragment The fragment to host the {@link AudioSharingSwitchBarController} dialog. */ - public void init(DashboardFragment fragment) { + public void init(@NonNull Fragment fragment) { this.mFragment = fragment; } @@ -494,34 +502,58 @@ public class AudioSharingSwitchBarController extends BasePreferenceController } private void handleOnBroadcastReady() { - AudioSharingUtils.addSourceToTargetSinks(mTargetActiveSinks, mBtManager); - mTargetActiveSinks.clear(); + Pair[] eventData = + AudioSharingUtils.buildAudioSharingDialogEventData( + SettingsEnums.AUDIO_SHARING_SETTINGS, + SettingsEnums.DIALOG_AUDIO_SHARING_ADD_DEVICE, + /* userTriggered= */ false, + /* deviceCountInSharing= */ mTargetActiveSinks.isEmpty() ? 0 : 1, + /* candidateDeviceCount= */ mDeviceItemsForSharing.size()); + if (!mTargetActiveSinks.isEmpty()) { + Log.d(TAG, "handleOnBroadcastReady: automatically add source to active sinks."); + AudioSharingUtils.addSourceToTargetSinks(mTargetActiveSinks, mBtManager); + mMetricsFeatureProvider.action(mContext, SettingsEnums.ACTION_AUTO_JOIN_AUDIO_SHARING); + mTargetActiveSinks.clear(); + } if (mFragment == null) { - Log.w(TAG, "Dialog fail to show due to null fragment."); + Log.d(TAG, "handleOnBroadcastReady: dialog fail to show due to null fragment."); mGroupedConnectedDevices.clear(); mDeviceItemsForSharing.clear(); return; } + showDialog(eventData); + } + + private void showDialog(Pair[] eventData) { + AudioSharingDialogFragment.DialogEventListener listener = + new AudioSharingDialogFragment.DialogEventListener() { + @Override + public void onItemClick(@NonNull AudioSharingDeviceItem item) { + AudioSharingUtils.addSourceToTargetSinks( + mGroupedConnectedDevices + .getOrDefault(item.getGroupId(), ImmutableList.of()) + .stream() + .map(CachedBluetoothDevice::getDevice) + .filter(Objects::nonNull) + .collect(Collectors.toList()), + mBtManager); + mGroupedConnectedDevices.clear(); + mDeviceItemsForSharing.clear(); + } + + @Override + public void onCancelClick() { + mGroupedConnectedDevices.clear(); + mDeviceItemsForSharing.clear(); + } + }; AudioSharingUtils.postOnMainThread( mContext, () -> { // Check nullability to pass NullAway check if (mFragment != null) { AudioSharingDialogFragment.show( - mFragment, - mDeviceItemsForSharing, - item -> { - AudioSharingUtils.addSourceToTargetSinks( - mGroupedConnectedDevices - .getOrDefault( - item.getGroupId(), ImmutableList.of()) - .stream() - .map(CachedBluetoothDevice::getDevice) - .collect(Collectors.toList()), - mBtManager); - mGroupedConnectedDevices.clear(); - mDeviceItemsForSharing.clear(); - }); + mFragment, mDeviceItemsForSharing, listener, eventData); } }); } diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingUtils.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingUtils.java index f63717eb536..29f605c94a4 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingUtils.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingUtils.java @@ -16,6 +16,12 @@ package com.android.settings.connecteddevice.audiosharing; +import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT; +import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING; +import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID; +import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID; +import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED; + import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothCsipSetCoordinator; import android.bluetooth.BluetoothDevice; @@ -25,6 +31,7 @@ import android.bluetooth.BluetoothStatusCodes; import android.content.Context; import android.provider.Settings; import android.util.Log; +import android.util.Pair; import android.widget.Toast; import androidx.annotation.NonNull; @@ -54,6 +61,14 @@ public class AudioSharingUtils { private static final String TAG = "AudioSharingUtils"; private static final boolean DEBUG = BluetoothUtils.D; + public enum MetricKey { + METRIC_KEY_SOURCE_PAGE_ID, + METRIC_KEY_PAGE_ID, + METRIC_KEY_USER_TRIGGERED, + METRIC_KEY_DEVICE_COUNT_IN_SHARING, + METRIC_KEY_CANDIDATE_DEVICE_COUNT + } + /** * Fetch {@link CachedBluetoothDevice}s connected to the broadcast assistant. The devices are * grouped by CSIP group id. @@ -121,7 +136,7 @@ public class AudioSharingUtils { boolean filterByInSharing) { List orderedDevices = new ArrayList<>(); for (List devices : groupedConnectedDevices.values()) { - @Nullable CachedBluetoothDevice leadDevice = getLeadDevice(devices); + CachedBluetoothDevice leadDevice = getLeadDevice(devices); if (leadDevice == null) { Log.d(TAG, "Skip due to no lead device"); continue; @@ -206,7 +221,7 @@ public class AudioSharingUtils { return buildOrderedConnectedLeadDevices( localBtManager, groupedConnectedDevices, filterByInSharing) .stream() - .map(device -> buildAudioSharingDeviceItem(device)) + .map(AudioSharingUtils::buildAudioSharingDeviceItem) .collect(Collectors.toList()); } @@ -315,8 +330,9 @@ public class AudioSharingUtils { manager.getProfileManager().getLeAudioBroadcastProfile(); if (broadcast == null) { Log.d(TAG, "Skip stop broadcasting due to broadcast profile is null"); + } else { + broadcast.stopBroadcast(broadcast.getLatestBroadcastId()); } - broadcast.stopBroadcast(broadcast.getLatestBroadcastId()); } /** @@ -378,9 +394,32 @@ public class AudioSharingUtils { return false; } VolumeControlProfile vc = profileManager.getVolumeControlProfile(); - if (vc == null || !vc.isProfileReady()) { - return false; - } - return true; + return vc != null && vc.isProfileReady(); + } + + /** + * Build audio sharing dialog log event data + * + * @param sourcePageId The source page id on which the dialog is shown. * + * @param pageId The page id of the dialog. + * @param userTriggered Indicates whether the dialog is triggered by user click. + * @param deviceCountInSharing The count of the devices joining the audio sharing. + * @param candidateDeviceCount The count of the eligible devices to join the audio sharing. + * @return The event data to be attached to the audio sharing action logs. + */ + @NonNull + public static Pair[] buildAudioSharingDialogEventData( + int sourcePageId, + int pageId, + boolean userTriggered, + int deviceCountInSharing, + int candidateDeviceCount) { + return new Pair[] { + Pair.create(METRIC_KEY_SOURCE_PAGE_ID.ordinal(), sourcePageId), + Pair.create(METRIC_KEY_PAGE_ID.ordinal(), pageId), + Pair.create(METRIC_KEY_USER_TRIGGERED.ordinal(), userTriggered ? 1 : 0), + Pair.create(METRIC_KEY_DEVICE_COUNT_IN_SHARING.ordinal(), deviceCountInSharing), + Pair.create(METRIC_KEY_CANDIDATE_DEVICE_COUNT.ordinal(), candidateDeviceCount) + }; } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragmentTest.java index 4336e771c96..c63a1a971a4 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragmentTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogFragmentTest.java @@ -18,11 +18,17 @@ package com.android.settings.connecteddevice.audiosharing; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; +import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothStatusCodes; +import android.content.Context; import android.platform.test.flag.junit.SetFlagsRule; +import android.util.Pair; import android.view.View; import android.widget.Button; import android.widget.ImageView; @@ -34,6 +40,7 @@ import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.RecyclerView; import com.android.settings.R; +import com.android.settings.testutils.FakeFeatureFactory; import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; import com.android.settingslib.flags.Flags; @@ -72,30 +79,50 @@ public class AudioSharingDialogFragmentTest { new AudioSharingDeviceItem(TEST_DEVICE_NAME2, /* groupId= */ 2, /* isActive= */ false); private static final AudioSharingDeviceItem TEST_DEVICE_ITEM3 = new AudioSharingDeviceItem(TEST_DEVICE_NAME3, /* groupId= */ 3, /* isActive= */ false); + private static final AudioSharingDialogFragment.DialogEventListener EMPTY_EVENT_LISTENER = + new AudioSharingDialogFragment.DialogEventListener() { + @Override + public void onItemClick(AudioSharingDeviceItem item) {} + + @Override + public void onCancelClick() {} + }; + private static final Pair TEST_EVENT_DATA = Pair.create(1, 1); + private static final Pair[] TEST_EVENT_DATA_LIST = + new Pair[] {TEST_EVENT_DATA}; private Fragment mParent; private AudioSharingDialogFragment mFragment; - private ShadowBluetoothAdapter mShadowBluetoothAdapter; + private FakeFeatureFactory mFeatureFactory; @Before public void setUp() { ShadowAlertDialogCompat.reset(); - mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); - mShadowBluetoothAdapter.setEnabled(true); - mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); - mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); + mFeatureFactory = FakeFeatureFactory.setupForTest(); mFragment = new AudioSharingDialogFragment(); mParent = new Fragment(); FragmentController.setupFragment( mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null); } + @Test + public void getMetricsCategory_correctValue() { + assertThat(mFragment.getMetricsCategory()) + .isEqualTo(SettingsEnums.DIALOG_AUDIO_SHARING_ADD_DEVICE); + } + @Test public void onCreateDialog_flagOff_dialogNotExist() { mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, new ArrayList<>(), (item) -> {}); + AudioSharingDialogFragment.show( + mParent, new ArrayList<>(), EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); @@ -105,14 +132,20 @@ public class AudioSharingDialogFragmentTest { @Test public void onCreateDialog_flagOn_noConnectedDevice() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, new ArrayList<>(), (item) -> {}); + AudioSharingDialogFragment.show( + mParent, new ArrayList<>(), EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); TextView description = dialog.findViewById(R.id.description_text); + assertThat(description).isNotNull(); ImageView image = dialog.findViewById(R.id.description_image); + assertThat(image).isNotNull(); Button shareBtn = dialog.findViewById(R.id.positive_btn); + assertThat(shareBtn).isNotNull(); Button cancelBtn = dialog.findViewById(R.id.negative_btn); + assertThat(cancelBtn).isNotNull(); assertThat(dialog.isShowing()).isTrue(); assertThat(description.getVisibility()).isEqualTo(View.VISIBLE); assertThat(description.getText().toString()) @@ -125,13 +158,22 @@ public class AudioSharingDialogFragmentTest { @Test public void onCreateDialog_noConnectedDevice_dialogDismiss() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, new ArrayList<>(), (item) -> {}); + AudioSharingDialogFragment.show( + mParent, new ArrayList<>(), EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(android.R.id.button2).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(android.R.id.button2); + assertThat(btnView).isNotNull(); + btnView.performClick(); shadowMainLooper().idle(); assertThat(dialog.isShowing()).isFalse(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } @Test @@ -139,15 +181,21 @@ public class AudioSharingDialogFragmentTest { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); ArrayList list = new ArrayList<>(); list.add(TEST_DEVICE_ITEM1); - mFragment.show(mParent, list, (item) -> {}); + AudioSharingDialogFragment.show(mParent, list, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); TextView title = dialog.findViewById(R.id.title_text); + assertThat(title).isNotNull(); TextView description = dialog.findViewById(R.id.description_text); + assertThat(description).isNotNull(); ImageView image = dialog.findViewById(R.id.description_image); + assertThat(image).isNotNull(); Button shareBtn = dialog.findViewById(R.id.positive_btn); + assertThat(shareBtn).isNotNull(); Button cancelBtn = dialog.findViewById(R.id.negative_btn); + assertThat(cancelBtn).isNotNull(); assertThat(dialog.isShowing()).isTrue(); assertThat(title.getText().toString()) .isEqualTo( @@ -166,12 +214,22 @@ public class AudioSharingDialogFragmentTest { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); ArrayList list = new ArrayList<>(); list.add(TEST_DEVICE_ITEM1); - mFragment.show(mParent, list, (item) -> {}); + AudioSharingDialogFragment.show(mParent, list, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(R.id.negative_btn).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(R.id.negative_btn); + assertThat(btnView).isNotNull(); + btnView.performClick(); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isFalse(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } @Test @@ -180,13 +238,35 @@ public class AudioSharingDialogFragmentTest { ArrayList list = new ArrayList<>(); list.add(TEST_DEVICE_ITEM1); AtomicBoolean isShareBtnClicked = new AtomicBoolean(false); - mFragment.show(mParent, list, (item) -> isShareBtnClicked.set(true)); + AudioSharingDialogFragment.show( + mParent, + list, + new AudioSharingDialogFragment.DialogEventListener() { + @Override + public void onItemClick(AudioSharingDeviceItem item) { + isShareBtnClicked.set(true); + } + + @Override + public void onCancelClick() {} + }, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(R.id.positive_btn).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(R.id.positive_btn); + assertThat(btnView).isNotNull(); + btnView.performClick(); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isFalse(); assertThat(isShareBtnClicked.get()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } @Test @@ -196,15 +276,21 @@ public class AudioSharingDialogFragmentTest { list.add(TEST_DEVICE_ITEM1); list.add(TEST_DEVICE_ITEM2); list.add(TEST_DEVICE_ITEM3); - mFragment.show(mParent, list, (item) -> {}); + AudioSharingDialogFragment.show(mParent, list, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); TextView description = dialog.findViewById(R.id.description_text); + assertThat(description).isNotNull(); ImageView image = dialog.findViewById(R.id.description_image); + assertThat(image).isNotNull(); Button shareBtn = dialog.findViewById(R.id.positive_btn); + assertThat(shareBtn).isNotNull(); Button cancelBtn = dialog.findViewById(R.id.negative_btn); + assertThat(cancelBtn).isNotNull(); RecyclerView recyclerView = dialog.findViewById(R.id.device_btn_list); + assertThat(recyclerView).isNotNull(); assertThat(dialog.isShowing()).isTrue(); assertThat(description.getVisibility()).isEqualTo(View.VISIBLE); assertThat(description.getText().toString()) @@ -223,11 +309,35 @@ public class AudioSharingDialogFragmentTest { list.add(TEST_DEVICE_ITEM1); list.add(TEST_DEVICE_ITEM2); list.add(TEST_DEVICE_ITEM3); - mFragment.show(mParent, list, (item) -> {}); + AtomicBoolean isCancelBtnClicked = new AtomicBoolean(false); + AudioSharingDialogFragment.show( + mParent, + list, + new AudioSharingDialogFragment.DialogEventListener() { + @Override + public void onItemClick(AudioSharingDeviceItem item) {} + + @Override + public void onCancelClick() { + isCancelBtnClicked.set(true); + } + }, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(R.id.negative_btn).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(R.id.negative_btn); + assertThat(btnView).isNotNull(); + btnView.performClick(); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isFalse(); + assertThat(isCancelBtnClicked.get()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandlerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandlerTest.java index 570af1f3c19..633bc06aa30 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandlerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDialogHandlerTest.java @@ -24,6 +24,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; +import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcastReceiveState; @@ -32,6 +33,7 @@ import android.bluetooth.BluetoothStatusCodes; import android.content.Context; import android.os.Looper; import android.platform.test.flag.junit.SetFlagsRule; +import android.util.Pair; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; @@ -39,6 +41,7 @@ import androidx.fragment.app.FragmentActivity; import androidx.test.core.app.ApplicationProvider; import com.android.settings.bluetooth.Utils; +import com.android.settings.testutils.FakeFeatureFactory; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; import com.android.settings.testutils.shadow.ShadowBluetoothUtils; import com.android.settingslib.bluetooth.CachedBluetoothDevice; @@ -51,6 +54,7 @@ import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; import com.android.settingslib.flags.Flags; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; import com.google.common.truth.Correspondence; import org.junit.Before; @@ -87,6 +91,7 @@ public class AudioSharingDialogHandlerTest { Correspondence.from( (Fragment fragment, String tag) -> fragment instanceof DialogFragment + && ((DialogFragment) fragment).getTag() != null && ((DialogFragment) fragment).getTag().equals(tag), "is equal to"); @@ -107,20 +112,22 @@ public class AudioSharingDialogHandlerTest { private Fragment mParentFragment; @Mock private BluetoothLeBroadcastReceiveState mState; private Context mContext; - private ShadowBluetoothAdapter mShadowBluetoothAdapter; private AudioSharingDialogHandler mHandler; + private FakeFeatureFactory mFeatureFactory; @Before public void setup() { mContext = ApplicationProvider.getApplicationContext(); ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; mLocalBtManager = Utils.getLocalBtManager(mContext); - mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); - mShadowBluetoothAdapter.setEnabled(true); - mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); - mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); + mFeatureFactory = FakeFeatureFactory.setupForTest(); mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); when(mLocalBtManager.getProfileManager()).thenReturn(mLocalBtProfileManager); when(mLocalBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast); @@ -183,9 +190,33 @@ public class AudioSharingDialogHandlerTest { when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mState)); mHandler.handleDeviceConnected(mCachedDevice2, /* userTriggered= */ true); shadowOf(Looper.getMainLooper()).idle(); - assertThat(mParentFragment.getChildFragmentManager().getFragments()) + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) .comparingElementsUsing(TAG_EQUALS) .containsExactly(AudioSharingStopDialogFragment.tag()); + + AudioSharingStopDialogFragment fragment = + (AudioSharingStopDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_STOP_AUDIO_SHARING), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 0)); } @Test @@ -211,9 +242,33 @@ public class AudioSharingDialogHandlerTest { when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of()); mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ true); shadowOf(Looper.getMainLooper()).idle(); - assertThat(mParentFragment.getChildFragmentManager().getFragments()) + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) .comparingElementsUsing(TAG_EQUALS) .containsExactly(AudioSharingJoinDialogFragment.tag()); + + AudioSharingJoinDialogFragment fragment = + (AudioSharingJoinDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_START_AUDIO_SHARING), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 0), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 2)); } @Test @@ -227,9 +282,33 @@ public class AudioSharingDialogHandlerTest { when(mAssistant.getAllSources(mDevice3)).thenReturn(ImmutableList.of(mState)); mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ true); shadowOf(Looper.getMainLooper()).idle(); - assertThat(mParentFragment.getChildFragmentManager().getFragments()) + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) .comparingElementsUsing(TAG_EQUALS) .containsExactly(AudioSharingJoinDialogFragment.tag()); + + AudioSharingJoinDialogFragment fragment = + (AudioSharingJoinDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_AUDIO_SHARING_ADD_DEVICE), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 1)); } @Test @@ -245,9 +324,33 @@ public class AudioSharingDialogHandlerTest { when(mAssistant.getAllSources(mDevice4)).thenReturn(ImmutableList.of(mState)); mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ true); shadowOf(Looper.getMainLooper()).idle(); - assertThat(mParentFragment.getChildFragmentManager().getFragments()) + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) .comparingElementsUsing(TAG_EQUALS) .containsExactly(AudioSharingDisconnectDialogFragment.tag()); + + AudioSharingDisconnectDialogFragment fragment = + (AudioSharingDisconnectDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 2), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 1)); } @Test @@ -273,9 +376,33 @@ public class AudioSharingDialogHandlerTest { when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of(mState)); mHandler.handleDeviceConnected(mCachedDevice2, /* userTriggered= */ false); shadowOf(Looper.getMainLooper()).idle(); - assertThat(mParentFragment.getChildFragmentManager().getFragments()) + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) .comparingElementsUsing(TAG_EQUALS) .containsExactly(AudioSharingStopDialogFragment.tag()); + + AudioSharingStopDialogFragment fragment = + (AudioSharingStopDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_STOP_AUDIO_SHARING), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 0), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 0)); } @Test @@ -301,9 +428,33 @@ public class AudioSharingDialogHandlerTest { when(mAssistant.getAllSources(any())).thenReturn(ImmutableList.of()); mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ false); shadowOf(Looper.getMainLooper()).idle(); - assertThat(mParentFragment.getChildFragmentManager().getFragments()) + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) .comparingElementsUsing(TAG_EQUALS) .containsExactly(AudioSharingJoinDialogFragment.tag()); + + AudioSharingJoinDialogFragment fragment = + (AudioSharingJoinDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_START_AUDIO_SHARING), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 0), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 0), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 2)); } @Test @@ -317,9 +468,33 @@ public class AudioSharingDialogHandlerTest { when(mAssistant.getAllSources(mDevice3)).thenReturn(ImmutableList.of(mState)); mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ false); shadowOf(Looper.getMainLooper()).idle(); - assertThat(mParentFragment.getChildFragmentManager().getFragments()) + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) .comparingElementsUsing(TAG_EQUALS) .containsExactly(AudioSharingJoinDialogFragment.tag()); + + AudioSharingJoinDialogFragment fragment = + (AudioSharingJoinDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_AUDIO_SHARING_ADD_DEVICE), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 0), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 1)); } @Test @@ -334,9 +509,33 @@ public class AudioSharingDialogHandlerTest { when(mAssistant.getAllSources(mDevice4)).thenReturn(ImmutableList.of(mState)); mHandler.handleDeviceConnected(mCachedDevice1, /* userTriggered= */ false); shadowOf(Looper.getMainLooper()).idle(); - assertThat(mParentFragment.getChildFragmentManager().getFragments()) + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) .comparingElementsUsing(TAG_EQUALS) .containsExactly(AudioSharingDisconnectDialogFragment.tag()); + + AudioSharingDisconnectDialogFragment fragment = + (AudioSharingDisconnectDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.SETTINGS_CONNECTED_DEVICE_CATEGORY), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 0), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 2), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 1)); } @Test @@ -357,6 +556,11 @@ public class AudioSharingDialogHandlerTest { mHandler.closeOpeningDialogsForLeaDevice(mCachedDevice1); shadowOf(Looper.getMainLooper()).idle(); assertThat(mParentFragment.getChildFragmentManager().getFragments()).isEmpty(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + mContext, + SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS, + SettingsEnums.DIALOG_START_AUDIO_SHARING); } @Test @@ -377,6 +581,11 @@ public class AudioSharingDialogHandlerTest { mHandler.closeOpeningDialogsForNonLeaDevice(mCachedDevice2); shadowOf(Looper.getMainLooper()).idle(); assertThat(mParentFragment.getChildFragmentManager().getFragments()).isEmpty(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + mContext, + SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS, + SettingsEnums.DIALOG_STOP_AUDIO_SHARING); } private void setUpBroadcast(boolean isBroadcasting) { diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragmentTest.java index 348efbe931b..481c78d2917 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragmentTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDisconnectDialogFragmentTest.java @@ -18,13 +18,21 @@ package com.android.settings.connecteddevice.audiosharing; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; +import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothStatusCodes; +import android.content.Context; import android.platform.test.flag.junit.SetFlagsRule; +import android.util.Pair; +import android.view.View; import android.widget.Button; import androidx.appcompat.app.AlertDialog; @@ -33,6 +41,7 @@ import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.RecyclerView; import com.android.settings.R; +import com.android.settings.testutils.FakeFeatureFactory; import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; import com.android.settingslib.bluetooth.CachedBluetoothDevice; @@ -78,15 +87,19 @@ public class AudioSharingDisconnectDialogFragmentTest { new AudioSharingDeviceItem(TEST_DEVICE_NAME2, TEST_GROUP_ID2, /* isActive= */ false); private static final AudioSharingDeviceItem TEST_DEVICE_ITEM3 = new AudioSharingDeviceItem(TEST_DEVICE_NAME3, TEST_GROUP_ID3, /* isActive= */ false); + private static final AudioSharingDisconnectDialogFragment.DialogEventListener + EMPTY_EVENT_LISTENER = (AudioSharingDeviceItem item) -> {}; + private static final Pair TEST_EVENT_DATA = Pair.create(1, 1); + private static final Pair[] TEST_EVENT_DATA_LIST = + new Pair[] {TEST_EVENT_DATA}; @Mock private BluetoothDevice mDevice1; @Mock private BluetoothDevice mDevice3; - @Mock private CachedBluetoothDevice mCachedDevice1; @Mock private CachedBluetoothDevice mCachedDevice3; + private FakeFeatureFactory mFeatureFactory; private Fragment mParent; private AudioSharingDisconnectDialogFragment mFragment; - private ShadowBluetoothAdapter mShadowBluetoothAdapter; private ArrayList mDeviceItems = new ArrayList<>(); @Before @@ -96,12 +109,14 @@ public class AudioSharingDisconnectDialogFragmentTest { latestAlertDialog.dismiss(); ShadowAlertDialogCompat.reset(); } - mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); - mShadowBluetoothAdapter.setEnabled(true); - mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); - mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); + mFeatureFactory = FakeFeatureFactory.setupForTest(); when(mDevice1.getAnonymizedAddress()).thenReturn(TEST_ADDRESS1); when(mDevice3.getAnonymizedAddress()).thenReturn(TEST_ADDRESS3); when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1); @@ -116,13 +131,20 @@ public class AudioSharingDisconnectDialogFragmentTest { mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null); } + @Test + public void getMetricsCategory_correctValue() { + assertThat(mFragment.getMetricsCategory()) + .isEqualTo(SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE); + } + @Test public void onCreateDialog_flagOff_dialogNotExist() { mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); mDeviceItems = new ArrayList<>(); mDeviceItems.add(TEST_DEVICE_ITEM1); mDeviceItems.add(TEST_DEVICE_ITEM2); - mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {}); + AudioSharingDisconnectDialogFragment.show( + mParent, mDeviceItems, mCachedDevice3, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); @@ -135,12 +157,15 @@ public class AudioSharingDisconnectDialogFragmentTest { mDeviceItems = new ArrayList<>(); mDeviceItems.add(TEST_DEVICE_ITEM1); mDeviceItems.add(TEST_DEVICE_ITEM2); - mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {}); + AudioSharingDisconnectDialogFragment.show( + mParent, mDeviceItems, mCachedDevice3, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); RecyclerView view = dialog.findViewById(R.id.device_btn_list); + assertThat(view).isNotNull(); assertThat(view.getAdapter().getItemCount()).isEqualTo(2); } @@ -150,12 +175,14 @@ public class AudioSharingDisconnectDialogFragmentTest { mDeviceItems = new ArrayList<>(); mDeviceItems.add(TEST_DEVICE_ITEM1); mDeviceItems.add(TEST_DEVICE_ITEM2); - mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {}); + AudioSharingDisconnectDialogFragment.show( + mParent, mDeviceItems, mCachedDevice3, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); - AtomicBoolean isItemBtnClicked = new AtomicBoolean(false); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); RecyclerView view = dialog.findViewById(R.id.device_btn_list); + assertThat(view).isNotNull(); assertThat(view.getAdapter().getItemCount()).isEqualTo(2); Button btn1 = view.findViewHolderForAdapterPosition(0).itemView.findViewById(R.id.device_button); @@ -173,37 +200,71 @@ public class AudioSharingDisconnectDialogFragmentTest { TEST_DEVICE_NAME2)); // Update dialog content for device with same group - mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> isItemBtnClicked.set(true)); + AtomicBoolean isItemBtnClicked = new AtomicBoolean(false); + AudioSharingDisconnectDialogFragment.show( + mParent, + mDeviceItems, + mCachedDevice3, + (AudioSharingDeviceItem item) -> isItemBtnClicked.set(true), + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog.isShowing()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider, times(0)) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS), + eq(SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE)); + btn1 = view.findViewHolderForAdapterPosition(0).itemView.findViewById(R.id.device_button); btn1.performClick(); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isFalse(); assertThat(isItemBtnClicked.get()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } @Test - public void onCreateDialog_dialogIsShowingForNewGroup_updateDialog() { + public void onCreateDialog_dialogIsShowingForNewGroup_showNewDialog() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); mDeviceItems = new ArrayList<>(); mDeviceItems.add(TEST_DEVICE_ITEM1); mDeviceItems.add(TEST_DEVICE_ITEM2); - mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {}); + AudioSharingDisconnectDialogFragment.show( + mParent, mDeviceItems, mCachedDevice3, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); RecyclerView view = dialog.findViewById(R.id.device_btn_list); + assertThat(view).isNotNull(); assertThat(view.getAdapter().getItemCount()).isEqualTo(2); // Show new dialog for device with new group ArrayList newDeviceItems = new ArrayList<>(); newDeviceItems.add(TEST_DEVICE_ITEM2); newDeviceItems.add(TEST_DEVICE_ITEM3); - mFragment.show(mParent, newDeviceItems, mCachedDevice1, (item) -> {}); + AudioSharingDisconnectDialogFragment.show( + mParent, + newDeviceItems, + mCachedDevice1, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog.isShowing()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS), + eq(SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE)); + view = dialog.findViewById(R.id.device_btn_list); + assertThat(view).isNotNull(); assertThat(view.getAdapter().getItemCount()).isEqualTo(2); Button btn1 = view.findViewHolderForAdapterPosition(0).itemView.findViewById(R.id.device_button); @@ -227,12 +288,27 @@ public class AudioSharingDisconnectDialogFragmentTest { mDeviceItems = new ArrayList<>(); mDeviceItems.add(TEST_DEVICE_ITEM1); mDeviceItems.add(TEST_DEVICE_ITEM2); - mFragment.show(mParent, mDeviceItems, mCachedDevice3, (item) -> {}); + AudioSharingDisconnectDialogFragment.show( + mParent, mDeviceItems, mCachedDevice3, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + View btnView = dialog.findViewById(R.id.negative_btn); + assertThat(btnView).isNotNull(); + btnView.performClick(); shadowMainLooper().idle(); - AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - assertThat(dialog.isShowing()).isTrue(); - dialog.findViewById(R.id.negative_btn).performClick(); assertThat(dialog.isShowing()).isFalse(); + verify(mFeatureFactory.metricsFeatureProvider, times(0)) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS), + eq(SettingsEnums.DIALOG_AUDIO_SHARING_SWITCH_DEVICE)); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragmentTest.java index 2d55d97d4e1..c7b21ade886 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragmentTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingJoinDialogFragmentTest.java @@ -18,13 +18,19 @@ package com.android.settings.connecteddevice.audiosharing; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothStatusCodes; +import android.content.Context; import android.platform.test.flag.junit.SetFlagsRule; +import android.util.Pair; +import android.view.View; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; @@ -32,6 +38,7 @@ import androidx.fragment.app.FragmentActivity; import com.android.settings.R; import com.android.settings.bluetooth.Utils; +import com.android.settings.testutils.FakeFeatureFactory; import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; import com.android.settings.testutils.shadow.ShadowBluetoothUtils; @@ -82,6 +89,9 @@ public class AudioSharingJoinDialogFragmentTest { @Override public void onCancelClick() {} }; + private static final Pair TEST_EVENT_DATA = Pair.create(1, 1); + private static final Pair[] TEST_EVENT_DATA_LIST = + new Pair[] {TEST_EVENT_DATA}; @Mock private CachedBluetoothDevice mCachedDevice1; @Mock private CachedBluetoothDevice mCachedDevice2; @@ -90,7 +100,7 @@ public class AudioSharingJoinDialogFragmentTest { @Mock private LocalBluetoothLeBroadcast mBroadcast; private Fragment mParent; private AudioSharingJoinDialogFragment mFragment; - private ShadowBluetoothAdapter mShadowBluetoothAdapter; + private FakeFeatureFactory mFeatureFactory; @Before public void setUp() { @@ -99,12 +109,14 @@ public class AudioSharingJoinDialogFragmentTest { latestAlertDialog.dismiss(); ShadowAlertDialogCompat.reset(); } - mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); - mShadowBluetoothAdapter.setEnabled(true); - mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); - mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); + mFeatureFactory = FakeFeatureFactory.setupForTest(); when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1); when(mCachedDevice2.getName()).thenReturn(TEST_DEVICE_NAME2); mFragment = new AudioSharingJoinDialogFragment(); @@ -137,7 +149,12 @@ public class AudioSharingJoinDialogFragmentTest { @Test public void onCreateDialog_flagOff_dialogNotExist() { mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, new ArrayList<>(), mCachedDevice2, EMPTY_EVENT_LISTENER); + AudioSharingJoinDialogFragment.show( + mParent, + new ArrayList<>(), + mCachedDevice2, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNull(); @@ -146,7 +163,12 @@ public class AudioSharingJoinDialogFragmentTest { @Test public void onCreateDialog_flagOn_dialogShowTextForSingleDevice() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, new ArrayList<>(), mCachedDevice2, EMPTY_EVENT_LISTENER); + AudioSharingJoinDialogFragment.show( + mParent, + new ArrayList<>(), + mCachedDevice2, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); @@ -160,7 +182,8 @@ public class AudioSharingJoinDialogFragmentTest { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); ArrayList list = new ArrayList<>(); list.add(TEST_DEVICE_ITEM1); - mFragment.show(mParent, list, mCachedDevice2, EMPTY_EVENT_LISTENER); + AudioSharingJoinDialogFragment.show( + mParent, list, mCachedDevice2, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); @@ -179,7 +202,8 @@ public class AudioSharingJoinDialogFragmentTest { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); ArrayList list = new ArrayList<>(); list.add(TEST_DEVICE_ITEM1); - mFragment.show(mParent, list, mCachedDevice2, EMPTY_EVENT_LISTENER); + AudioSharingJoinDialogFragment.show( + mParent, list, mCachedDevice2, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); @@ -188,7 +212,8 @@ public class AudioSharingJoinDialogFragmentTest { // Update the content ArrayList list2 = new ArrayList<>(); list2.add(TEST_DEVICE_ITEM2); - mFragment.show(mParent, list2, mCachedDevice1, EMPTY_EVENT_LISTENER); + AudioSharingJoinDialogFragment.show( + mParent, list2, mCachedDevice1, EMPTY_EVENT_LISTENER, TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); @@ -205,11 +230,25 @@ public class AudioSharingJoinDialogFragmentTest { @Test public void onCreateDialog_clickCancel_dialogDismiss() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, new ArrayList<>(), mCachedDevice2, EMPTY_EVENT_LISTENER); + AudioSharingJoinDialogFragment.show( + mParent, + new ArrayList<>(), + mCachedDevice2, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(R.id.negative_btn).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(R.id.negative_btn); + assertThat(btnView).isNotNull(); + btnView.performClick(); + shadowMainLooper().idle(); assertThat(dialog.isShowing()).isFalse(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } @Test @@ -228,12 +267,22 @@ public class AudioSharingJoinDialogFragmentTest { @Override public void onCancelClick() {} - }); + }, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(R.id.positive_btn).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(R.id.positive_btn); + assertThat(btnView).isNotNull(); + btnView.performClick(); + shadowMainLooper().idle(); assertThat(dialog.isShowing()).isFalse(); assertThat(isShareBtnClicked.get()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } @Test @@ -252,11 +301,21 @@ public class AudioSharingJoinDialogFragmentTest { public void onCancelClick() { isCancelBtnClicked.set(true); } - }); + }, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(R.id.negative_btn).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(R.id.negative_btn); + assertThat(btnView).isNotNull(); + btnView.performClick(); + shadowMainLooper().idle(); assertThat(dialog.isShowing()).isFalse(); assertThat(isCancelBtnClicked.get()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragmentTest.java index 84d7a317164..7d46a18a4f2 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragmentTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingStopDialogFragmentTest.java @@ -18,13 +18,21 @@ package com.android.settings.connecteddevice.audiosharing; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; +import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothStatusCodes; +import android.content.Context; import android.platform.test.flag.junit.SetFlagsRule; +import android.util.Pair; +import android.view.View; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; @@ -32,6 +40,7 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import com.android.settings.R; +import com.android.settings.testutils.FakeFeatureFactory; import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; import com.android.settingslib.bluetooth.CachedBluetoothDevice; @@ -76,14 +85,19 @@ public class AudioSharingStopDialogFragmentTest { private static final AudioSharingDeviceItem TEST_DEVICE_ITEM3 = new AudioSharingDeviceItem( TEST_DEVICE_NAME3, TEST_DEVICE_GROUP_ID3, /* isActive= */ false); + private static final AudioSharingStopDialogFragment.DialogEventListener EMPTY_EVENT_LISTENER = + () -> {}; + private static final Pair TEST_EVENT_DATA = Pair.create(1, 1); + private static final Pair[] TEST_EVENT_DATA_LIST = + new Pair[] {TEST_EVENT_DATA}; @Mock private CachedBluetoothDevice mCachedDevice1; @Mock private CachedBluetoothDevice mCachedDevice2; @Mock private BluetoothDevice mDevice1; @Mock private BluetoothDevice mDevice2; + private FakeFeatureFactory mFeatureFactory; private Fragment mParent; private AudioSharingStopDialogFragment mFragment; - private ShadowBluetoothAdapter mShadowBluetoothAdapter; @Before public void setUp() { @@ -92,12 +106,14 @@ public class AudioSharingStopDialogFragmentTest { latestAlertDialog.dismiss(); ShadowAlertDialogCompat.reset(); } - mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); - mShadowBluetoothAdapter.setEnabled(true); - mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); - mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); + mFeatureFactory = FakeFeatureFactory.setupForTest(); when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1); when(mCachedDevice1.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID1); when(mCachedDevice1.getDevice()).thenReturn(mDevice1); @@ -110,10 +126,21 @@ public class AudioSharingStopDialogFragmentTest { mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null); } + @Test + public void getMetricsCategory_correctValue() { + assertThat(mFragment.getMetricsCategory()) + .isEqualTo(SettingsEnums.DIALOG_STOP_AUDIO_SHARING); + } + @Test public void onCreateDialog_flagOff_dialogNotExist() { mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, ImmutableList.of(), mCachedDevice1, () -> {}); + AudioSharingStopDialogFragment.show( + mParent, + ImmutableList.of(), + mCachedDevice1, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNull(); @@ -122,12 +149,18 @@ public class AudioSharingStopDialogFragmentTest { @Test public void onCreateDialog_oneDeviceInSharing_showDialogWithCorrectMessage() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, ImmutableList.of(TEST_DEVICE_ITEM2), mCachedDevice1, () -> {}); + AudioSharingStopDialogFragment.show( + mParent, + ImmutableList.of(TEST_DEVICE_ITEM2), + mCachedDevice1, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); TextView view = dialog.findViewById(R.id.description_text); + assertThat(view).isNotNull(); assertThat(view.getText().toString()) .isEqualTo( mParent.getString( @@ -137,16 +170,18 @@ public class AudioSharingStopDialogFragmentTest { @Test public void onCreateDialog_twoDeviceInSharing_showDialogWithCorrectMessage() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show( + AudioSharingStopDialogFragment.show( mParent, ImmutableList.of(TEST_DEVICE_ITEM2, TEST_DEVICE_ITEM3), mCachedDevice1, - () -> {}); + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); TextView view = dialog.findViewById(R.id.description_text); + assertThat(view).isNotNull(); assertThat(view.getText().toString()) .isEqualTo( mParent.getString( @@ -158,57 +193,99 @@ public class AudioSharingStopDialogFragmentTest { @Test public void onCreateDialog_dialogIsShowingForSameDevice_updateDialog() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, ImmutableList.of(), mCachedDevice1, () -> {}); + AudioSharingStopDialogFragment.show( + mParent, + ImmutableList.of(), + mCachedDevice1, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); TextView view = dialog.findViewById(R.id.description_text); + assertThat(view).isNotNull(); assertThat(view.getText().toString()) .isEqualTo(mParent.getString(R.string.audio_sharing_stop_dialog_with_more_content)); // Update the content AtomicBoolean isStopBtnClicked = new AtomicBoolean(false); - mFragment.show( - mParent, ImmutableList.of(), mCachedDevice1, () -> isStopBtnClicked.set(true)); + AudioSharingStopDialogFragment.show( + mParent, + ImmutableList.of(), + mCachedDevice1, + () -> isStopBtnClicked.set(true), + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider, times(0)) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS), + eq(SettingsEnums.DIALOG_STOP_AUDIO_SHARING)); - dialog.findViewById(android.R.id.button1).performClick(); + View btnView = dialog.findViewById(android.R.id.button1); + assertThat(btnView).isNotNull(); + btnView.performClick(); shadowMainLooper().idle(); assertThat(dialog.isShowing()).isFalse(); assertThat(isStopBtnClicked.get()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } @Test public void onCreateDialog_dialogIsShowingForNewDevice_showNewDialog() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, ImmutableList.of(), mCachedDevice1, () -> {}); + AudioSharingStopDialogFragment.show( + mParent, + ImmutableList.of(), + mCachedDevice1, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); TextView view = dialog.findViewById(R.id.description_text); + assertThat(view).isNotNull(); assertThat(view.getText().toString()) .isEqualTo(mParent.getString(R.string.audio_sharing_stop_dialog_with_more_content)); TextView title = dialog.findViewById(R.id.title_text); + assertThat(title).isNotNull(); assertThat(title.getText().toString()) .isEqualTo( mParent.getString( R.string.audio_sharing_stop_dialog_title, TEST_DEVICE_NAME1)); // Show new dialog - mFragment.show(mParent, ImmutableList.of(), mCachedDevice2, () -> {}); + AudioSharingStopDialogFragment.show( + mParent, + ImmutableList.of(), + mCachedDevice2, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); assertThat(dialog).isNotNull(); assertThat(dialog.isShowing()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS), + eq(SettingsEnums.DIALOG_STOP_AUDIO_SHARING)); + view = dialog.findViewById(R.id.description_text); + assertThat(view).isNotNull(); assertThat(view.getText().toString()) .isEqualTo(mParent.getString(R.string.audio_sharing_stop_dialog_with_more_content)); title = dialog.findViewById(R.id.title_text); + assertThat(title).isNotNull(); assertThat(title.getText().toString()) .isEqualTo( mParent.getString( @@ -218,25 +295,60 @@ public class AudioSharingStopDialogFragmentTest { @Test public void onCreateDialog_clickCancel_dialogDismiss() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); - mFragment.show(mParent, ImmutableList.of(), mCachedDevice1, () -> {}); + AudioSharingStopDialogFragment.show( + mParent, + ImmutableList.of(), + mCachedDevice1, + EMPTY_EVENT_LISTENER, + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(android.R.id.button2).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(android.R.id.button2); + assertThat(btnView).isNotNull(); + btnView.performClick(); shadowMainLooper().idle(); assertThat(dialog.isShowing()).isFalse(); + verify(mFeatureFactory.metricsFeatureProvider, times(0)) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS), + eq(SettingsEnums.DIALOG_STOP_AUDIO_SHARING)); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_NEGATIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } @Test public void onCreateDialog_clickShare_callbackTriggered() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); AtomicBoolean isStopBtnClicked = new AtomicBoolean(false); - mFragment.show( - mParent, ImmutableList.of(), mCachedDevice1, () -> isStopBtnClicked.set(true)); + AudioSharingStopDialogFragment.show( + mParent, + ImmutableList.of(), + mCachedDevice1, + () -> isStopBtnClicked.set(true), + TEST_EVENT_DATA_LIST); shadowMainLooper().idle(); AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - dialog.findViewById(android.R.id.button1).performClick(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(android.R.id.button1); + assertThat(btnView).isNotNull(); + btnView.performClick(); shadowMainLooper().idle(); assertThat(dialog.isShowing()).isFalse(); assertThat(isStopBtnClicked.get()).isTrue(); + verify(mFeatureFactory.metricsFeatureProvider, times(0)) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_AUTO_DISMISS), + eq(SettingsEnums.DIALOG_STOP_AUDIO_SHARING)); + verify(mFeatureFactory.metricsFeatureProvider) + .action( + any(Context.class), + eq(SettingsEnums.ACTION_AUDIO_SHARING_DIALOG_POSITIVE_BTN_CLICKED), + eq(TEST_EVENT_DATA)); } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarControllerTest.java index 0ead2d5d807..8f85feb89fd 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingSwitchBarControllerTest.java @@ -23,6 +23,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -30,6 +31,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; +import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothDevice; import android.bluetooth.BluetoothLeBroadcast; @@ -43,12 +45,17 @@ import android.content.IntentFilter; import android.os.Looper; import android.platform.test.flag.junit.SetFlagsRule; import android.util.FeatureFlagUtils; +import android.util.Pair; import android.widget.CompoundButton; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.LifecycleOwner; import androidx.test.core.app.ApplicationProvider; import com.android.settings.bluetooth.Utils; +import com.android.settings.testutils.FakeFeatureFactory; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; import com.android.settings.testutils.shadow.ShadowBluetoothUtils; import com.android.settings.testutils.shadow.ShadowThreadUtils; @@ -65,6 +72,8 @@ import com.android.settingslib.core.lifecycle.Lifecycle; import com.android.settingslib.flags.Flags; import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.truth.Correspondence; import org.junit.Before; import org.junit.Rule; @@ -77,7 +86,9 @@ import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.androidx.fragment.FragmentController; +import java.util.List; import java.util.concurrent.Executor; @RunWith(RobolectricTestRunner.class) @@ -88,6 +99,18 @@ import java.util.concurrent.Executor; ShadowThreadUtils.class, }) public class AudioSharingSwitchBarControllerTest { + private static final String TEST_DEVICE_NAME1 = "test1"; + private static final String TEST_DEVICE_NAME2 = "test2"; + private static final int TEST_DEVICE_GROUP_ID1 = 1; + private static final int TEST_DEVICE_GROUP_ID2 = 2; + private static final Correspondence TAG_EQUALS = + Correspondence.from( + (Fragment fragment, String tag) -> + fragment instanceof DialogFragment + && ((DialogFragment) fragment).getTag() != null + && ((DialogFragment) fragment).getTag().equals(tag), + "is equal to"); + @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); @@ -99,17 +122,19 @@ public class AudioSharingSwitchBarControllerTest { @Mock private LocalBluetoothLeBroadcastAssistant mAssistant; @Mock private VolumeControlProfile mVolumeControl; @Mock private CompoundButton mBtnView; - @Mock private CachedBluetoothDevice mCachedDevice; - @Mock private BluetoothDevice mDevice; + @Mock private CachedBluetoothDevice mCachedDevice1; + @Mock private CachedBluetoothDevice mCachedDevice2; + @Mock private BluetoothDevice mDevice1; + @Mock private BluetoothDevice mDevice2; private SettingsMainSwitchBar mSwitchBar; private AudioSharingSwitchBarController mController; - private AudioSharingSwitchBarController.OnAudioSharingStateChangedListener mListener; + private FakeFeatureFactory mFeatureFactory; private Lifecycle mLifecycle; private LifecycleOwner mLifecycleOwner; private boolean mOnAudioSharingStateChanged; private boolean mOnAudioSharingServiceConnected; private ShadowBluetoothAdapter mShadowBluetoothAdapter; - private LocalBluetoothManager mLocalBluetoothManager; + private Fragment mParentFragment; @Before public void setUp() { @@ -122,13 +147,20 @@ public class AudioSharingSwitchBarControllerTest { mLifecycleOwner = () -> mLifecycle; mLifecycle = new Lifecycle(mLifecycleOwner); ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; - mLocalBluetoothManager = Utils.getLocalBtManager(mContext); - when(mLocalBluetoothManager.getProfileManager()).thenReturn(mBtProfileManager); - when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(mDeviceManager); - when(mDeviceManager.findDevice(mDevice)).thenReturn(mCachedDevice); - when(mCachedDevice.getDevice()).thenReturn(mDevice); - when(mCachedDevice.getGroupId()).thenReturn(1); - when(mCachedDevice.getName()).thenReturn("test"); + LocalBluetoothManager localBluetoothManager = Utils.getLocalBtManager(mContext); + mFeatureFactory = FakeFeatureFactory.setupForTest(); + when(localBluetoothManager.getProfileManager()).thenReturn(mBtProfileManager); + when(localBluetoothManager.getCachedDeviceManager()).thenReturn(mDeviceManager); + when(mDeviceManager.findDevice(mDevice1)).thenReturn(mCachedDevice1); + when(mCachedDevice1.getDevice()).thenReturn(mDevice1); + when(mCachedDevice1.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID1); + when(mCachedDevice1.getName()).thenReturn(TEST_DEVICE_NAME1); + when(mCachedDevice1.isActiveDevice(BluetoothProfile.LE_AUDIO)).thenReturn(false); + when(mDeviceManager.findDevice(mDevice2)).thenReturn(mCachedDevice2); + when(mCachedDevice2.getDevice()).thenReturn(mDevice2); + when(mCachedDevice2.getGroupId()).thenReturn(TEST_DEVICE_GROUP_ID2); + when(mCachedDevice2.getName()).thenReturn(TEST_DEVICE_NAME2); + when(mCachedDevice2.isActiveDevice(BluetoothProfile.LE_AUDIO)).thenReturn(true); when(mBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast); when(mBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant); when(mBtProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl); @@ -153,7 +185,7 @@ public class AudioSharingSwitchBarControllerTest { mSwitchBar.setDisabledByAdmin(mock(RestrictedLockUtils.EnforcedAdmin.class)); mOnAudioSharingStateChanged = false; mOnAudioSharingServiceConnected = false; - mListener = + AudioSharingSwitchBarController.OnAudioSharingStateChangedListener listener = new AudioSharingSwitchBarController.OnAudioSharingStateChangedListener() { @Override public void onAudioSharingStateChanged() { @@ -165,7 +197,14 @@ public class AudioSharingSwitchBarControllerTest { mOnAudioSharingServiceConnected = true; } }; - mController = new AudioSharingSwitchBarController(mContext, mSwitchBar, mListener); + mController = new AudioSharingSwitchBarController(mContext, mSwitchBar, listener); + mParentFragment = new Fragment(); + FragmentController.setupFragment( + mParentFragment, + FragmentActivity.class, + 0 /* containerViewId */, + null /* bundle */); + mController.init(mParentFragment); } @Test @@ -356,7 +395,7 @@ public class AudioSharingSwitchBarControllerTest { when(mBtnView.isEnabled()).thenReturn(true); when(mAssistant.getDevicesMatchingConnectionStates( new int[] {BluetoothProfile.STATE_CONNECTED})) - .thenReturn(ImmutableList.of(mDevice)); + .thenReturn(ImmutableList.of(mDevice1)); doNothing().when(mBroadcast).startPrivateBroadcast(); mController.onCheckedChanged(mBtnView, /* isChecked= */ true); verify(mBroadcast).startPrivateBroadcast(); @@ -380,4 +419,50 @@ public class AudioSharingSwitchBarControllerTest { mController.onCheckedChanged(mBtnView, /* isChecked= */ false); verify(mBroadcast).stopBroadcast(1); } + + @Test + public void onPlaybackStarted_showJoinAudioSharingDialog() { + FeatureFlagUtils.setEnabled( + mContext, FeatureFlagUtils.SETTINGS_NEED_CONNECTED_BLE_DEVICE_FOR_BROADCAST, true); + when(mBtnView.isEnabled()).thenReturn(true); + when(mAssistant.getDevicesMatchingConnectionStates( + new int[] {BluetoothProfile.STATE_CONNECTED})) + .thenReturn(ImmutableList.of(mDevice2, mDevice1)); + doNothing().when(mBroadcast).startPrivateBroadcast(); + mController.onCheckedChanged(mBtnView, /* isChecked= */ true); + verify(mBroadcast).startPrivateBroadcast(); + mController.mBroadcastCallback.onPlaybackStarted(0, 0); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mFeatureFactory.metricsFeatureProvider) + .action(any(Context.class), eq(SettingsEnums.ACTION_AUTO_JOIN_AUDIO_SHARING)); + + List childFragments = mParentFragment.getChildFragmentManager().getFragments(); + assertThat(childFragments) + .comparingElementsUsing(TAG_EQUALS) + .containsExactly(AudioSharingDialogFragment.tag()); + + AudioSharingDialogFragment fragment = + (AudioSharingDialogFragment) Iterables.getOnlyElement(childFragments); + Pair[] eventData = fragment.getEventData(); + assertThat(eventData) + .asList() + .containsExactly( + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_SOURCE_PAGE_ID.ordinal(), + SettingsEnums.AUDIO_SHARING_SETTINGS), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_PAGE_ID.ordinal(), + SettingsEnums.DIALOG_AUDIO_SHARING_ADD_DEVICE), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_USER_TRIGGERED.ordinal(), 0), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_DEVICE_COUNT_IN_SHARING + .ordinal(), + 1), + Pair.create( + AudioSharingUtils.MetricKey.METRIC_KEY_CANDIDATE_DEVICE_COUNT + .ordinal(), + 1)); + } } From 6e5249e5fb7ba695e175a797784ea0f188086766 Mon Sep 17 00:00:00 2001 From: chelseahao Date: Wed, 5 Jun 2024 20:49:40 +0800 Subject: [PATCH 02/15] [Audiosharing] Start creating view after service is connected in confirm dialog activity. Test: atest Change-Id: If68621e0782c9639fc196385d0f42052d8f06798 --- .../android/settings/SettingsActivity.java | 2 +- .../AudioStreamConfirmDialogActivity.java | 76 ++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/com/android/settings/SettingsActivity.java b/src/com/android/settings/SettingsActivity.java index 02205c1b63a..a79ba8073ac 100644 --- a/src/com/android/settings/SettingsActivity.java +++ b/src/com/android/settings/SettingsActivity.java @@ -283,7 +283,7 @@ public class SettingsActivity extends SettingsBaseActivity createUiFromIntent(savedState, intent); } - protected void createUiFromIntent(Bundle savedState, Intent intent) { + protected void createUiFromIntent(@Nullable Bundle savedState, Intent intent) { long startTime = System.currentTimeMillis(); final FeatureFactory factory = FeatureFactory.getFeatureFactory(); diff --git a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogActivity.java b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogActivity.java index ddb0b425d71..88e2322ec1f 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogActivity.java +++ b/src/com/android/settings/connecteddevice/audiosharing/audiostreams/AudioStreamConfirmDialogActivity.java @@ -16,17 +16,91 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams; +import android.content.Intent; import android.os.Bundle; +import android.util.Log; + +import androidx.annotation.Nullable; import com.android.settings.SettingsActivity; +import com.android.settings.bluetooth.Utils; +import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils; +import com.android.settingslib.bluetooth.LocalBluetoothProfileManager; -public class AudioStreamConfirmDialogActivity extends SettingsActivity { +public class AudioStreamConfirmDialogActivity extends SettingsActivity + implements LocalBluetoothProfileManager.ServiceListener { + private static final String TAG = "AudioStreamConfirmDialogActivity"; + @Nullable private LocalBluetoothProfileManager mProfileManager; + @Nullable private Bundle mSavedState; + @Nullable private Intent mIntent; + + @Override + protected boolean isToolbarEnabled() { + return false; + } @Override protected void onCreate(Bundle savedState) { + var localBluetoothManager = Utils.getLocalBluetoothManager(this); + mProfileManager = + localBluetoothManager == null ? null : localBluetoothManager.getProfileManager(); super.onCreate(savedState); } + @Override + protected void createUiFromIntent(@Nullable Bundle savedState, Intent intent) { + if (AudioSharingUtils.isFeatureEnabled() + && !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) { + Log.d(TAG, "createUiFromIntent() : supported but not ready, skip createUiFromIntent"); + mSavedState = savedState; + mIntent = intent; + return; + } + + Log.d( + TAG, + "createUiFromIntent() : not supported or already connected, starting" + + " createUiFromIntent"); + super.createUiFromIntent(savedState, intent); + } + + @Override + public void onStart() { + if (AudioSharingUtils.isFeatureEnabled() + && !AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) { + Log.d(TAG, "onStart() : supported but not ready, listen to service ready"); + if (mProfileManager != null) { + mProfileManager.addServiceListener(this); + } + } + super.onStart(); + } + + @Override + public void onStop() { + if (mProfileManager != null) { + mProfileManager.removeServiceListener(this); + } + super.onStop(); + } + + @Override + public void onServiceConnected() { + if (AudioSharingUtils.isFeatureEnabled() + && AudioSharingUtils.isAudioSharingProfileReady(mProfileManager)) { + if (mProfileManager != null) { + mProfileManager.removeServiceListener(this); + } + if (mIntent != null) { + Log.d(TAG, "onServiceConnected() : service ready, starting createUiFromIntent"); + super.createUiFromIntent(mSavedState, mIntent); + } + } + } + + @Override + public void onServiceDisconnected() {} + @Override protected boolean isValidFragment(String fragmentName) { return AudioStreamConfirmDialog.class.getName().equals(fragmentName); From 52b5aef9995235d9c982c4ede889840dda92a8ae Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Fri, 7 Jun 2024 13:54:09 +0800 Subject: [PATCH 03/15] InternetPreferenceController V2 (2/n) Add ConnectivityRepository, when no validated connection, show "Airplane mode is on" or "Networks available". Bug: 339884322 Flag: com.android.settings.flags.internet_preference_controller_v2 Test: manual - on Internet Test: unit test Change-Id: I227d896fd52c099fb6d58a7ab60e98983fdab8cf --- .../network/ConnectivityRepository.kt | 63 +++++++++ .../network/InternetPreferenceControllerV2.kt | 3 +- .../network/InternetPreferenceRepository.kt | 82 ++++++++++++ .../wifi/repository/WifiRepository.kt | 44 +++++++ .../network/ConnectivityRepositoryTest.kt | 100 ++++++++++++++ .../InternetPreferenceRepositoryTest.kt | 123 ++++++++++++++++++ .../wifi/repository/WifiRepositoryTest.kt | 48 +++++++ 7 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 src/com/android/settings/network/ConnectivityRepository.kt create mode 100644 src/com/android/settings/network/InternetPreferenceRepository.kt create mode 100644 src/com/android/settings/wifi/repository/WifiRepository.kt create mode 100644 tests/spa_unit/src/com/android/settings/network/ConnectivityRepositoryTest.kt create mode 100644 tests/spa_unit/src/com/android/settings/network/InternetPreferenceRepositoryTest.kt create mode 100644 tests/spa_unit/src/com/android/settings/wifi/repository/WifiRepositoryTest.kt diff --git a/src/com/android/settings/network/ConnectivityRepository.kt b/src/com/android/settings/network/ConnectivityRepository.kt new file mode 100644 index 00000000000..3f9b61c394d --- /dev/null +++ b/src/com/android/settings/network/ConnectivityRepository.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2024 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.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn + +class ConnectivityRepository(context: Context) { + private val connectivityManager = context.getSystemService(ConnectivityManager::class.java)!! + + fun networkCapabilitiesFlow(): Flow = callbackFlow { + val callback = object : NetworkCallback() { + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities, + ) { + trySend(networkCapabilities) + Log.d(TAG, "onCapabilitiesChanged: $networkCapabilities") + } + + override fun onLost(network: Network) { + trySend(NetworkCapabilities()) + Log.d(TAG, "onLost") + } + } + trySend(getNetworkCapabilities()) + connectivityManager.registerDefaultNetworkCallback(callback) + + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } + }.conflate().flowOn(Dispatchers.Default) + + private fun getNetworkCapabilities(): NetworkCapabilities = + connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + ?: NetworkCapabilities() + + private companion object { + private const val TAG = "ConnectivityRepository" + } +} diff --git a/src/com/android/settings/network/InternetPreferenceControllerV2.kt b/src/com/android/settings/network/InternetPreferenceControllerV2.kt index f9d56189476..351aca83526 100644 --- a/src/com/android/settings/network/InternetPreferenceControllerV2.kt +++ b/src/com/android/settings/network/InternetPreferenceControllerV2.kt @@ -22,7 +22,6 @@ import androidx.preference.Preference import androidx.preference.PreferenceScreen import com.android.settings.R import com.android.settings.core.BasePreferenceController -import com.android.settings.wifi.WifiSummaryRepository import com.android.settingslib.spa.framework.util.collectLatestWithLifecycle class InternetPreferenceControllerV2(context: Context, preferenceKey: String) : @@ -40,7 +39,7 @@ class InternetPreferenceControllerV2(context: Context, preferenceKey: String) : } override fun onViewCreated(viewLifecycleOwner: LifecycleOwner) { - WifiSummaryRepository(mContext).summaryFlow() + InternetPreferenceRepository(mContext).summaryFlow() .collectLatestWithLifecycle(viewLifecycleOwner) { preference?.summary = it } diff --git a/src/com/android/settings/network/InternetPreferenceRepository.kt b/src/com/android/settings/network/InternetPreferenceRepository.kt new file mode 100644 index 00000000000..30a98d7cb1d --- /dev/null +++ b/src/com/android/settings/network/InternetPreferenceRepository.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2024 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.network + +import android.content.Context +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import android.provider.Settings +import android.util.Log +import com.android.settings.R +import com.android.settings.wifi.WifiSummaryRepository +import com.android.settings.wifi.repository.WifiRepository +import com.android.settingslib.spaprivileged.settingsprovider.settingsGlobalBooleanFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onEach + +@OptIn(ExperimentalCoroutinesApi::class) +class InternetPreferenceRepository( + private val context: Context, + private val connectivityRepository: ConnectivityRepository = ConnectivityRepository(context), + private val wifiSummaryRepository: WifiSummaryRepository = WifiSummaryRepository(context), + private val wifiRepository: WifiRepository = WifiRepository(context), + private val airplaneModeOnFlow: Flow = + context.settingsGlobalBooleanFlow(Settings.Global.AIRPLANE_MODE_ON), +) { + + fun summaryFlow(): Flow = connectivityRepository.networkCapabilitiesFlow() + .flatMapLatest { capabilities -> capabilities.summaryFlow() } + .onEach { Log.d(TAG, "summaryFlow: $it") } + .conflate() + .flowOn(Dispatchers.Default) + + private fun NetworkCapabilities.summaryFlow(): Flow { + if (hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + ) { + for (transportType in transportTypes) { + if (transportType == NetworkCapabilities.TRANSPORT_WIFI) { + return wifiSummaryRepository.summaryFlow() + } + } + } + return defaultSummaryFlow() + } + + private fun defaultSummaryFlow(): Flow = combine( + airplaneModeOnFlow, + wifiRepository.wifiStateFlow(), + ) { airplaneModeOn: Boolean, wifiState: Int -> + context.getString( + if (airplaneModeOn && wifiState != WifiManager.WIFI_STATE_ENABLED) { + R.string.condition_airplane_title + } else { + R.string.networks_available + } + ) + } + + private companion object { + private const val TAG = "InternetPreferenceRepo" + } +} diff --git a/src/com/android/settings/wifi/repository/WifiRepository.kt b/src/com/android/settings/wifi/repository/WifiRepository.kt new file mode 100644 index 00000000000..77f0b1b47cf --- /dev/null +++ b/src/com/android/settings/wifi/repository/WifiRepository.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 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.wifi.repository + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.wifi.WifiManager +import android.util.Log +import com.android.settingslib.spaprivileged.framework.common.broadcastReceiverFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class WifiRepository( + private val context: Context, + private val wifiStateChangedActionFlow: Flow = + context.broadcastReceiverFlow(IntentFilter(WifiManager.WIFI_STATE_CHANGED_ACTION)), +) { + + fun wifiStateFlow() = wifiStateChangedActionFlow + .map { intent -> + intent.getIntExtra(WifiManager.EXTRA_WIFI_STATE, WifiManager.WIFI_STATE_UNKNOWN) + } + .onEach { Log.d(TAG, "wifiStateFlow: $it") } + + private companion object { + private const val TAG = "WifiRepository" + } +} diff --git a/tests/spa_unit/src/com/android/settings/network/ConnectivityRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/network/ConnectivityRepositoryTest.kt new file mode 100644 index 00000000000..170b84d8884 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/network/ConnectivityRepositoryTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2024 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.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.ConnectivityManager.NetworkCallback +import android.net.Network +import android.net.NetworkCapabilities +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settingslib.spa.testutils.firstWithTimeoutOrNull +import com.android.settingslib.spa.testutils.toListWithTimeout +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.stub + +@RunWith(AndroidJUnit4::class) +class ConnectivityRepositoryTest { + + private var networkCallback: NetworkCallback? = null + + private val mockConnectivityManager = mock { + on { registerDefaultNetworkCallback(any()) } doAnswer { + networkCallback = it.arguments[0] as NetworkCallback + } + } + + private val context: Context = spy(ApplicationProvider.getApplicationContext()) { + on { getSystemService(ConnectivityManager::class.java) } doReturn mockConnectivityManager + } + + private val connectivityRepository = ConnectivityRepository(context) + + @Test + fun networkCapabilitiesFlow_activeNetworkIsNull_noCrash() = runBlocking { + mockConnectivityManager.stub { + on { activeNetwork } doReturn null + on { getNetworkCapabilities(null) } doReturn null + } + + val networkCapabilities = + connectivityRepository.networkCapabilitiesFlow().firstWithTimeoutOrNull()!! + + assertThat(networkCapabilities.transportTypes).isEmpty() + } + + @Test + fun networkCapabilitiesFlow_getInitialValue() = runBlocking { + val expectedNetworkCapabilities = NetworkCapabilities.Builder().apply { + addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + }.build() + mockConnectivityManager.stub { + on { getNetworkCapabilities(null) } doReturn expectedNetworkCapabilities + } + + val actualNetworkCapabilities = + connectivityRepository.networkCapabilitiesFlow().firstWithTimeoutOrNull()!! + + assertThat(actualNetworkCapabilities).isSameInstanceAs(expectedNetworkCapabilities) + } + + @Test + fun networkCapabilitiesFlow_getUpdatedValue() = runBlocking { + val expectedNetworkCapabilities = NetworkCapabilities.Builder().apply { + addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + }.build() + + val deferredList = async { + connectivityRepository.networkCapabilitiesFlow().toListWithTimeout() + } + delay(100) + networkCallback?.onCapabilitiesChanged(mock(), expectedNetworkCapabilities) + + assertThat(deferredList.await().last()).isSameInstanceAs(expectedNetworkCapabilities) + } +} diff --git a/tests/spa_unit/src/com/android/settings/network/InternetPreferenceRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/network/InternetPreferenceRepositoryTest.kt new file mode 100644 index 00000000000..4cd65e74505 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/network/InternetPreferenceRepositoryTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2024 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.network + +import android.content.Context +import android.net.NetworkCapabilities +import android.net.wifi.WifiManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settings.R +import com.android.settings.wifi.WifiSummaryRepository +import com.android.settings.wifi.repository.WifiRepository +import com.android.settingslib.spa.testutils.firstWithTimeoutOrNull +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub + +@RunWith(AndroidJUnit4::class) +class InternetPreferenceRepositoryTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val mockConnectivityRepository = mock() + private val mockWifiSummaryRepository = mock() + private val mockWifiRepository = mock() + private val airplaneModeOnFlow = MutableStateFlow(false) + + private val repository = InternetPreferenceRepository( + context = context, + connectivityRepository = mockConnectivityRepository, + wifiSummaryRepository = mockWifiSummaryRepository, + wifiRepository = mockWifiRepository, + airplaneModeOnFlow = airplaneModeOnFlow, + ) + + @Test + fun summaryFlow_wifi() = runBlocking { + val wifiNetworkCapabilities = NetworkCapabilities.Builder().apply { + addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + }.build() + mockConnectivityRepository.stub { + on { networkCapabilitiesFlow() } doReturn flowOf(wifiNetworkCapabilities) + } + mockWifiSummaryRepository.stub { + on { summaryFlow() } doReturn flowOf(SUMMARY) + } + + val summary = repository.summaryFlow().firstWithTimeoutOrNull() + + assertThat(summary).isEqualTo(SUMMARY) + } + + @Test + fun summaryFlow_airplaneModeOnAndWifiOn() = runBlocking { + mockConnectivityRepository.stub { + on { networkCapabilitiesFlow() } doReturn flowOf(NetworkCapabilities()) + } + airplaneModeOnFlow.value = true + mockWifiRepository.stub { + on { wifiStateFlow() } doReturn flowOf(WifiManager.WIFI_STATE_ENABLED) + } + + val summary = repository.summaryFlow().firstWithTimeoutOrNull() + + assertThat(summary).isEqualTo(context.getString(R.string.networks_available)) + } + + @Test + fun summaryFlow_airplaneModeOnAndWifiOff() = runBlocking { + mockConnectivityRepository.stub { + on { networkCapabilitiesFlow() } doReturn flowOf(NetworkCapabilities()) + } + airplaneModeOnFlow.value = true + mockWifiRepository.stub { + on { wifiStateFlow() } doReturn flowOf(WifiManager.WIFI_STATE_DISABLED) + } + + val summary = repository.summaryFlow().firstWithTimeoutOrNull() + + assertThat(summary).isEqualTo(context.getString(R.string.condition_airplane_title)) + } + + @Test + fun summaryFlow_airplaneModeOff() = runBlocking { + mockConnectivityRepository.stub { + on { networkCapabilitiesFlow() } doReturn flowOf(NetworkCapabilities()) + } + airplaneModeOnFlow.value = false + mockWifiRepository.stub { + on { wifiStateFlow() } doReturn flowOf(WifiManager.WIFI_STATE_DISABLED) + } + + val summary = repository.summaryFlow().firstWithTimeoutOrNull() + + assertThat(summary).isEqualTo(context.getString(R.string.networks_available)) + } + + private companion object { + const val SUMMARY = "Summary" + } +} diff --git a/tests/spa_unit/src/com/android/settings/wifi/repository/WifiRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/wifi/repository/WifiRepositoryTest.kt new file mode 100644 index 00000000000..dae3617c37e --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/wifi/repository/WifiRepositoryTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 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.wifi.repository + +import android.content.Context +import android.content.Intent +import android.net.wifi.WifiManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settingslib.spa.testutils.firstWithTimeoutOrNull +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class WifiRepositoryTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + + private val mockWifiStateChangedActionFlow = flowOf(Intent().apply { + putExtra(WifiManager.EXTRA_WIFI_STATE, WifiManager.WIFI_STATE_ENABLED) + }) + + private val repository = WifiRepository(context, mockWifiStateChangedActionFlow) + + @Test + fun wifiStateFlow() = runBlocking { + val wifiState = repository.wifiStateFlow().firstWithTimeoutOrNull() + + assertThat(wifiState).isEqualTo(WifiManager.WIFI_STATE_ENABLED) + } +} From 96537caa6d38162f12146c479b879c988e2ee7fb Mon Sep 17 00:00:00 2001 From: Hao Dong Date: Fri, 7 Jun 2024 20:21:49 +0000 Subject: [PATCH 04/15] Update to use PromptInfo.setLogo() Bug: 341697368 Test: Manually verified on the device Change-Id: Ie350ef4551367ce65b065f38e924f8ff93d16021 --- .../password/ConfirmDeviceCredentialActivity.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java b/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java index 30fd619d9df..0e60a68ed43 100644 --- a/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java +++ b/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java @@ -23,6 +23,8 @@ import static android.app.admin.DevicePolicyResources.Strings.Settings.CONFIRM_W import static android.Manifest.permission.SET_BIOMETRIC_DIALOG_ADVANCED; import static android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS; +import static com.android.systemui.biometrics.Utils.toBitmap; + import android.app.Activity; import android.app.KeyguardManager; import android.app.RemoteLockscreenValidationSession; @@ -35,6 +37,7 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.UserProperties; import android.content.res.Configuration; +import android.graphics.Bitmap; import android.graphics.Color; import android.hardware.biometrics.BiometricConstants; import android.hardware.biometrics.BiometricPrompt; @@ -215,9 +218,10 @@ public class ConfirmDeviceCredentialActivity extends FragmentActivity { && android.multiuser.Flags.usePrivateSpaceIconInBiometricPrompt() && hasSetBiometricDialogAdvanced(mContext, getLaunchedFromUid()) ) { - int iconResId = intent.getIntExtra(CUSTOM_BIOMETRIC_PROMPT_LOGO_RES_ID_KEY, 0); + final int iconResId = intent.getIntExtra(CUSTOM_BIOMETRIC_PROMPT_LOGO_RES_ID_KEY, 0); + final Bitmap iconBitmap = toBitmap(mContext.getDrawable(iconResId)); if (iconResId != 0) { - promptInfo.setLogoRes(iconResId); + promptInfo.setLogo(iconResId, iconBitmap); } String logoDescription = intent.getStringExtra( CUSTOM_BIOMETRIC_PROMPT_LOGO_DESCRIPTION_KEY); From 3e24b134c44d1c33997964b701590cd46b8adb06 Mon Sep 17 00:00:00 2001 From: Ang Li Date: Fri, 7 Jun 2024 20:25:56 +0000 Subject: [PATCH 05/15] Re-enables ChooseLockTypeDialogFragmentTest for robolectric Bug: 342667939 Test: atest SettingsRoboTests and ABTD (https://android-build.corp.google.com/builds/abtd/run/L66300030004349936) Change-Id: I50196830b25cc498cc6601daa2e8fcb99452093e --- .../settings/password/ChooseLockTypeDialogFragmentTest.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/robotests/src/com/android/settings/password/ChooseLockTypeDialogFragmentTest.java b/tests/robotests/src/com/android/settings/password/ChooseLockTypeDialogFragmentTest.java index 68f8ed736d1..24418bfeb41 100644 --- a/tests/robotests/src/com/android/settings/password/ChooseLockTypeDialogFragmentTest.java +++ b/tests/robotests/src/com/android/settings/password/ChooseLockTypeDialogFragmentTest.java @@ -37,7 +37,6 @@ import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; import com.android.settings.testutils.shadow.ShadowLockPatternUtils; import org.junit.Before; -import org.junit.Ignore; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; @@ -47,7 +46,6 @@ import org.robolectric.shadows.androidx.fragment.FragmentController; @RunWith(RobolectricTestRunner.class) @Config(shadows = {ShadowAlertDialogCompat.class, ShadowLockPatternUtils.class}) -@Ignore("b/342667939") public class ChooseLockTypeDialogFragmentTest { private Context mContext; From 4203f311dd48952696d8a998b141a087fb57a388 Mon Sep 17 00:00:00 2001 From: Yuri Lin Date: Tue, 28 May 2024 17:28:13 -0400 Subject: [PATCH 06/15] Add schedule setting page for time-based modes. Creates new layout for setting the start & end time, and days of the week, for a schedule-based mode. This is close to the mocks specified in https://screenshot.googleplex.com/8zmb7PAjjt73VkN, but with the following differences: - the end time is left-aligned with the center rather than on the right side of the screen. This is a side effect of using LinearLayout to evenly space start & end times, and could in theory be fixed by using a ConstraintLayout, but that option seems to cause times to overlap instead of wrap when display size is cranked up. Could be fixed later. - no icons yet on either side of the time display - no Done button. Instead, has the "exit at alarm" switch that exists today. Have not yet checked how this interacts with TalkBack, etc. Flag: android.app.modes_ui Bug: 332730302 Test: ZenModeSetSchedulePreferenceControllerTest, ZenModeExitAtAlarmPreferenceControllerTest, ZenModeSetTriggerLinkPreferenceControllerTest Test: manual: interacting with UI in normal size, with font & display at minimum and maximum, and in locales (fr) where the first day of the week is a different day Change-Id: I0b76f55891d6c12fc27720657c9eea6fe42fbafe --- res/color/modes_set_schedule_text_color.xml | 27 ++ res/drawable/modes_schedule_day_toggle.xml | 47 +++ res/layout/modes_set_schedule_layout.xml | 228 +++++++++++++++ res/values/strings.xml | 9 + res/xml/modes_set_schedule.xml | 38 +++ ...enModeExitAtAlarmPreferenceController.java | 56 ++++ .../modes/ZenModeSetScheduleFragment.java | 54 ++++ ...enModeSetSchedulePreferenceController.java | 274 ++++++++++++++++++ ...odeSetTriggerLinkPreferenceController.java | 17 +- .../modes/ZenModeTimePickerFragment.java | 76 +++++ ...deExitAtAlarmPreferenceControllerTest.java | 115 ++++++++ ...deSetSchedulePreferenceControllerTest.java | 163 +++++++++++ ...etTriggerLinkPreferenceControllerTest.java | 28 ++ 13 files changed, 1129 insertions(+), 3 deletions(-) create mode 100644 res/color/modes_set_schedule_text_color.xml create mode 100644 res/drawable/modes_schedule_day_toggle.xml create mode 100644 res/layout/modes_set_schedule_layout.xml create mode 100644 res/xml/modes_set_schedule.xml create mode 100644 src/com/android/settings/notification/modes/ZenModeExitAtAlarmPreferenceController.java create mode 100644 src/com/android/settings/notification/modes/ZenModeSetScheduleFragment.java create mode 100644 src/com/android/settings/notification/modes/ZenModeSetSchedulePreferenceController.java create mode 100644 src/com/android/settings/notification/modes/ZenModeTimePickerFragment.java create mode 100644 tests/robotests/src/com/android/settings/notification/modes/ZenModeExitAtAlarmPreferenceControllerTest.java create mode 100644 tests/robotests/src/com/android/settings/notification/modes/ZenModeSetSchedulePreferenceControllerTest.java diff --git a/res/color/modes_set_schedule_text_color.xml b/res/color/modes_set_schedule_text_color.xml new file mode 100644 index 00000000000..5ceb68e709c --- /dev/null +++ b/res/color/modes_set_schedule_text_color.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/res/drawable/modes_schedule_day_toggle.xml b/res/drawable/modes_schedule_day_toggle.xml new file mode 100644 index 00000000000..c09f5972833 --- /dev/null +++ b/res/drawable/modes_schedule_day_toggle.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/layout/modes_set_schedule_layout.xml b/res/layout/modes_set_schedule_layout.xml new file mode 100644 index 00000000000..5758cfb4be2 --- /dev/null +++ b/res/layout/modes_set_schedule_layout.xml @@ -0,0 +1,228 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/values/strings.xml b/res/values/strings.xml index f15137a66e8..ac50d9f614b 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -7961,6 +7961,15 @@ Schedule + + Set a schedule + + + Schedule + + + %1$d hr, %2$d min + Schedule diff --git a/res/xml/modes_set_schedule.xml b/res/xml/modes_set_schedule.xml new file mode 100644 index 00000000000..dd73ec814b6 --- /dev/null +++ b/res/xml/modes_set_schedule.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/com/android/settings/notification/modes/ZenModeExitAtAlarmPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeExitAtAlarmPreferenceController.java new file mode 100644 index 00000000000..8517af16975 --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeExitAtAlarmPreferenceController.java @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import android.content.Context; +import android.service.notification.ZenModeConfig; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; +import androidx.preference.TwoStatePreference; + +/** + * Preference controller controlling whether a time schedule-based mode ends at the next alarm. + */ +class ZenModeExitAtAlarmPreferenceController extends + AbstractZenModePreferenceController implements Preference.OnPreferenceChangeListener { + private ZenModeConfig.ScheduleInfo mSchedule; + + ZenModeExitAtAlarmPreferenceController(Context context, + String key, ZenModesBackend backend) { + super(context, key, backend); + } + + @Override + public void updateState(Preference preference, @NonNull ZenMode zenMode) { + mSchedule = ZenModeConfig.tryParseScheduleConditionId(zenMode.getRule().getConditionId()); + ((TwoStatePreference) preference).setChecked(mSchedule.exitAtAlarm); + } + + @Override + public boolean onPreferenceChange(@NonNull Preference preference, Object newValue) { + final boolean exitAtAlarm = (Boolean) newValue; + if (mSchedule.exitAtAlarm != exitAtAlarm) { + mSchedule.exitAtAlarm = exitAtAlarm; + return saveMode(mode -> { + mode.getRule().setConditionId(ZenModeConfig.toScheduleConditionId(mSchedule)); + return mode; + }); + } + return false; + } +} diff --git a/src/com/android/settings/notification/modes/ZenModeSetScheduleFragment.java b/src/com/android/settings/notification/modes/ZenModeSetScheduleFragment.java new file mode 100644 index 00000000000..4d58097b1dc --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeSetScheduleFragment.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import android.app.settings.SettingsEnums; +import android.content.Context; + +import com.android.settings.R; +import com.android.settingslib.core.AbstractPreferenceController; + +import java.util.ArrayList; +import java.util.List; + +/** + * Settings page to set a schedule for a mode that turns on automatically based on specific days + * of the week and times of day. + */ +public class ZenModeSetScheduleFragment extends ZenModeFragmentBase { + + @Override + protected int getPreferenceScreenResId() { + return R.xml.modes_set_schedule; + } + + @Override + protected List createPreferenceControllers(Context context) { + List controllers = new ArrayList<>(); + controllers.add( + new ZenModeSetSchedulePreferenceController(mContext, this, "schedule", mBackend)); + controllers.add( + new ZenModeExitAtAlarmPreferenceController(mContext, "exit_at_alarm", mBackend)); + return controllers; + } + + @Override + public int getMetricsCategory() { + // TODO: b/332937635 - make this the correct metrics category + return SettingsEnums.NOTIFICATION_ZEN_MODE_SCHEDULE_RULE; + } +} diff --git a/src/com/android/settings/notification/modes/ZenModeSetSchedulePreferenceController.java b/src/com/android/settings/notification/modes/ZenModeSetSchedulePreferenceController.java new file mode 100644 index 00000000000..a6008ccd768 --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeSetSchedulePreferenceController.java @@ -0,0 +1,274 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import android.app.Flags; +import android.content.Context; +import android.service.notification.SystemZenRules; +import android.service.notification.ZenModeConfig; +import android.text.format.DateFormat; +import android.util.ArraySet; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.ToggleButton; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; + +import com.android.settings.R; +import com.android.settingslib.widget.LayoutPreference; + +import java.text.SimpleDateFormat; +import java.time.Duration; +import java.util.Arrays; +import java.util.Calendar; +import java.util.function.Function; + +/** + * Preference controller for setting the start and end time and days of the week associated with + * an automatic zen mode. + */ +class ZenModeSetSchedulePreferenceController extends AbstractZenModePreferenceController { + // per-instance to ensure we're always using the current locale + // E = day of the week; "EEEEE" is the shortest version; "EEEE" is the full name + private final SimpleDateFormat mShortDayFormat = new SimpleDateFormat("EEEEE"); + private final SimpleDateFormat mLongDayFormat = new SimpleDateFormat("EEEE"); + + private static final String TAG = "ZenModeSetSchedulePreferenceController"; + private Fragment mParent; + private ZenModeConfig.ScheduleInfo mSchedule; + + ZenModeSetSchedulePreferenceController(Context context, Fragment parent, String key, + ZenModesBackend backend) { + super(context, key, backend); + mParent = parent; + } + + @Override + public void updateState(Preference preference, @NonNull ZenMode zenMode) { + mSchedule = ZenModeConfig.tryParseScheduleConditionId(zenMode.getRule().getConditionId()); + LayoutPreference layoutPref = (LayoutPreference) preference; + + TextView start = layoutPref.findViewById(R.id.start_time); + start.setText(timeString(mSchedule.startHour, mSchedule.startMinute)); + start.setOnClickListener( + timePickerLauncher(mSchedule.startHour, mSchedule.startMinute, mStartSetter)); + + TextView end = layoutPref.findViewById(R.id.end_time); + end.setText(timeString(mSchedule.endHour, mSchedule.endMinute)); + end.setOnClickListener( + timePickerLauncher(mSchedule.endHour, mSchedule.endMinute, mEndSetter)); + + TextView durationView = layoutPref.findViewById(R.id.schedule_duration); + durationView.setText(getScheduleDurationDescription(mSchedule)); + + ViewGroup daysContainer = layoutPref.findViewById(R.id.days_of_week_container); + setupDayToggles(daysContainer, mSchedule, Calendar.getInstance()); + } + + private String timeString(int hour, int minute) { + final Calendar c = Calendar.getInstance(); + c.set(Calendar.HOUR_OF_DAY, hour); + c.set(Calendar.MINUTE, minute); + return DateFormat.getTimeFormat(mContext).format(c.getTime()); + } + + private boolean isValidTime(int hour, int minute) { + return ZenModeConfig.isValidHour(hour) && ZenModeConfig.isValidMinute(minute); + } + + private String getScheduleDurationDescription(ZenModeConfig.ScheduleInfo schedule) { + final int startMin = 60 * schedule.startHour + schedule.startMinute; + final int endMin = 60 * schedule.endHour + schedule.endMinute; + final boolean nextDay = startMin >= endMin; + + Duration scheduleDuration; + if (nextDay) { + // add one day's worth of minutes (24h x 60min) to end minute for end time calculation + int endMinNextDay = endMin + (24 * 60); + scheduleDuration = Duration.ofMinutes(endMinNextDay - startMin); + } else { + scheduleDuration = Duration.ofMinutes(endMin - startMin); + } + + int hours = scheduleDuration.toHoursPart(); + int minutes = scheduleDuration.minusHours(hours).toMinutesPart(); + return mContext.getString(R.string.zen_mode_schedule_duration, hours, minutes); + } + + @VisibleForTesting + protected Function updateScheduleMode(ZenModeConfig.ScheduleInfo schedule) { + return (zenMode) -> { + zenMode.getRule().setConditionId(ZenModeConfig.toScheduleConditionId(schedule)); + if (Flags.modesApi() && Flags.modesUi()) { + zenMode.getRule().setTriggerDescription( + SystemZenRules.getTriggerDescriptionForScheduleTime(mContext, schedule)); + } + return zenMode; + }; + } + + private ZenModeTimePickerFragment.TimeSetter mStartSetter = (hour, minute) -> { + if (!isValidTime(hour, minute)) { + return; + } + if (hour == mSchedule.startHour && minute == mSchedule.startMinute) { + return; + } + mSchedule.startHour = hour; + mSchedule.startMinute = minute; + saveMode(updateScheduleMode(mSchedule)); + }; + + private ZenModeTimePickerFragment.TimeSetter mEndSetter = (hour, minute) -> { + if (!isValidTime(hour, minute)) { + return; + } + if (hour == mSchedule.endHour && minute == mSchedule.endMinute) { + return; + } + mSchedule.endHour = hour; + mSchedule.endMinute = minute; + saveMode(updateScheduleMode(mSchedule)); + }; + + private View.OnClickListener timePickerLauncher(int hour, int minute, + ZenModeTimePickerFragment.TimeSetter timeSetter) { + return v -> { + final ZenModeTimePickerFragment frag = new ZenModeTimePickerFragment(mContext, hour, + minute, timeSetter); + frag.show(mParent.getParentFragmentManager(), TAG); + }; + } + + protected static int[] getDaysOfWeekForLocale(Calendar c) { + int[] daysOfWeek = new int[7]; + int currentDay = c.getFirstDayOfWeek(); + for (int i = 0; i < daysOfWeek.length; i++) { + if (currentDay > 7) currentDay = 1; + daysOfWeek[i] = currentDay; + currentDay++; + } + return daysOfWeek; + } + + @VisibleForTesting + protected void setupDayToggles(ViewGroup dayContainer, ZenModeConfig.ScheduleInfo schedule, + Calendar c) { + int[] daysOfWeek = getDaysOfWeekForLocale(c); + + // Index in daysOfWeek is associated with the [idx]'th object in the list of days in the + // layout. Note that because the order of the days of the week may differ per locale, this + // is not necessarily the same as the actual value of the day number at that index. + for (int i = 0; i < daysOfWeek.length; i++) { + ToggleButton dayToggle = dayContainer.findViewById(resIdForDayIndex(i)); + if (dayToggle == null) { + continue; + } + + final int day = daysOfWeek[i]; + c.set(Calendar.DAY_OF_WEEK, day); + + // find current setting for this day + boolean dayEnabled = false; + if (schedule.days != null) { + for (int idx = 0; idx < schedule.days.length; idx++) { + if (schedule.days[idx] == day) { + dayEnabled = true; + break; + } + } + } + + // On/off is indicated by visuals, and both states share the shortest (one-character) + // day label. + dayToggle.setTextOn(mShortDayFormat.format(c.getTime())); + dayToggle.setTextOff(mShortDayFormat.format(c.getTime())); + dayToggle.setContentDescription(mLongDayFormat.format(c.getTime())); + + dayToggle.setChecked(dayEnabled); + dayToggle.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (updateScheduleDays(schedule, day, isChecked)) { + saveMode(updateScheduleMode(schedule)); + } + }); + + // If display and text settings cause the text to be larger than its containing box, + // don't show scrollbars. + dayToggle.setVerticalScrollBarEnabled(false); + dayToggle.setHorizontalScrollBarEnabled(false); + } + } + + // Updates the set of enabled days in provided schedule to either turn on or off the given day. + // The format of days in ZenModeConfig.ScheduleInfo is an array of days, where inclusion means + // the schedule is set to run on that day. Returns whether anything was changed. + @VisibleForTesting + protected static boolean updateScheduleDays(ZenModeConfig.ScheduleInfo schedule, int day, + boolean set) { + // Build a set representing the days that are currently set in mSchedule. + ArraySet daySet = new ArraySet(); + if (schedule.days != null) { + for (int i = 0; i < schedule.days.length; i++) { + daySet.add(schedule.days[i]); + } + } + + if (daySet.contains(day) != set) { + if (set) { + daySet.add(day); + } else { + daySet.remove(day); + } + + // rebuild days array for mSchedule + final int[] out = new int[daySet.size()]; + for (int i = 0; i < daySet.size(); i++) { + out[i] = daySet.valueAt(i); + } + Arrays.sort(out); + schedule.days = out; + return true; + } + // If the setting is the same as it was before, no need to update anything. + return false; + } + + protected static int resIdForDayIndex(int idx) { + switch (idx) { + case 0: + return R.id.day0; + case 1: + return R.id.day1; + case 2: + return R.id.day2; + case 3: + return R.id.day3; + case 4: + return R.id.day4; + case 5: + return R.id.day5; + case 6: + return R.id.day6; + default: + return 0; // unknown + } + } +} diff --git a/src/com/android/settings/notification/modes/ZenModeSetTriggerLinkPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeSetTriggerLinkPreferenceController.java index a3bc508cfbb..14d5d59a19d 100644 --- a/src/com/android/settings/notification/modes/ZenModeSetTriggerLinkPreferenceController.java +++ b/src/com/android/settings/notification/modes/ZenModeSetTriggerLinkPreferenceController.java @@ -16,6 +16,7 @@ package com.android.settings.notification.modes; import static android.app.AutomaticZenRule.TYPE_SCHEDULE_CALENDAR; +import static android.app.AutomaticZenRule.TYPE_SCHEDULE_TIME; import static com.android.settings.notification.modes.ZenModeFragmentBase.MODE_ID; @@ -32,13 +33,13 @@ import com.android.settings.core.SubSettingLauncher; import com.android.settingslib.PrimarySwitchPreference; /** - * Preference controller for the link + * Preference controller for the link to an individual mode's configuration page. */ -public class ZenModeSetTriggerLinkPreferenceController extends AbstractZenModePreferenceController { +class ZenModeSetTriggerLinkPreferenceController extends AbstractZenModePreferenceController { @VisibleForTesting protected static final String AUTOMATIC_TRIGGER_PREF_KEY = "zen_automatic_trigger_settings"; - public ZenModeSetTriggerLinkPreferenceController(Context context, String key, + ZenModeSetTriggerLinkPreferenceController(Context context, String key, ZenModesBackend backend) { super(context, key, backend); } @@ -66,6 +67,16 @@ public class ZenModeSetTriggerLinkPreferenceController extends AbstractZenModePr // TODO: b/341961712 - direct preference to app-owned intent if available switch (zenMode.getRule().getType()) { + case TYPE_SCHEDULE_TIME: + switchPref.setTitle(R.string.zen_mode_set_schedule_link); + switchPref.setSummary(zenMode.getRule().getTriggerDescription()); + switchPref.setIntent(new SubSettingLauncher(mContext) + .setDestination(ZenModeSetScheduleFragment.class.getName()) + // TODO: b/332937635 - set correct metrics category + .setSourceMetricsCategory(0) + .setArguments(bundle) + .toIntent()); + break; case TYPE_SCHEDULE_CALENDAR: switchPref.setTitle(R.string.zen_mode_set_calendar_link); switchPref.setSummary(zenMode.getRule().getTriggerDescription()); diff --git a/src/com/android/settings/notification/modes/ZenModeTimePickerFragment.java b/src/com/android/settings/notification/modes/ZenModeTimePickerFragment.java new file mode 100644 index 00000000000..d8e1b38875b --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeTimePickerFragment.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import android.app.Dialog; +import android.app.TimePickerDialog; +import android.app.settings.SettingsEnums; +import android.content.Context; +import android.os.Bundle; +import android.text.format.DateFormat; +import android.widget.TimePicker; + +import androidx.annotation.NonNull; + +import com.android.settings.core.instrumentation.InstrumentedDialogFragment; + +/** + * Dialog that shows when a user selects a (start or end) time to edit for a schedule-based mode. + */ +public class ZenModeTimePickerFragment extends InstrumentedDialogFragment implements + TimePickerDialog.OnTimeSetListener { + private final Context mContext; + private final TimeSetter mTimeSetter; + private final int mHour; + private final int mMinute; + + public ZenModeTimePickerFragment(Context context, int hour, int minute, + @NonNull TimeSetter timeSetter) { + super(); + mContext = context; + mHour = hour; + mMinute = minute; + mTimeSetter = timeSetter; + } + + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return new TimePickerDialog(mContext, this, mHour, mMinute, + DateFormat.is24HourFormat(mContext)); + } + + /** + * Calls the provided TimeSetter's setTime() method when a time is set on the TimePicker. + */ + public void onTimeSet(TimePicker view, int hourOfDay, int minute) { + mTimeSetter.setTime(hourOfDay, minute); + } + + @Override + public int getMetricsCategory() { + // TODO: b/332937635 - set correct metrics category (or decide to keep this one?) + return SettingsEnums.DIALOG_ZEN_TIMEPICKER; + } + + /** + * Interface for a method to pass into the TimePickerFragment that specifies what to do when the + * time is updated. + */ + public interface TimeSetter { + void setTime(int hour, int minute); + } +} diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeExitAtAlarmPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeExitAtAlarmPreferenceControllerTest.java new file mode 100644 index 00000000000..c1c4d61727f --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeExitAtAlarmPreferenceControllerTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import android.app.AutomaticZenRule; +import android.content.Context; +import android.service.notification.ZenModeConfig; + +import androidx.preference.TwoStatePreference; +import androidx.test.core.app.ApplicationProvider; + +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.RobolectricTestRunner; + +import java.util.Calendar; + +@RunWith(RobolectricTestRunner.class) +public class ZenModeExitAtAlarmPreferenceControllerTest { + private Context mContext; + @Mock + private ZenModesBackend mBackend; + + private ZenModeExitAtAlarmPreferenceController mPrefController; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = ApplicationProvider.getApplicationContext(); + mPrefController = new ZenModeExitAtAlarmPreferenceController(mContext, "exit_at_alarm", + mBackend); + } + + @Test + public void testUpdateState() { + TwoStatePreference preference = mock(TwoStatePreference.class); + + // previously: don't exit at alarm + ZenModeConfig.ScheduleInfo scheduleInfo = new ZenModeConfig.ScheduleInfo(); + scheduleInfo.days = new int[] { Calendar.MONDAY }; + scheduleInfo.startHour = 1; + scheduleInfo.endHour = 2; + scheduleInfo.exitAtAlarm = false; + + ZenMode mode = new ZenMode("id", + new AutomaticZenRule.Builder("name", + ZenModeConfig.toScheduleConditionId(scheduleInfo)).build(), + true); // is active + + // need to call updateZenMode for the first call + mPrefController.updateZenMode(preference, mode); + verify(preference).setChecked(false); + + // Now update state after changing exitAtAlarm + scheduleInfo.exitAtAlarm = true; + mode.getRule().setConditionId(ZenModeConfig.toScheduleConditionId(scheduleInfo)); + + // now can just call updateState + mPrefController.updateState(preference, mode); + verify(preference).setChecked(true); + } + + @Test + public void testOnPreferenceChange() { + TwoStatePreference preference = mock(TwoStatePreference.class); + + // previously: exit at alarm + ZenModeConfig.ScheduleInfo scheduleInfo = new ZenModeConfig.ScheduleInfo(); + scheduleInfo.days = new int[] { Calendar.MONDAY }; + scheduleInfo.startHour = 1; + scheduleInfo.endHour = 2; + scheduleInfo.exitAtAlarm = true; + + ZenMode mode = new ZenMode("id", + new AutomaticZenRule.Builder("name", + ZenModeConfig.toScheduleConditionId(scheduleInfo)).build(), + true); // is active + mPrefController.updateZenMode(preference, mode); + + // turn off exit at alarm + mPrefController.onPreferenceChange(preference, false); + ArgumentCaptor captor = ArgumentCaptor.forClass(ZenMode.class); + verify(mBackend).updateMode(captor.capture()); + ZenModeConfig.ScheduleInfo newSchedule = ZenModeConfig.tryParseScheduleConditionId( + captor.getValue().getRule().getConditionId()); + assertThat(newSchedule.exitAtAlarm).isFalse(); + + // other properties remain the same + assertThat(newSchedule.startHour).isEqualTo(1); + assertThat(newSchedule.endHour).isEqualTo(2); + } +} diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeSetSchedulePreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeSetSchedulePreferenceControllerTest.java new file mode 100644 index 00000000000..7cf327c983e --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeSetSchedulePreferenceControllerTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import static android.platform.test.flag.junit.SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.AutomaticZenRule; +import android.app.Flags; +import android.content.Context; +import android.net.Uri; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; +import android.service.notification.ZenModeConfig; +import android.view.ViewGroup; +import android.widget.ToggleButton; + +import androidx.fragment.app.Fragment; +import androidx.test.core.app.ApplicationProvider; + +import com.android.settings.R; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.util.Calendar; + +@RunWith(RobolectricTestRunner.class) +public class ZenModeSetSchedulePreferenceControllerTest { + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(DEVICE_DEFAULT); + + @Mock + private ZenModesBackend mBackend; + private Context mContext; + + @Mock + private Fragment mParent; + @Mock + private Calendar mCalendar; + @Mock + private ViewGroup mDaysContainer; + @Mock + private ToggleButton mDay0, mDay1, mDay2, mDay3, mDay4, mDay5, mDay6; + + private ZenModeSetSchedulePreferenceController mPrefController; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + mContext = ApplicationProvider.getApplicationContext(); + mPrefController = new ZenModeSetSchedulePreferenceController(mContext, mParent, "schedule", + mBackend); + setupMockDayContainer(); + } + + @Test + @EnableFlags({Flags.FLAG_MODES_API, Flags.FLAG_MODES_UI}) + public void updateScheduleRule_updatesConditionAndTriggerDescription() { + ZenMode mode = new ZenMode("id", + new AutomaticZenRule.Builder("name", Uri.parse("condition")).build(), + true); // is active + + ZenModeConfig.ScheduleInfo scheduleInfo = new ZenModeConfig.ScheduleInfo(); + scheduleInfo.days = new int[] { Calendar.MONDAY }; + scheduleInfo.startHour = 1; + scheduleInfo.endHour = 2; + ZenMode out = mPrefController.updateScheduleMode(scheduleInfo).apply(mode); + + assertThat(out.getRule().getConditionId()) + .isEqualTo(ZenModeConfig.toScheduleConditionId(scheduleInfo)); + assertThat(out.getRule().getTriggerDescription()).isNotEmpty(); + } + + @Test + public void testUpdateScheduleDays() { + // Confirm that adding/subtracting/etc days works as expected + // starting from null: no days set + ZenModeConfig.ScheduleInfo schedule = new ZenModeConfig.ScheduleInfo(); + + // Unset a day that's already unset: nothing should change + assertThat(ZenModeSetSchedulePreferenceController.updateScheduleDays(schedule, + Calendar.TUESDAY, false)).isFalse(); + // not explicitly checking whether schedule.days is still null here, as we don't necessarily + // want to require nullness as distinct from an empty list of days. + + // set a few new days + assertThat(ZenModeSetSchedulePreferenceController.updateScheduleDays(schedule, + Calendar.MONDAY, true)).isTrue(); + assertThat(ZenModeSetSchedulePreferenceController.updateScheduleDays(schedule, + Calendar.FRIDAY, true)).isTrue(); + assertThat(schedule.days).hasLength(2); + assertThat(schedule.days).asList().containsExactly(Calendar.MONDAY, Calendar.FRIDAY); + + // remove an existing day to make sure that works + assertThat(ZenModeSetSchedulePreferenceController.updateScheduleDays(schedule, + Calendar.MONDAY, false)).isTrue(); + assertThat(schedule.days).hasLength(1); + assertThat(schedule.days).asList().containsExactly(Calendar.FRIDAY); + } + + @Test + public void testSetupDayToggles_daysOfWeekOrder() { + // Confirm that days are correctly associated with the actual day of the week independent + // of when the first day of the week is for the given calendar. + ZenModeConfig.ScheduleInfo schedule = new ZenModeConfig.ScheduleInfo(); + schedule.days = new int[] { Calendar.SUNDAY, Calendar.TUESDAY, Calendar.FRIDAY }; + schedule.startHour = 1; + schedule.endHour = 5; + + // Start mCalendar on Wednesday, arbitrarily + when(mCalendar.getFirstDayOfWeek()).thenReturn(Calendar.WEDNESDAY); + + // Setup the day toggles + mPrefController.setupDayToggles(mDaysContainer, schedule, mCalendar); + + // we should see toggle 0 associated with the first day of the week, etc. + // in this week order, schedule turns on friday (2), sunday (4), tuesday (6) so those + // should be checked while everything else should not be checked. + verify(mDay0).setChecked(false); // weds + verify(mDay1).setChecked(false); // thurs + verify(mDay2).setChecked(true); // fri + verify(mDay3).setChecked(false); // sat + verify(mDay4).setChecked(true); // sun + verify(mDay5).setChecked(false); // mon + verify(mDay6).setChecked(true); // tues + } + + private void setupMockDayContainer() { + // associate each index (regardless of associated day of the week) with the appropriate + // res id in the days container + when(mDaysContainer.findViewById(R.id.day0)).thenReturn(mDay0); + when(mDaysContainer.findViewById(R.id.day1)).thenReturn(mDay1); + when(mDaysContainer.findViewById(R.id.day2)).thenReturn(mDay2); + when(mDaysContainer.findViewById(R.id.day3)).thenReturn(mDay3); + when(mDaysContainer.findViewById(R.id.day4)).thenReturn(mDay4); + when(mDaysContainer.findViewById(R.id.day5)).thenReturn(mDay5); + when(mDaysContainer.findViewById(R.id.day6)).thenReturn(mDay6); + } +} diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeSetTriggerLinkPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeSetTriggerLinkPreferenceControllerTest.java index 7dcec1cfeed..91de4ea8348 100644 --- a/tests/robotests/src/com/android/settings/notification/modes/ZenModeSetTriggerLinkPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeSetTriggerLinkPreferenceControllerTest.java @@ -17,6 +17,7 @@ package com.android.settings.notification.modes; import static android.app.AutomaticZenRule.TYPE_SCHEDULE_CALENDAR; +import static android.app.AutomaticZenRule.TYPE_SCHEDULE_TIME; import static android.app.NotificationManager.INTERRUPTION_FILTER_PRIORITY; import static android.platform.test.flag.junit.SetFlagsRule.DefaultInitValueType.DEVICE_DEFAULT; @@ -53,6 +54,8 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; import org.robolectric.RobolectricTestRunner; +import java.util.Calendar; + @RunWith(RobolectricTestRunner.class) public class ZenModeSetTriggerLinkPreferenceControllerTest { @Rule @@ -167,4 +170,29 @@ public class ZenModeSetTriggerLinkPreferenceControllerTest { captor.getValue().getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)).isEqualTo( ZenModeSetCalendarFragment.class.getName()); } + + @Test + public void testRuleLink_schedule() { + ZenModeConfig.ScheduleInfo scheduleInfo = new ZenModeConfig.ScheduleInfo(); + scheduleInfo.days = new int[] { Calendar.MONDAY, Calendar.TUESDAY, Calendar.THURSDAY }; + scheduleInfo.startHour = 1; + scheduleInfo.endHour = 15; + ZenMode mode = new ZenMode("id", new AutomaticZenRule.Builder("name", + ZenModeConfig.toScheduleConditionId(scheduleInfo)) + .setType(TYPE_SCHEDULE_TIME) + .setTriggerDescription("some schedule") + .build(), + true); // is active + mPrefController.updateZenMode(mPrefCategory, mode); + + verify(mPreference).setTitle(R.string.zen_mode_set_schedule_link); + verify(mPreference).setSummary(mode.getRule().getTriggerDescription()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Intent.class); + verify(mPreference).setIntent(captor.capture()); + // Destination as written into the intent by SubSettingLauncher + assertThat( + captor.getValue().getStringExtra(SettingsActivity.EXTRA_SHOW_FRAGMENT)).isEqualTo( + ZenModeSetScheduleFragment.class.getName()); + } } From 8d397f038ff152e8cc82bf3e2fe7468494eab7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Hern=C3=A1ndez?= Date: Thu, 6 Jun 2024 12:28:49 +0200 Subject: [PATCH 07/15] Add icon picker for zen modes Bug: 333901673 Test: atest ZenModeIconPickerListPreferenceControllerTest Flag: android.app.modes_ui Change-Id: Ib6faaac7c93b25423d18dbdcd0a2310de15fa86f --- .../ic_zen_mode_action_change_icon.xml | 25 +++ res/layout/modes_icon_list.xml | 38 ++++ res/layout/modes_icon_list_item.xml | 31 ++++ res/values/dimens.xml | 5 + res/values/strings.xml | 9 + res/xml/modes_icon_picker.xml | 37 ++++ res/xml/modes_rule_settings.xml | 4 + .../settings/notification/modes/IconUtil.java | 69 +++++++ .../settings/notification/modes/ZenMode.java | 8 + .../ZenModeActionsPreferenceController.java | 64 +++++++ .../notification/modes/ZenModeFragment.java | 1 + .../modes/ZenModeFragmentBase.java | 4 +- .../modes/ZenModeHeaderController.java | 5 +- .../modes/ZenModeIconPickerFragment.java | 49 +++++ ...odeIconPickerIconPreferenceController.java | 59 ++++++ ...odeIconPickerListPreferenceController.java | 170 ++++++++++++++++++ .../modes/ZenModeListPreference.java | 7 +- ...conPickerListPreferenceControllerTest.java | 91 ++++++++++ 18 files changed, 665 insertions(+), 11 deletions(-) create mode 100644 res/drawable/ic_zen_mode_action_change_icon.xml create mode 100644 res/layout/modes_icon_list.xml create mode 100644 res/layout/modes_icon_list_item.xml create mode 100644 res/xml/modes_icon_picker.xml create mode 100644 src/com/android/settings/notification/modes/IconUtil.java create mode 100644 src/com/android/settings/notification/modes/ZenModeActionsPreferenceController.java create mode 100644 src/com/android/settings/notification/modes/ZenModeIconPickerFragment.java create mode 100644 src/com/android/settings/notification/modes/ZenModeIconPickerIconPreferenceController.java create mode 100644 src/com/android/settings/notification/modes/ZenModeIconPickerListPreferenceController.java create mode 100644 tests/robotests/src/com/android/settings/notification/modes/ZenModeIconPickerListPreferenceControllerTest.java diff --git a/res/drawable/ic_zen_mode_action_change_icon.xml b/res/drawable/ic_zen_mode_action_change_icon.xml new file mode 100644 index 00000000000..4cf4167314a --- /dev/null +++ b/res/drawable/ic_zen_mode_action_change_icon.xml @@ -0,0 +1,25 @@ + + + + \ No newline at end of file diff --git a/res/layout/modes_icon_list.xml b/res/layout/modes_icon_list.xml new file mode 100644 index 00000000000..87e647eb7a6 --- /dev/null +++ b/res/layout/modes_icon_list.xml @@ -0,0 +1,38 @@ + + + + + + + + + \ No newline at end of file diff --git a/res/layout/modes_icon_list_item.xml b/res/layout/modes_icon_list_item.xml new file mode 100644 index 00000000000..aa45de33b72 --- /dev/null +++ b/res/layout/modes_icon_list_item.xml @@ -0,0 +1,31 @@ + + + + + + + + diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 402fd042fab..f07bc8f1ec0 100755 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -491,4 +491,9 @@ 264dp 30dp + + + 96dp + 56dp + 32dp diff --git a/res/values/strings.xml b/res/values/strings.xml index ba14ce12558..53b5243122d 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -9309,6 +9309,15 @@ Change to always interrupt + + Rename + + + Change icon + + + Change icon + Warning diff --git a/res/xml/modes_icon_picker.xml b/res/xml/modes_icon_picker.xml new file mode 100644 index 00000000000..cb0ff302672 --- /dev/null +++ b/res/xml/modes_icon_picker.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + diff --git a/res/xml/modes_rule_settings.xml b/res/xml/modes_rule_settings.xml index f2822741bc7..cf090be46d3 100644 --- a/res/xml/modes_rule_settings.xml +++ b/res/xml/modes_rule_settings.xml @@ -28,6 +28,10 @@ android:selectable="false" android:layout="@layout/modes_activation_button"/> + + diff --git a/src/com/android/settings/notification/modes/IconUtil.java b/src/com/android/settings/notification/modes/IconUtil.java new file mode 100644 index 00000000000..c6ecaa0a56d --- /dev/null +++ b/src/com/android/settings/notification/modes/IconUtil.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import static com.google.common.base.Preconditions.checkNotNull; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; +import android.graphics.drawable.ShapeDrawable; +import android.graphics.drawable.shapes.OvalShape; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; + +import com.android.settings.R; +import com.android.settingslib.Utils; + +class IconUtil { + + static Drawable applyTint(@NonNull Context context, @NonNull Drawable icon) { + icon = icon.mutate(); + icon.setTintList( + Utils.getColorAttr(context, android.R.attr.colorControlNormal)); + return icon; + } + + /** + * Returns a variant of the supplied {@code icon} to be used in the icon picker. The inner icon + * is 36x36dp and it's contained into a circle of diameter 54dp. + */ + static Drawable makeIconCircle(@NonNull Context context, @NonNull Drawable icon) { + ShapeDrawable background = new ShapeDrawable(new OvalShape()); + background.getPaint().setColor(Utils.getColorAttrDefaultColor(context, + com.android.internal.R.attr.materialColorSecondaryContainer)); + icon.setTint(Utils.getColorAttrDefaultColor(context, + com.android.internal.R.attr.materialColorOnSecondaryContainer)); + + LayerDrawable layerDrawable = new LayerDrawable(new Drawable[] { background, icon }); + + int circleDiameter = context.getResources().getDimensionPixelSize( + R.dimen.zen_mode_icon_list_circle_diameter); + int iconSize = context.getResources().getDimensionPixelSize( + R.dimen.zen_mode_icon_list_icon_size); + int iconPadding = (circleDiameter - iconSize) / 2; + layerDrawable.setBounds(0, 0, circleDiameter, circleDiameter); + layerDrawable.setLayerInset(1, iconPadding, iconPadding, iconPadding, iconPadding); + + return layerDrawable; + } + + static Drawable makeIconCircle(@NonNull Context context, @DrawableRes int iconResId) { + return makeIconCircle(context, checkNotNull(context.getDrawable(iconResId))); + } +} diff --git a/src/com/android/settings/notification/modes/ZenMode.java b/src/com/android/settings/notification/modes/ZenMode.java index 1be7e5fda1e..aca959f0f51 100644 --- a/src/com/android/settings/notification/modes/ZenMode.java +++ b/src/com/android/settings/notification/modes/ZenMode.java @@ -204,6 +204,14 @@ class ZenMode { : new ZenDeviceEffects.Builder().build(); } + public boolean canEditName() { + return !isManualDnd(); + } + + public boolean canEditIcon() { + return !isManualDnd(); + } + public boolean canBeDeleted() { return !mIsManualDnd; } diff --git a/src/com/android/settings/notification/modes/ZenModeActionsPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeActionsPreferenceController.java new file mode 100644 index 00000000000..5695fbcbc47 --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeActionsPreferenceController.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import static com.android.settings.notification.modes.ZenModeFragmentBase.MODE_ID; + +import android.content.Context; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; + +import com.android.settings.R; +import com.android.settings.core.SubSettingLauncher; +import com.android.settingslib.widget.ActionButtonsPreference; + +class ZenModeActionsPreferenceController extends AbstractZenModePreferenceController { + + private ActionButtonsPreference mPreference; + + ZenModeActionsPreferenceController(@NonNull Context context, @NonNull String key, + @Nullable ZenModesBackend backend) { + super(context, key, backend); + } + + @Override + void updateState(Preference preference, @NonNull ZenMode zenMode) { + ActionButtonsPreference buttonsPreference = (ActionButtonsPreference) preference; + + // TODO: b/346278854 - Add rename action (with setButton1Enabled(zenMode.canEditName()) + buttonsPreference.setButton1Text(R.string.zen_mode_action_change_name); + buttonsPreference.setButton1Icon(R.drawable.ic_mode_edit); + buttonsPreference.setButton1Enabled(false); + + buttonsPreference.setButton2Text(R.string.zen_mode_action_change_icon); + buttonsPreference.setButton2Icon(R.drawable.ic_zen_mode_action_change_icon); + buttonsPreference.setButton2Enabled(zenMode.canEditIcon()); + buttonsPreference.setButton2OnClickListener(v -> { + Bundle bundle = new Bundle(); + bundle.putString(MODE_ID, zenMode.getId()); + new SubSettingLauncher(mContext) + .setDestination(ZenModeIconPickerFragment.class.getName()) + // TODO: b/332937635 - Update metrics category + .setSourceMetricsCategory(0) + .setArguments(bundle) + .launch(); + }); + } +} diff --git a/src/com/android/settings/notification/modes/ZenModeFragment.java b/src/com/android/settings/notification/modes/ZenModeFragment.java index 7084f51a922..87165b85d72 100644 --- a/src/com/android/settings/notification/modes/ZenModeFragment.java +++ b/src/com/android/settings/notification/modes/ZenModeFragment.java @@ -38,6 +38,7 @@ public class ZenModeFragment extends ZenModeFragmentBase { List prefControllers = new ArrayList<>(); prefControllers.add(new ZenModeHeaderController(context, "header", this, mBackend)); prefControllers.add(new ZenModeButtonPreferenceController(context, "activate", mBackend)); + prefControllers.add(new ZenModeActionsPreferenceController(context, "actions", mBackend)); prefControllers.add(new ZenModePeopleLinkPreferenceController( context, "zen_mode_people", mBackend)); prefControllers.add(new ZenModeAppsLinkPreferenceController( diff --git a/src/com/android/settings/notification/modes/ZenModeFragmentBase.java b/src/com/android/settings/notification/modes/ZenModeFragmentBase.java index 5e6cfa5084e..e086524a427 100644 --- a/src/com/android/settings/notification/modes/ZenModeFragmentBase.java +++ b/src/com/android/settings/notification/modes/ZenModeFragmentBase.java @@ -51,12 +51,12 @@ abstract class ZenModeFragmentBase extends ZenModesFragmentBase { if (bundle != null && bundle.containsKey(MODE_ID)) { String id = bundle.getString(MODE_ID); if (!reloadMode(id)) { - Log.d(TAG, "Mode id " + id + " not found"); + Log.e(TAG, "Mode id " + id + " not found"); toastAndFinish(); return; } } else { - Log.d(TAG, "Mode id required to set mode config settings"); + Log.e(TAG, "Mode id required to set mode config settings"); toastAndFinish(); return; } diff --git a/src/com/android/settings/notification/modes/ZenModeHeaderController.java b/src/com/android/settings/notification/modes/ZenModeHeaderController.java index ba6e9d9a22e..d8f0a6730fa 100644 --- a/src/com/android/settings/notification/modes/ZenModeHeaderController.java +++ b/src/com/android/settings/notification/modes/ZenModeHeaderController.java @@ -63,9 +63,8 @@ class ZenModeHeaderController extends AbstractZenModePreferenceController { FutureUtil.whenDone( zenMode.getIcon(mContext, IconLoader.getInstance()), - icon -> mHeaderController.setIcon(icon) - .setLabel(zenMode.getRule().getName()) - .done(false /* rebindActions */), + icon -> mHeaderController.setIcon(IconUtil.applyTint(mContext, icon)) + .done(/* rebindActions= */ false), mContext.getMainExecutor()); } } diff --git a/src/com/android/settings/notification/modes/ZenModeIconPickerFragment.java b/src/com/android/settings/notification/modes/ZenModeIconPickerFragment.java new file mode 100644 index 00000000000..950849e0056 --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeIconPickerFragment.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import android.app.settings.SettingsEnums; +import android.content.Context; + +import com.android.settings.R; +import com.android.settingslib.core.AbstractPreferenceController; + +import com.google.common.collect.ImmutableList; + +import java.util.List; + +public class ZenModeIconPickerFragment extends ZenModeFragmentBase { + @Override + protected int getPreferenceScreenResId() { + return R.xml.modes_icon_picker; + } + + @Override + public int getMetricsCategory() { + // TODO: b/332937635 - make this the correct metrics category + return SettingsEnums.NOTIFICATION_ZEN_MODE_AUTOMATION; + } + + @Override + protected List createPreferenceControllers(Context context) { + return ImmutableList.of( + new ZenModeIconPickerIconPreferenceController(context, "current_icon", this, + mBackend), + new ZenModeIconPickerListPreferenceController(context, "icon_list", this, + mBackend)); + } +} diff --git a/src/com/android/settings/notification/modes/ZenModeIconPickerIconPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeIconPickerIconPreferenceController.java new file mode 100644 index 00000000000..9eaaa973305 --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeIconPickerIconPreferenceController.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; + +import com.android.settings.R; +import com.android.settings.dashboard.DashboardFragment; +import com.android.settings.widget.EntityHeaderController; +import com.android.settingslib.widget.LayoutPreference; + +class ZenModeIconPickerIconPreferenceController extends AbstractZenModePreferenceController { + + private final DashboardFragment mFragment; + private EntityHeaderController mHeaderController; + + ZenModeIconPickerIconPreferenceController(@NonNull Context context, @NonNull String key, + @NonNull DashboardFragment fragment, @Nullable ZenModesBackend backend) { + super(context, key, backend); + mFragment = fragment; + } + + @Override + void updateState(Preference preference, @NonNull ZenMode zenMode) { + preference.setSelectable(false); + + if (mHeaderController == null) { + final LayoutPreference pref = (LayoutPreference) preference; + mHeaderController = EntityHeaderController.newInstance( + mFragment.getActivity(), + mFragment, + pref.findViewById(R.id.entity_header)); + } + + FutureUtil.whenDone( + zenMode.getIcon(mContext, IconLoader.getInstance()), + icon -> mHeaderController.setIcon(IconUtil.applyTint(mContext, icon)) + .done(/* rebindActions= */ false), + mContext.getMainExecutor()); + } +} diff --git a/src/com/android/settings/notification/modes/ZenModeIconPickerListPreferenceController.java b/src/com/android/settings/notification/modes/ZenModeIconPickerListPreferenceController.java new file mode 100644 index 00000000000..b07c26f9e18 --- /dev/null +++ b/src/com/android/settings/notification/modes/ZenModeIconPickerListPreferenceController.java @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.android.settings.R; +import com.android.settings.dashboard.DashboardFragment; +import com.android.settingslib.widget.LayoutPreference; + +import com.google.common.collect.ImmutableList; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +class ZenModeIconPickerListPreferenceController extends AbstractZenModePreferenceController { + + private final DashboardFragment mFragment; + private IconAdapter mAdapter; + + ZenModeIconPickerListPreferenceController(@NonNull Context context, @NonNull String key, + @NonNull DashboardFragment fragment, @Nullable ZenModesBackend backend) { + super(context, key, backend); + mFragment = fragment; + } + + @Override + public void displayPreference(PreferenceScreen screen) { + super.displayPreference(screen); + LayoutPreference pref = screen.findPreference(getPreferenceKey()); + if (pref == null) { + return; + } + + if (mAdapter == null) { + // TODO: b/333901673 - This is just an example; replace with correct list. + List exampleIcons = + Arrays.stream(android.R.drawable.class.getFields()) + .filter( + f -> Modifier.isStatic(f.getModifiers()) + && f.getName().startsWith("ic_")) + .sorted(Comparator.comparing(Field::getName)) + .limit(20) + .map(f -> { + try { + return new IconInfo(f.getInt(null), f.getName()); + } catch (IllegalAccessException e) { + return null; + } + }) + .filter(Objects::nonNull) + .toList(); + mAdapter = new IconAdapter(exampleIcons); + } + RecyclerView recyclerView = pref.findViewById(R.id.icon_list); + recyclerView.setLayoutManager(new AutoFitGridLayoutManager(mContext)); + recyclerView.setAdapter(mAdapter); + recyclerView.setHasFixedSize(true); + } + + @VisibleForTesting + void onIconSelected(@DrawableRes int resId) { + saveMode(mode -> { + mode.getRule().setIconResId(resId); + return mode; + }); + mFragment.finish(); + } + + @Override + void updateState(Preference preference, @NonNull ZenMode zenMode) { + // Nothing to do, the current icon is shown in a different preference. + } + + private record IconInfo(@DrawableRes int resId, String description) { } + + private class IconHolder extends RecyclerView.ViewHolder { + + private final ImageView mImageView; + + IconHolder(@NonNull View itemView) { + super(itemView); + mImageView = itemView.findViewById(R.id.icon_image_view); + } + + void bindIcon(IconInfo icon) { + mImageView.setImageDrawable( + IconUtil.makeIconCircle(itemView.getContext(), icon.resId())); + itemView.setContentDescription(icon.description()); + itemView.setOnClickListener(v -> onIconSelected(icon.resId())); + } + } + + private class IconAdapter extends RecyclerView.Adapter { + + private final ImmutableList mIconResources; + + private IconAdapter(List iconOptions) { + mIconResources = ImmutableList.copyOf(iconOptions); + } + + @NonNull + @Override + public IconHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View itemView = LayoutInflater.from(parent.getContext()).inflate( + R.layout.modes_icon_list_item, parent, false); + return new IconHolder(itemView); + } + + @Override + public void onBindViewHolder(@NonNull IconHolder holder, int position) { + holder.bindIcon(mIconResources.get(position)); + } + + @Override + public int getItemCount() { + return mIconResources.size(); + } + } + + private static class AutoFitGridLayoutManager extends GridLayoutManager { + private final float mColumnWidth; + + AutoFitGridLayoutManager(Context context) { + super(context, /* spanCount= */ 1); + this.mColumnWidth = context + .getResources() + .getDimensionPixelSize(R.dimen.zen_mode_icon_list_item_size); + } + + @Override + public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { + final int totalSpace = getWidth() - getPaddingRight() - getPaddingLeft(); + final int spanCount = Math.max(1, (int) (totalSpace / mColumnWidth)); + setSpanCount(spanCount); + super.onLayoutChildren(recycler, state); + } + } +} diff --git a/src/com/android/settings/notification/modes/ZenModeListPreference.java b/src/com/android/settings/notification/modes/ZenModeListPreference.java index a106bddf964..c3daa614343 100644 --- a/src/com/android/settings/notification/modes/ZenModeListPreference.java +++ b/src/com/android/settings/notification/modes/ZenModeListPreference.java @@ -23,7 +23,6 @@ import android.os.Bundle; import com.android.settings.core.SubSettingLauncher; import com.android.settingslib.RestrictedPreference; -import com.android.settingslib.Utils; /** * Preference representing a single mode item on the modes aggregator page. Clicking on this @@ -59,11 +58,7 @@ class ZenModeListPreference extends RestrictedPreference { FutureUtil.whenDone( mZenMode.getIcon(mContext, IconLoader.getInstance()), - icon -> { - icon.setTintList( - Utils.getColorAttr(mContext, android.R.attr.colorControlNormal)); - setIcon(icon); - }, + icon -> setIcon(IconUtil.applyTint(mContext, icon)), mContext.getMainExecutor()); } } diff --git a/tests/robotests/src/com/android/settings/notification/modes/ZenModeIconPickerListPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/notification/modes/ZenModeIconPickerListPreferenceControllerTest.java new file mode 100644 index 00000000000..c0fbe15a6c2 --- /dev/null +++ b/tests/robotests/src/com/android/settings/notification/modes/ZenModeIconPickerListPreferenceControllerTest.java @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2024 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.notification.modes; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.AutomaticZenRule; +import android.content.Context; +import android.net.Uri; + +import androidx.preference.PreferenceScreen; +import androidx.recyclerview.widget.RecyclerView; + +import com.android.settings.R; +import com.android.settings.dashboard.DashboardFragment; +import com.android.settingslib.widget.LayoutPreference; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class ZenModeIconPickerListPreferenceControllerTest { + + private static final ZenMode ZEN_MODE = new ZenMode( + "mode_id", + new AutomaticZenRule.Builder("mode name", Uri.parse("mode")).build(), + /* isActive= */ false); + + private ZenModesBackend mBackend; + private ZenModeIconPickerListPreferenceController mController; + private PreferenceScreen mPreferenceScreen; + private RecyclerView mRecyclerView; + + @Before + public void setUp() { + Context context = RuntimeEnvironment.getApplication(); + mBackend = mock(ZenModesBackend.class); + + DashboardFragment fragment = mock(DashboardFragment.class); + mController = new ZenModeIconPickerListPreferenceController( + RuntimeEnvironment.getApplication(), "icon_list", fragment, mBackend); + + mRecyclerView = new RecyclerView(context); + mRecyclerView.setId(R.id.icon_list); + LayoutPreference layoutPreference = new LayoutPreference(context, mRecyclerView); + mPreferenceScreen = mock(PreferenceScreen.class); + when(mPreferenceScreen.findPreference(eq("icon_list"))).thenReturn(layoutPreference); + } + + @Test + public void displayPreference_loadsIcons() { + mController.displayPreference(mPreferenceScreen); + + assertThat(mRecyclerView.getAdapter()).isNotNull(); + assertThat(mRecyclerView.getAdapter().getItemCount()).isEqualTo(20); + } + + @Test + public void selectIcon_updatesMode() { + mController.setZenMode(ZEN_MODE); + + mController.onIconSelected(R.drawable.ic_android); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ZenMode.class); + verify(mBackend).updateMode(captor.capture()); + assertThat(captor.getValue().getRule().getIconResId()).isEqualTo(R.drawable.ic_android); + } +} From 18c0e172a5c9e4bf2f8b07b70a5ec16952ac7c94 Mon Sep 17 00:00:00 2001 From: Yiyi Shen Date: Fri, 7 Jun 2024 18:15:56 +0800 Subject: [PATCH 08/15] [Audiosharing] Add test for confirm dialog. Bug: 345686602 Test: atest Change-Id: Ifb461bf37f1f802de41c2957ac9aef4f7d7578de --- ...AudioSharingConfirmDialogFragmentTest.java | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingConfirmDialogFragmentTest.java diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingConfirmDialogFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingConfirmDialogFragmentTest.java new file mode 100644 index 00000000000..e5facc1d62d --- /dev/null +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingConfirmDialogFragmentTest.java @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2024 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.connecteddevice.audiosharing; + +import static com.google.common.truth.Truth.assertThat; + +import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; + +import android.app.settings.SettingsEnums; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothStatusCodes; +import android.platform.test.flag.junit.SetFlagsRule; +import android.view.View; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; + +import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; +import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settingslib.flags.Flags; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadow.api.Shadow; +import org.robolectric.shadows.androidx.fragment.FragmentController; + +@RunWith(RobolectricTestRunner.class) +@Config( + shadows = { + ShadowAlertDialogCompat.class, + ShadowBluetoothAdapter.class, + }) +public class AudioSharingConfirmDialogFragmentTest { + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + + private Fragment mParent; + private AudioSharingConfirmDialogFragment mFragment; + + @Before + public void setUp() { + cleanUpDialogs(); + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + BluetoothStatusCodes.FEATURE_SUPPORTED); + mFragment = new AudioSharingConfirmDialogFragment(); + mParent = new Fragment(); + FragmentController.setupFragment( + mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null); + } + + @After + public void tearDown() { + cleanUpDialogs(); + } + + @Test + public void getMetricsCategory_correctValue() { + assertThat(mFragment.getMetricsCategory()) + .isEqualTo(SettingsEnums.DIALOG_AUDIO_SHARING_CONFIRMATION); + } + + @Test + public void onCreateDialog_flagOff_dialogNotExist() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + AudioSharingConfirmDialogFragment.show(mParent); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNull(); + } + + @Test + public void onCreateDialog_flagOn_showDialog() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + AudioSharingConfirmDialogFragment.show(mParent); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + } + + @Test + public void onCreateDialog_clickOk_dialogDismiss() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + AudioSharingConfirmDialogFragment.show(mParent); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + View btnView = dialog.findViewById(android.R.id.button1); + assertThat(btnView).isNotNull(); + btnView.performClick(); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isFalse(); + } + + private void cleanUpDialogs() { + AlertDialog latestAlertDialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + if (latestAlertDialog != null) { + latestAlertDialog.dismiss(); + ShadowAlertDialogCompat.reset(); + } + } +} From facbd33839fd7d9c16225715d9040457f079dbc6 Mon Sep 17 00:00:00 2001 From: Hao Dong Date: Tue, 11 Jun 2024 03:48:45 +0000 Subject: [PATCH 09/15] Fix res not found in ConfirmDeviceCredentialActivity Bug: 346447223 Test: atest RepairModeUnitTests Change-Id: I3267c046b0e9eebdd2d0505cd5f2bc04c67c1bd0 --- .../settings/password/ConfirmDeviceCredentialActivity.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java b/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java index 0e60a68ed43..7f362c32904 100644 --- a/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java +++ b/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java @@ -219,8 +219,8 @@ public class ConfirmDeviceCredentialActivity extends FragmentActivity { && hasSetBiometricDialogAdvanced(mContext, getLaunchedFromUid()) ) { final int iconResId = intent.getIntExtra(CUSTOM_BIOMETRIC_PROMPT_LOGO_RES_ID_KEY, 0); - final Bitmap iconBitmap = toBitmap(mContext.getDrawable(iconResId)); if (iconResId != 0) { + final Bitmap iconBitmap = toBitmap(mContext.getDrawable(iconResId)); promptInfo.setLogo(iconResId, iconBitmap); } String logoDescription = intent.getStringExtra( From 26cda0dd4244bf5c5bc6dda2c343b6130f94d7d2 Mon Sep 17 00:00:00 2001 From: josephpv Date: Mon, 10 Jun 2024 18:37:03 +0000 Subject: [PATCH 10/15] Fix for settings crash during PS setup Fix for Null pointer exception to add null checks before calling unRegisterReceiver for broadcast and startActivity. Bug: 346233086 Test: Manual Change-Id: I0941c34c3f5912c57abc9497809962ed0954ac06 --- .../PrivateSpaceCreationFragment.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/com/android/settings/privatespace/PrivateSpaceCreationFragment.java b/src/com/android/settings/privatespace/PrivateSpaceCreationFragment.java index eb8864467b2..ce85d7238cc 100644 --- a/src/com/android/settings/privatespace/PrivateSpaceCreationFragment.java +++ b/src/com/android/settings/privatespace/PrivateSpaceCreationFragment.java @@ -157,22 +157,26 @@ public class PrivateSpaceCreationFragment extends InstrumentedFragment { /** Start new activity in private profile to add an account to private profile */ private void startAccountLogin() { - Intent intent = new Intent(getContext(), PrivateProfileContextHelperActivity.class); - intent.putExtra(EXTRA_ACTION_TYPE, ACCOUNT_LOGIN_ACTION); - mMetricsFeatureProvider.action( - getContext(), SettingsEnums.ACTION_PRIVATE_SPACE_SETUP_ACCOUNT_LOGIN_START); - getActivity().startActivityForResult(intent, ACCOUNT_LOGIN_ACTION); + if (isAdded() && getContext() != null && getActivity() != null) { + Intent intent = new Intent(getContext(), PrivateProfileContextHelperActivity.class); + intent.putExtra(EXTRA_ACTION_TYPE, ACCOUNT_LOGIN_ACTION); + mMetricsFeatureProvider.action( + getContext(), SettingsEnums.ACTION_PRIVATE_SPACE_SETUP_ACCOUNT_LOGIN_START); + getActivity().startActivityForResult(intent, ACCOUNT_LOGIN_ACTION); + } } private void registerReceiver() { IntentFilter filter = new IntentFilter(); filter.addAction(Intent.ACTION_PROFILE_ACCESSIBLE); - getActivity().registerReceiver(mProfileAccessReceiver, filter); + if (getContext() != null) { + getContext().registerReceiver(mProfileAccessReceiver, filter); + } } private void unRegisterReceiver() { - if (mProfileAccessReceiver != null) { - getActivity().unregisterReceiver(mProfileAccessReceiver); + if (mProfileAccessReceiver != null && isAdded() && getContext() != null) { + getContext().unregisterReceiver(mProfileAccessReceiver); } } } From 86f4ed16f3c3d9e6be77bdf41169b173265a81d2 Mon Sep 17 00:00:00 2001 From: Chaohui Wang Date: Tue, 11 Jun 2024 14:00:04 +0800 Subject: [PATCH 11/15] Fix preferred apn not selected when back from edit When back from edit page, preferred apn and apn list will both refresh, if apn list refresh happens later, it will clear the preferred apn. Saved the latest preferred apn into mPreferredApnKey to fix. Bug: 257316932 Test: manual Change-Id: I62f9cbd5847b7edb834c815cdecc089e0764a8c7 --- src/com/android/settings/network/apn/ApnSettings.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/com/android/settings/network/apn/ApnSettings.java b/src/com/android/settings/network/apn/ApnSettings.java index 5249eb27f22..2debba1d99a 100644 --- a/src/com/android/settings/network/apn/ApnSettings.java +++ b/src/com/android/settings/network/apn/ApnSettings.java @@ -99,6 +99,8 @@ public class ApnSettings extends RestrictedSettingsFragment private UserManager mUserManager; private int mSubId; private PreferredApnRepository mPreferredApnRepository; + @Nullable + private String mPreferredApnKey; private String mMvnoType; private String mMvnoMatchData; @@ -175,6 +177,7 @@ public class ApnSettings extends RestrictedSettingsFragment }); mPreferredApnRepository.collectPreferredApn(viewLifecycleOwner, (preferredApn) -> { + mPreferredApnKey = preferredApn; final PreferenceGroup apnPreferenceList = findPreference(APN_LIST); for (int i = 0; i < apnPreferenceList.getPreferenceCount(); i++) { ApnPreference apnPreference = (ApnPreference) apnPreferenceList.getPreference(i); @@ -259,6 +262,7 @@ public class ApnSettings extends RestrictedSettingsFragment ((type == null) || type.contains(ApnSetting.TYPE_DEFAULT_STRING)); pref.setDefaultSelectable(defaultSelectable); if (defaultSelectable) { + pref.setIsChecked(key.equals(mPreferredApnKey)); apnList.add(pref); } else { mmsApnList.add(pref); From 643d6c66c19493f19da32630356ec896983e2abe Mon Sep 17 00:00:00 2001 From: Yiyi Shen Date: Fri, 7 Jun 2024 18:26:47 +0800 Subject: [PATCH 12/15] [Audiosharing] Increase test coverage for audio sharing Bug: 345686602 Test: atest Change-Id: Ib75a0127921d7ac6e7e0bbc82258ac0a21be90a2 --- ...ringCompatibilityPreferenceController.java | 5 +- .../AudioSharingDashboardFragment.java | 23 +++- .../AudioSharingPreferenceController.java | 4 +- ...CompatibilityPreferenceControllerTest.java | 113 ++++++++++++++---- .../AudioSharingDashboardFragmentTest.java | 62 +++++++++- .../AudioSharingDeviceItemTest.java | 15 +++ .../AudioSharingPreferenceControllerTest.java | 87 ++++++++++++-- 7 files changed, 263 insertions(+), 46 deletions(-) diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceController.java index d2f23edfe8a..581ad62bb31 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceController.java @@ -57,9 +57,10 @@ public class AudioSharingCompatibilityPreferenceController extends TogglePrefere @Nullable private TwoStatePreference mPreference; private final Executor mExecutor; private final MetricsFeatureProvider mMetricsFeatureProvider; - private AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false); + private final AtomicBoolean mCallbacksRegistered = new AtomicBoolean(false); - private final BluetoothLeBroadcast.Callback mBroadcastCallback = + @VisibleForTesting + protected final BluetoothLeBroadcast.Callback mBroadcastCallback = new BluetoothLeBroadcast.Callback() { @Override public void onBroadcastStarted(int reason, int broadcastId) { diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragment.java index c3248c7e573..c7d740740e7 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragment.java @@ -20,6 +20,8 @@ import android.app.settings.SettingsEnums; import android.content.Context; import android.os.Bundle; +import androidx.annotation.VisibleForTesting; + import com.android.settings.R; import com.android.settings.SettingsActivity; import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsCategoryController; @@ -31,7 +33,6 @@ public class AudioSharingDashboardFragment extends DashboardFragment private static final String TAG = "AudioSharingDashboardFrag"; SettingsMainSwitchBar mMainSwitchBar; - private AudioSharingSwitchBarController mSwitchBarController; private AudioSharingDeviceVolumeGroupController mAudioSharingDeviceVolumeGroupController; private AudioSharingCallAudioPreferenceController mAudioSharingCallAudioPreferenceController; private AudioSharingPlaySoundPreferenceController mAudioSharingPlaySoundPreferenceController; @@ -83,9 +84,10 @@ public class AudioSharingDashboardFragment extends DashboardFragment final SettingsActivity activity = (SettingsActivity) getActivity(); mMainSwitchBar = activity.getSwitchBar(); mMainSwitchBar.setTitle(getText(R.string.audio_sharing_switch_title)); - mSwitchBarController = new AudioSharingSwitchBarController(activity, mMainSwitchBar, this); - mSwitchBarController.init(this); - getSettingsLifecycle().addObserver(mSwitchBarController); + AudioSharingSwitchBarController switchBarController = + new AudioSharingSwitchBarController(activity, mMainSwitchBar, this); + switchBarController.init(this); + getSettingsLifecycle().addObserver(switchBarController); mMainSwitchBar.show(); } @@ -99,6 +101,19 @@ public class AudioSharingDashboardFragment extends DashboardFragment onProfilesConnectedForAttachedPreferences(); } + /** Test only: set mock controllers for the {@link AudioSharingDashboardFragment} */ + @VisibleForTesting + protected void setControllers( + AudioSharingDeviceVolumeGroupController volumeGroupController, + AudioSharingCallAudioPreferenceController callAudioController, + AudioSharingPlaySoundPreferenceController playSoundController, + AudioStreamsCategoryController streamsCategoryController) { + mAudioSharingDeviceVolumeGroupController = volumeGroupController; + mAudioSharingCallAudioPreferenceController = callAudioController; + mAudioSharingPlaySoundPreferenceController = playSoundController; + mAudioStreamsCategoryController = streamsCategoryController; + } + private void updateVisibilityForAttachedPreferences() { mAudioSharingDeviceVolumeGroupController.updateVisibility(); mAudioSharingCallAudioPreferenceController.updateVisibility(); diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceController.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceController.java index 54eb722ba50..d27d3a20d72 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceController.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceController.java @@ -23,6 +23,7 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.LifecycleOwner; import androidx.preference.Preference; @@ -50,7 +51,8 @@ public class AudioSharingPreferenceController extends BasePreferenceController @Nullable private Preference mPreference; private final Executor mExecutor; - private final BluetoothLeBroadcast.Callback mBroadcastCallback = + @VisibleForTesting + protected final BluetoothLeBroadcast.Callback mBroadcastCallback = new BluetoothLeBroadcast.Callback() { @Override public void onBroadcastStarted(int reason, int broadcastId) { diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceControllerTest.java index 1a9d09e4148..19221a65abe 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingCompatibilityPreferenceControllerTest.java @@ -25,6 +25,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -34,6 +35,7 @@ import static org.robolectric.Shadows.shadowOf; import android.app.settings.SettingsEnums; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothLeBroadcast; +import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothStatusCodes; import android.content.Context; import android.os.Looper; @@ -94,28 +96,28 @@ public class AudioSharingCompatibilityPreferenceControllerTest { @Mock private LocalBluetoothLeBroadcastAssistant mAssistant; @Mock private VolumeControlProfile mVolumeControl; @Mock private TwoStatePreference mPreference; + @Mock private BluetoothLeBroadcastMetadata mMetadata; private AudioSharingCompatibilityPreferenceController mController; - private ShadowBluetoothAdapter mShadowBluetoothAdapter; - private LocalBluetoothManager mLocalBluetoothManager; private FakeFeatureFactory mFeatureFactory; private Lifecycle mLifecycle; private LifecycleOwner mLifecycleOwner; @Before public void setUp() { - mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); - mShadowBluetoothAdapter.setEnabled(true); - mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); - mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); mLifecycleOwner = () -> mLifecycle; mLifecycle = new Lifecycle(mLifecycleOwner); ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; - mLocalBluetoothManager = Utils.getLocalBtManager(mContext); + LocalBluetoothManager localBluetoothManager = Utils.getLocalBtManager(mContext); mFeatureFactory = FakeFeatureFactory.setupForTest(); - when(mLocalBluetoothManager.getEventManager()).thenReturn(mBtEventManager); - when(mLocalBluetoothManager.getProfileManager()).thenReturn(mBtProfileManager); + when(localBluetoothManager.getEventManager()).thenReturn(mBtEventManager); + when(localBluetoothManager.getProfileManager()).thenReturn(mBtProfileManager); when(mBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast); when(mBtProfileManager.getLeAudioBroadcastAssistantProfile()).thenReturn(mAssistant); when(mBtProfileManager.getVolumeControlProfile()).thenReturn(mVolumeControl); @@ -133,7 +135,7 @@ public class AudioSharingCompatibilityPreferenceControllerTest { verify(mBroadcast) .registerServiceCallBack( any(Executor.class), any(BluetoothLeBroadcast.Callback.class)); - verify(mBtProfileManager, times(0)).addServiceListener(mController); + verify(mBtProfileManager, never()).addServiceListener(mController); } @Test @@ -141,7 +143,7 @@ public class AudioSharingCompatibilityPreferenceControllerTest { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); when(mBroadcast.isProfileReady()).thenReturn(false); mController.onStart(mLifecycleOwner); - verify(mBroadcast, times(0)) + verify(mBroadcast, never()) .registerServiceCallBack( any(Executor.class), any(BluetoothLeBroadcast.Callback.class)); verify(mBtProfileManager).addServiceListener(mController); @@ -151,7 +153,7 @@ public class AudioSharingCompatibilityPreferenceControllerTest { public void onStart_flagOff_doNothing() { mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); mController.onStart(mLifecycleOwner); - verify(mBroadcast, times(0)) + verify(mBroadcast, never()) .registerServiceCallBack( any(Executor.class), any(BluetoothLeBroadcast.Callback.class)); } @@ -170,9 +172,9 @@ public class AudioSharingCompatibilityPreferenceControllerTest { mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); mController.setCallbacksRegistered(true); mController.onStop(mLifecycleOwner); - verify(mBroadcast, times(0)) + verify(mBroadcast, never()) .unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class)); - verify(mBtProfileManager, times(0)).removeServiceListener(mController); + verify(mBtProfileManager, never()).removeServiceListener(mController); } @Test @@ -224,11 +226,10 @@ public class AudioSharingCompatibilityPreferenceControllerTest { mController.displayPreference(mScreen); shadowOf(Looper.getMainLooper()).idle(); verify(mPreference).setEnabled(false); - verify(mPreference) - .setSummary( - eq(mContext.getString( - R.string - .audio_sharing_stream_compatibility_disabled_description))); + String expected = + mContext.getString( + R.string.audio_sharing_stream_compatibility_disabled_description); + verify(mPreference).setSummary(eq(expected)); } @Test @@ -237,10 +238,9 @@ public class AudioSharingCompatibilityPreferenceControllerTest { mController.displayPreference(mScreen); shadowOf(Looper.getMainLooper()).idle(); verify(mPreference).setEnabled(true); - verify(mPreference) - .setSummary( - eq(mContext.getString( - R.string.audio_sharing_stream_compatibility_description))); + String expected = + mContext.getString(R.string.audio_sharing_stream_compatibility_description); + verify(mPreference).setSummary(eq(expected)); } @Test @@ -272,8 +272,73 @@ public class AudioSharingCompatibilityPreferenceControllerTest { public void setCheckedToCurrentValue_returnsFalse() { when(mBroadcast.getImproveCompatibility()).thenReturn(true); boolean setChecked = mController.setChecked(true); - verify(mBroadcast, times(0)).setImproveCompatibility(anyBoolean()); + verify(mBroadcast, never()).setImproveCompatibility(anyBoolean()); verifyNoInteractions(mFeatureFactory.metricsFeatureProvider); assertThat(setChecked).isFalse(); } + + @Test + public void testBluetoothLeBroadcastCallbacks_refreshPreference() { + when(mBroadcast.isEnabled(any())).thenReturn(false); + mController.displayPreference(mScreen); + shadowOf(Looper.getMainLooper()).idle(); + verify(mPreference).setEnabled(true); + String expected = + mContext.getString(R.string.audio_sharing_stream_compatibility_description); + verify(mPreference).setSummary(eq(expected)); + + when(mBroadcast.isEnabled(any())).thenReturn(true); + mController.mBroadcastCallback.onBroadcastStarted(/* reason= */ 1, /* broadcastId= */ 1); + shadowOf(Looper.getMainLooper()).idle(); + verify(mPreference).setEnabled(false); + expected = + mContext.getString( + R.string.audio_sharing_stream_compatibility_disabled_description); + verify(mPreference).setSummary(eq(expected)); + + when(mBroadcast.isEnabled(any())).thenReturn(false); + mController.mBroadcastCallback.onBroadcastStopped(/* reason= */ 1, /* broadcastId= */ 1); + shadowOf(Looper.getMainLooper()).idle(); + + // Verify one extra setEnabled/setSummary is called other than the first call in + // displayPreference. + verify(mPreference, times(2)).setEnabled(true); + expected = mContext.getString(R.string.audio_sharing_stream_compatibility_description); + verify(mPreference, times(2)).setSummary(eq(expected)); + } + + @Test + public void testBluetoothLeBroadcastCallbacks_doNothing() { + when(mBroadcast.isEnabled(any())).thenReturn(false); + mController.displayPreference(mScreen); + shadowOf(Looper.getMainLooper()).idle(); + verify(mPreference).setEnabled(true); + String expected = + mContext.getString(R.string.audio_sharing_stream_compatibility_description); + verify(mPreference).setSummary(eq(expected)); + + // Verify no extra setEnabled/setSummary is called other than call in displayPreference. + mController.mBroadcastCallback.onBroadcastMetadataChanged(/* reason= */ 1, mMetadata); + verify(mPreference).setEnabled(anyBoolean()); + verify(mPreference).setSummary(any()); + mController.mBroadcastCallback.onBroadcastUpdated(/* reason= */ 1, /* broadcastId= */ 1); + verify(mPreference).setEnabled(anyBoolean()); + verify(mPreference).setSummary(any()); + mController.mBroadcastCallback.onPlaybackStarted(/* reason= */ 1, /* broadcastId= */ 1); + verify(mPreference).setEnabled(anyBoolean()); + verify(mPreference).setSummary(any()); + mController.mBroadcastCallback.onPlaybackStopped(/* reason= */ 1, /* broadcastId= */ 1); + verify(mPreference).setEnabled(anyBoolean()); + verify(mPreference).setSummary(any()); + mController.mBroadcastCallback.onBroadcastStartFailed(/* reason= */ 1); + verify(mPreference).setEnabled(anyBoolean()); + verify(mPreference).setSummary(any()); + mController.mBroadcastCallback.onBroadcastStopFailed(/* reason= */ 1); + verify(mPreference).setEnabled(anyBoolean()); + verify(mPreference).setSummary(any()); + mController.mBroadcastCallback.onBroadcastUpdateFailed( + /* reason= */ 1, /* broadcastId= */ 1); + verify(mPreference).setEnabled(anyBoolean()); + verify(mPreference).setSummary(any()); + } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragmentTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragmentTest.java index c1afeaa7806..8e4915cc8ff 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragmentTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDashboardFragmentTest.java @@ -18,22 +18,45 @@ package com.android.settings.connecteddevice.audiosharing; import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + import android.app.settings.SettingsEnums; +import android.content.Context; +import android.os.Bundle; + +import androidx.test.core.app.ApplicationProvider; import com.android.settings.R; +import com.android.settings.SettingsActivity; +import com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsCategoryController; +import com.android.settings.testutils.shadow.ShadowFragment; +import com.android.settings.widget.SettingsMainSwitchBar; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; +import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; @RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowFragment.class}) public class AudioSharingDashboardFragmentTest { @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule(); + + @Mock private SettingsActivity mActivity; + @Mock private SettingsMainSwitchBar mSwitchBar; + @Mock private AudioSharingDeviceVolumeGroupController mVolumeGroupController; + @Mock private AudioSharingCallAudioPreferenceController mCallAudioController; + @Mock private AudioSharingPlaySoundPreferenceController mPlaySoundController; + @Mock private AudioStreamsCategoryController mStreamsCategoryController; + private final Context mContext = ApplicationProvider.getApplicationContext(); private AudioSharingDashboardFragment mFragment; @Before @@ -59,7 +82,42 @@ public class AudioSharingDashboardFragmentTest { @Test public void getHelpResource_returnsCorrectResource() { - assertThat(mFragment.getHelpResource()) - .isEqualTo(R.string.help_url_audio_sharing); + assertThat(mFragment.getHelpResource()).isEqualTo(R.string.help_url_audio_sharing); + } + + @Test + public void onActivityCreated_showSwitchBar() { + doReturn(mSwitchBar).when(mActivity).getSwitchBar(); + mFragment = spy(new AudioSharingDashboardFragment()); + doReturn(mActivity).when(mFragment).getActivity(); + doReturn(mContext).when(mFragment).getContext(); + mFragment.onAttach(mContext); + mFragment.onActivityCreated(new Bundle()); + verify(mSwitchBar).show(); + } + + @Test + public void onAudioSharingStateChanged_updateVisibilityForControllers() { + mFragment.setControllers( + mVolumeGroupController, + mCallAudioController, + mPlaySoundController, + mStreamsCategoryController); + mFragment.onAudioSharingStateChanged(); + verify(mVolumeGroupController).updateVisibility(); + verify(mCallAudioController).updateVisibility(); + verify(mPlaySoundController).updateVisibility(); + verify(mStreamsCategoryController).updateVisibility(); + } + + @Test + public void onAudioSharingProfilesConnected_registerCallbacksForVolumeGroupController() { + mFragment.setControllers( + mVolumeGroupController, + mCallAudioController, + mPlaySoundController, + mStreamsCategoryController); + mFragment.onAudioSharingProfilesConnected(); + verify(mVolumeGroupController).onAudioSharingProfilesConnected(); } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItemTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItemTest.java index 1bae3d170f8..b23882d3953 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItemTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingDeviceItemTest.java @@ -63,4 +63,19 @@ public class AudioSharingDeviceItemTest { public void creator_newArray() { assertThat(AudioSharingDeviceItem.CREATOR.newArray(2)).hasLength(2); } + + @Test + public void creator_createFromParcel() { + AudioSharingDeviceItem item = + new AudioSharingDeviceItem(TEST_NAME, TEST_GROUP_ID, TEST_IS_ACTIVE); + Parcel parcel = Parcel.obtain(); + item.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + AudioSharingDeviceItem itemFromParcel = + AudioSharingDeviceItem.CREATOR.createFromParcel(parcel); + parcel.recycle(); + assertThat(itemFromParcel.getName()).isEqualTo(TEST_NAME); + assertThat(itemFromParcel.getGroupId()).isEqualTo(TEST_GROUP_ID); + assertThat(itemFromParcel.isActive()).isEqualTo(TEST_IS_ACTIVE); + } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceControllerTest.java index b8bee1a65c8..046a4ce8f67 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/audiosharing/AudioSharingPreferenceControllerTest.java @@ -25,12 +25,15 @@ import static com.android.settings.core.BasePreferenceController.UNSUPPORTED_ON_ import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; +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 static org.robolectric.Shadows.shadowOf; import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothLeBroadcast; +import android.bluetooth.BluetoothLeBroadcastMetadata; import android.bluetooth.BluetoothStatusCodes; import android.content.Context; import android.os.Looper; @@ -84,47 +87,67 @@ public class AudioSharingPreferenceControllerTest { @Mock private BluetoothEventManager mBtEventManager; @Mock private LocalBluetoothProfileManager mLocalBtProfileManager; @Mock private LocalBluetoothLeBroadcast mBroadcast; + @Mock private BluetoothLeBroadcastMetadata mMetadata; private AudioSharingPreferenceController mController; - private ShadowBluetoothAdapter mShadowBluetoothAdapter; - private LocalBluetoothManager mLocalBluetoothManager; private Lifecycle mLifecycle; private LifecycleOwner mLifecycleOwner; - private Preference mPreference; + @Spy private Preference mPreference; @Before public void setUp() { - mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter()); - mShadowBluetoothAdapter.setEnabled(true); - mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( + ShadowBluetoothAdapter shadowBluetoothAdapter = + Shadow.extract(BluetoothAdapter.getDefaultAdapter()); + shadowBluetoothAdapter.setEnabled(true); + shadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); - mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( + shadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported( BluetoothStatusCodes.FEATURE_SUPPORTED); mLifecycleOwner = () -> mLifecycle; mLifecycle = new Lifecycle(mLifecycleOwner); ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBtManager; - mLocalBluetoothManager = Utils.getLocalBtManager(mContext); - when(mLocalBluetoothManager.getEventManager()).thenReturn(mBtEventManager); - when(mLocalBluetoothManager.getProfileManager()).thenReturn(mLocalBtProfileManager); + LocalBluetoothManager localBluetoothManager = Utils.getLocalBtManager(mContext); + when(localBluetoothManager.getEventManager()).thenReturn(mBtEventManager); + when(localBluetoothManager.getProfileManager()).thenReturn(mLocalBtProfileManager); when(mLocalBtProfileManager.getLeAudioBroadcastProfile()).thenReturn(mBroadcast); mController = new AudioSharingPreferenceController(mContext, PREF_KEY); - mPreference = new Preference(mContext); + mPreference = spy(new Preference(mContext)); when(mScreen.findPreference(PREF_KEY)).thenReturn(mPreference); } @Test - public void onStart_registerCallback() { + public void onStart_flagOn_registerCallback() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); mController.onStart(mLifecycleOwner); verify(mBtEventManager).registerCallback(mController); verify(mBroadcast).registerServiceCallBack(any(), any(BluetoothLeBroadcast.Callback.class)); } @Test - public void onStop_unregisterCallback() { + public void onStart_flagOff_skipRegisterCallback() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStart(mLifecycleOwner); + verify(mBtEventManager, never()).registerCallback(mController); + verify(mBroadcast, never()) + .registerServiceCallBack(any(), any(BluetoothLeBroadcast.Callback.class)); + } + + @Test + public void onStop_flagOn_unregisterCallback() { + mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); mController.onStop(mLifecycleOwner); verify(mBtEventManager).unregisterCallback(mController); verify(mBroadcast).unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class)); } + @Test + public void onStop_flagOff_skipUnregisterCallback() { + mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + mController.onStop(mLifecycleOwner); + verify(mBtEventManager, never()).unregisterCallback(mController); + verify(mBroadcast, never()) + .unregisterServiceCallBack(any(BluetoothLeBroadcast.Callback.class)); + } + @Test public void getAvailabilityStatus_flagOn() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); @@ -166,4 +189,42 @@ public class AudioSharingPreferenceControllerTest { assertThat(mPreference.getSummary().toString()) .isEqualTo(mContext.getString(R.string.audio_sharing_summary_off)); } + + @Test + public void testBluetoothLeBroadcastCallbacks_refreshSummary() { + mController.displayPreference(mScreen); + + when(mBroadcast.isEnabled(any())).thenReturn(true); + mController.mBroadcastCallback.onBroadcastStarted(/* reason= */ 1, /* broadcastId= */ 1); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(mPreference.getSummary().toString()) + .isEqualTo(mContext.getString(R.string.audio_sharing_summary_on)); + + when(mBroadcast.isEnabled(any())).thenReturn(false); + mController.mBroadcastCallback.onBroadcastStopped(/* reason= */ 1, /* broadcastId= */ 1); + shadowOf(Looper.getMainLooper()).idle(); + assertThat(mPreference.getSummary().toString()) + .isEqualTo(mContext.getString(R.string.audio_sharing_summary_off)); + } + + @Test + public void testBluetoothLeBroadcastCallbacks_doNothing() { + mController.displayPreference(mScreen); + + mController.mBroadcastCallback.onBroadcastMetadataChanged(/* reason= */ 1, mMetadata); + verify(mPreference, never()).setSummary(any()); + mController.mBroadcastCallback.onBroadcastUpdated(/* reason= */ 1, /* broadcastId= */ 1); + verify(mPreference, never()).setSummary(any()); + mController.mBroadcastCallback.onPlaybackStarted(/* reason= */ 1, /* broadcastId= */ 1); + verify(mPreference, never()).setSummary(any()); + mController.mBroadcastCallback.onPlaybackStopped(/* reason= */ 1, /* broadcastId= */ 1); + verify(mPreference, never()).setSummary(any()); + mController.mBroadcastCallback.onBroadcastStartFailed(/* reason= */ 1); + verify(mPreference, never()).setSummary(any()); + mController.mBroadcastCallback.onBroadcastStopFailed(/* reason= */ 1); + verify(mPreference, never()).setSummary(any()); + mController.mBroadcastCallback.onBroadcastUpdateFailed( + /* reason= */ 1, /* broadcastId= */ 1); + verify(mPreference, never()).setSummary(any()); + } } From caa3a4b9fd7afd64999363c917e10f3766f248b7 Mon Sep 17 00:00:00 2001 From: Ze Li Date: Tue, 4 Jun 2024 18:43:09 +0800 Subject: [PATCH 13/15] [ConnectedDevicePage] Make bonded bluetooth devices can be found by Settings search Bonded bluetooth devices can be found by Settings search using device name and jump to connected device page. Test: atest ConnectedDeviceGroupControllerTest Bug: 319056077 Change-Id: I738d7bd400e41647666966e6b39cd7bff01fc551 Flag: com.android.settings.flags.Flags.enableBondedBluetoothDeviceSearchable --- ..._connecteddevice_flag_declarations.aconfig | 11 ++++ .../ConnectedDeviceGroupController.java | 41 ++++++++++++ .../ConnectedDeviceGroupControllerTest.java | 63 ++++++++++++++++++- 3 files changed, 114 insertions(+), 1 deletion(-) diff --git a/aconfig/settings_connecteddevice_flag_declarations.aconfig b/aconfig/settings_connecteddevice_flag_declarations.aconfig index 2d66c30446a..7942ccd1416 100644 --- a/aconfig/settings_connecteddevice_flag_declarations.aconfig +++ b/aconfig/settings_connecteddevice_flag_declarations.aconfig @@ -14,3 +14,14 @@ flag { description: "Gates whether to require an auth challenge for changing USB preferences" bug: "317367746" } + + +flag { + name: "enable_bonded_bluetooth_device_searchable" + namespace: "pixel_cross_device_control" + description: "Set bonded bluetooth devices under connected devices page to be searchable by Settings search." + bug: "319056077" + metadata { + purpose: PURPOSE_BUGFIX + } +} \ No newline at end of file diff --git a/src/com/android/settings/connecteddevice/ConnectedDeviceGroupController.java b/src/com/android/settings/connecteddevice/ConnectedDeviceGroupController.java index 5be761efc79..56a3005f6dd 100644 --- a/src/com/android/settings/connecteddevice/ConnectedDeviceGroupController.java +++ b/src/com/android/settings/connecteddevice/ConnectedDeviceGroupController.java @@ -19,6 +19,7 @@ import android.content.Context; import android.content.pm.PackageManager; import android.hardware.input.InputManager; import android.util.FeatureFlagUtils; +import android.util.Log; import android.view.InputDevice; import androidx.annotation.VisibleForTesting; @@ -26,19 +27,29 @@ import androidx.preference.Preference; import androidx.preference.PreferenceGroup; import androidx.preference.PreferenceScreen; +import com.android.settings.R; import com.android.settings.bluetooth.BluetoothDeviceUpdater; import com.android.settings.bluetooth.ConnectedBluetoothDeviceUpdater; +import com.android.settings.bluetooth.Utils; import com.android.settings.connecteddevice.dock.DockUpdater; import com.android.settings.connecteddevice.stylus.StylusDeviceUpdater; import com.android.settings.connecteddevice.usb.ConnectedUsbDeviceUpdater; import com.android.settings.core.BasePreferenceController; import com.android.settings.core.PreferenceControllerMixin; import com.android.settings.dashboard.DashboardFragment; +import com.android.settings.flags.Flags; import com.android.settings.overlay.DockUpdaterFeatureProvider; import com.android.settings.overlay.FeatureFactory; +import com.android.settingslib.bluetooth.BluetoothDeviceFilter; +import com.android.settingslib.bluetooth.BluetoothUtils; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.core.lifecycle.LifecycleObserver; import com.android.settingslib.core.lifecycle.events.OnStart; import com.android.settingslib.core.lifecycle.events.OnStop; +import com.android.settingslib.search.SearchIndexableRaw; + +import java.util.List; /** * Controller to maintain the {@link androidx.preference.PreferenceGroup} for all @@ -49,6 +60,7 @@ public class ConnectedDeviceGroupController extends BasePreferenceController DevicePreferenceCallback { private static final String KEY = "connected_device_list"; + private static final String TAG = "ConnectedDeviceGroupController"; @VisibleForTesting PreferenceGroup mPreferenceGroup; @@ -58,11 +70,13 @@ public class ConnectedDeviceGroupController extends BasePreferenceController private StylusDeviceUpdater mStylusDeviceUpdater; private final PackageManager mPackageManager; private final InputManager mInputManager; + private final LocalBluetoothManager mLocalBluetoothManager; public ConnectedDeviceGroupController(Context context) { super(context, KEY); mPackageManager = context.getPackageManager(); mInputManager = context.getSystemService(InputManager.class); + mLocalBluetoothManager = Utils.getLocalBluetoothManager(context); } @Override @@ -221,4 +235,31 @@ public class ConnectedDeviceGroupController extends BasePreferenceController } return false; } + + @Override + public void updateDynamicRawDataToIndex(List rawData) { + if (!Flags.enableBondedBluetoothDeviceSearchable()) { + return; + } + if (mLocalBluetoothManager == null) { + Log.d(TAG, "Bluetooth is not supported"); + return; + } + for (CachedBluetoothDevice cachedDevice : + mLocalBluetoothManager.getCachedDeviceManager().getCachedDevicesCopy()) { + if (!BluetoothDeviceFilter.BONDED_DEVICE_FILTER.matches(cachedDevice.getDevice())) { + continue; + } + if (BluetoothUtils.isExclusivelyManagedBluetoothDevice(mContext, + cachedDevice.getDevice())) { + continue; + } + SearchIndexableRaw data = new SearchIndexableRaw(mContext); + // Include the identity address as well to ensure the key is unique. + data.key = cachedDevice.getName() + cachedDevice.getIdentityAddress(); + data.title = cachedDevice.getName(); + data.summaryOn = mContext.getString(R.string.connected_devices_dashboard_title); + rawData.add(data); + } + } } diff --git a/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceGroupControllerTest.java b/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceGroupControllerTest.java index a35ef45f518..d28ab3b928b 100644 --- a/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceGroupControllerTest.java +++ b/tests/robotests/src/com/android/settings/connecteddevice/ConnectedDeviceGroupControllerTest.java @@ -27,9 +27,12 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import android.bluetooth.BluetoothDevice; import android.content.Context; import android.content.pm.PackageManager; import android.hardware.input.InputManager; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; import android.util.FeatureFlagUtils; import android.view.InputDevice; @@ -39,13 +42,23 @@ import androidx.preference.PreferenceManager; import androidx.preference.PreferenceScreen; import com.android.settings.bluetooth.ConnectedBluetoothDeviceUpdater; +import com.android.settings.bluetooth.Utils; import com.android.settings.connecteddevice.dock.DockUpdater; import com.android.settings.connecteddevice.stylus.StylusDeviceUpdater; import com.android.settings.connecteddevice.usb.ConnectedUsbDeviceUpdater; import com.android.settings.dashboard.DashboardFragment; +import com.android.settings.flags.Flags; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settings.testutils.shadow.ShadowBluetoothUtils; +import com.android.settingslib.bluetooth.CachedBluetoothDevice; +import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager; +import com.android.settingslib.bluetooth.LocalBluetoothManager; +import com.android.settingslib.search.SearchIndexableRaw; + +import com.google.common.collect.ImmutableList; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Answers; @@ -57,11 +70,16 @@ import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowApplicationPackageManager; +import java.util.ArrayList; +import java.util.List; + @RunWith(RobolectricTestRunner.class) -@Config(shadows = {ShadowApplicationPackageManager.class, ShadowBluetoothAdapter.class}) +@Config(shadows = {ShadowApplicationPackageManager.class, ShadowBluetoothUtils.class, + ShadowBluetoothAdapter.class}) public class ConnectedDeviceGroupControllerTest { private static final String PREFERENCE_KEY_1 = "pref_key_1"; + private static final String DEVICE_NAME = "device"; @Mock private DashboardFragment mDashboardFragment; @@ -79,6 +97,14 @@ public class ConnectedDeviceGroupControllerTest { private PreferenceManager mPreferenceManager; @Mock private InputManager mInputManager; + @Mock + private CachedBluetoothDeviceManager mCachedDeviceManager; + @Mock + private LocalBluetoothManager mLocalBluetoothManager; + @Mock + private CachedBluetoothDevice mCachedDevice; + @Mock + private BluetoothDevice mDevice; private ShadowApplicationPackageManager mPackageManager; private PreferenceGroup mPreferenceGroup; @@ -86,6 +112,9 @@ public class ConnectedDeviceGroupControllerTest { private Preference mPreference; private ConnectedDeviceGroupController mConnectedDeviceGroupController; + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + @Before public void setUp() { MockitoAnnotations.initMocks(this); @@ -102,11 +131,20 @@ public class ConnectedDeviceGroupControllerTest { when(mContext.getSystemService(InputManager.class)).thenReturn(mInputManager); when(mInputManager.getInputDeviceIds()).thenReturn(new int[]{}); + ShadowBluetoothUtils.sLocalBluetoothManager = mLocalBluetoothManager; + mLocalBluetoothManager = Utils.getLocalBtManager(mContext); + when(mLocalBluetoothManager.getCachedDeviceManager()).thenReturn(mCachedDeviceManager); + mConnectedDeviceGroupController = new ConnectedDeviceGroupController(mContext); mConnectedDeviceGroupController.init(mConnectedBluetoothDeviceUpdater, mConnectedUsbDeviceUpdater, mConnectedDockUpdater, mStylusDeviceUpdater); mConnectedDeviceGroupController.mPreferenceGroup = mPreferenceGroup; + when(mCachedDevice.getName()).thenReturn(DEVICE_NAME); + when(mCachedDevice.getDevice()).thenReturn(mDevice); + when(mCachedDeviceManager.getCachedDevicesCopy()).thenReturn( + ImmutableList.of(mCachedDevice)); + FeatureFlagUtils.setEnabled(mContext, FeatureFlagUtils.SETTINGS_SHOW_STYLUS_PREFERENCES, true); } @@ -267,4 +305,27 @@ public class ConnectedDeviceGroupControllerTest { mConnectedDeviceGroupController.onStart(); mConnectedDeviceGroupController.onStop(); } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_BONDED_BLUETOOTH_DEVICE_SEARCHABLE) + public void updateDynamicRawDataToIndex_deviceNotBonded_deviceIsNotSearchable() { + when(mDevice.getBondState()).thenReturn(BluetoothDevice.BOND_NONE); + List searchData = new ArrayList<>(); + + mConnectedDeviceGroupController.updateDynamicRawDataToIndex(searchData); + + assertThat(searchData).isEmpty(); + } + + @Test + @EnableFlags(Flags.FLAG_ENABLE_BONDED_BLUETOOTH_DEVICE_SEARCHABLE) + public void updateDynamicRawDataToIndex_deviceBonded_deviceIsSearchable() { + when(mDevice.getBondState()).thenReturn(BluetoothDevice.BOND_BONDED); + List searchData = new ArrayList<>(); + + mConnectedDeviceGroupController.updateDynamicRawDataToIndex(searchData); + + assertThat(searchData).isNotEmpty(); + assertThat(searchData.get(0).key).contains(DEVICE_NAME); + } } From 1fe85991e84843bd9c751617dbb82f98df5b65af Mon Sep 17 00:00:00 2001 From: YK Hung Date: Tue, 11 Jun 2024 14:49:40 +0000 Subject: [PATCH 14/15] Disable explicit gc method invocation in V Disable the gc() method explicit invocation due to the new policy in the android to avoid android.os.strictmode.ExplicitGcViolation Test: presubmit Fix: 345577704 Flag: EXEMPT strictmode fix Change-Id: I6ec7dac6735499c40a7ab6d135108e4c5013a4a4 --- .../settings/fuelgauge/batteryusage/DatabaseUtils.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/com/android/settings/fuelgauge/batteryusage/DatabaseUtils.java b/src/com/android/settings/fuelgauge/batteryusage/DatabaseUtils.java index 5b28abb422f..0bb6286171c 100644 --- a/src/com/android/settings/fuelgauge/batteryusage/DatabaseUtils.java +++ b/src/com/android/settings/fuelgauge/batteryusage/DatabaseUtils.java @@ -66,10 +66,10 @@ import java.util.stream.Collectors; public final class DatabaseUtils { private static final String TAG = "DatabaseUtils"; private static final String SHARED_PREFS_FILE = "battery_usage_shared_prefs"; + private static final boolean EXPLICIT_CLEAR_MEMORY_ENABLED = false; /** Clear memory threshold for device booting phase. */ private static final long CLEAR_MEMORY_THRESHOLD_MS = Duration.ofMinutes(5).toMillis(); - private static final long CLEAR_MEMORY_DELAYED_MS = Duration.ofSeconds(2).toMillis(); private static final long INVALID_TIMESTAMP = 0L; @@ -975,7 +975,8 @@ public final class DatabaseUtils { } private static void clearMemory() { - if (SystemClock.uptimeMillis() > CLEAR_MEMORY_THRESHOLD_MS) { + if (!EXPLICIT_CLEAR_MEMORY_ENABLED + || SystemClock.uptimeMillis() > CLEAR_MEMORY_THRESHOLD_MS) { return; } final Handler mainHandler = new Handler(Looper.getMainLooper()); From bac3e20cce3684da4f5d293dd6addcef5b50f43d Mon Sep 17 00:00:00 2001 From: Tetiana Meronyk Date: Tue, 11 Jun 2024 14:51:13 +0000 Subject: [PATCH 15/15] Disable mRemoveUserPreference when mGuestUserAutoCreated only for guest. Before this change, Delete action was disabled for all uninitialized secondary users. Bug: 341840847 Test: atest UserDetailsSettingsTest Change-Id: Icd43836f577dd061d267f6fb75658c35a0c47589 --- src/com/android/settings/users/UserDetailsSettings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/com/android/settings/users/UserDetailsSettings.java b/src/com/android/settings/users/UserDetailsSettings.java index 1f0d824d087..b48c71727d0 100644 --- a/src/com/android/settings/users/UserDetailsSettings.java +++ b/src/com/android/settings/users/UserDetailsSettings.java @@ -127,7 +127,7 @@ public class UserDetailsSettings extends SettingsPreferenceFragment public void onResume() { super.onResume(); mSwitchUserPref.setEnabled(canSwitchUserNow()); - if (mGuestUserAutoCreated) { + if (mUserInfo.isGuest() && mGuestUserAutoCreated) { mRemoveUserPref.setEnabled((mUserInfo.flags & UserInfo.FLAG_INITIALIZED) != 0); } }