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/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/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/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_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/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/dimens.xml b/res/values/dimens.xml index c72c17d79b2..d972e138eec 100755 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -497,4 +497,9 @@ 264dp 30dp + + + 96dp + 56dp + 32dp diff --git a/res/values/strings.xml b/res/values/strings.xml index 1f5c11d82b3..fd6a084bdd6 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -7963,6 +7963,15 @@ Schedule + + Set a schedule + + + Schedule + + + %1$d hr, %2$d min + Schedule @@ -9303,6 +9312,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/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/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/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/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/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/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/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/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); 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()); 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/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); 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/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/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/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/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java b/src/com/android/settings/password/ConfirmDeviceCredentialActivity.java index 30fd619d9df..7f362c32904 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); if (iconResId != 0) { - promptInfo.setLogoRes(iconResId); + final Bitmap iconBitmap = toBitmap(mContext.getDrawable(iconResId)); + promptInfo.setLogo(iconResId, iconBitmap); } String logoDescription = intent.getStringExtra( CUSTOM_BIOMETRIC_PROMPT_LOGO_DESCRIPTION_KEY); 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); } } } 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); } } 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/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); + } } 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/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(); + } + } +} 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/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/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()); + } } 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)); + } } 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/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); + } +} 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()); + } } 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; 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) + } +}