diff --git a/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBase.java b/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBase.java index 2c65934dd72..387bf837ce4 100644 --- a/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBase.java +++ b/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBase.java @@ -31,15 +31,12 @@ import android.os.Handler; import android.os.Looper; import android.text.TextUtils; import android.util.Log; -import android.view.LayoutInflater; import android.view.View; -import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; -import androidx.appcompat.app.AlertDialog; import com.android.settings.R; import com.android.settings.SettingsActivity; @@ -71,8 +68,9 @@ public abstract class BluetoothDevicePairingDetailBase extends DeviceListPrefere private volatile BluetoothDevice mJustBonded = null; private final Handler mHandler = new Handler(Looper.getMainLooper()); private final ExecutorService mExecutor = Executors.newSingleThreadExecutor(); + @VisibleForTesting @Nullable - private AlertDialog mProgressDialog = null; + ProgressDialogFragment mProgressDialog = null; @VisibleForTesting boolean mShouldTriggerAudioSharingShareThenPairFlow = false; private CopyOnWriteArrayList mDevicesWithMetadataChangedListener = @@ -384,41 +382,24 @@ public abstract class BluetoothDevicePairingDetailBase extends DeviceListPrefere finish(); } - // TODO: use DialogFragment private void showConnectingDialog(@NonNull String deviceName) { postOnMainThread(() -> { String message = getContext().getString(R.string.progress_dialog_connect_device_content, deviceName); + if (mProgressDialog == null) { + mProgressDialog = ProgressDialogFragment.newInstance(this); + } if (mProgressDialog != null) { - Log.d(getLogTag(), "showConnectingDialog, is already showing"); - TextView textView = mProgressDialog.findViewById(R.id.message); - if (textView != null && !message.equals(textView.getText().toString())) { - Log.d(getLogTag(), "showConnectingDialog, update message"); - textView.setText(message); - } - return; + mProgressDialog.show(message); } - Log.d(getLogTag(), "showConnectingDialog, show dialog"); - AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - LayoutInflater inflater = LayoutInflater.from(builder.getContext()); - View customView = inflater.inflate( - R.layout.dialog_audio_sharing_progress, /* root= */ - null); - TextView textView = customView.findViewById(R.id.message); - if (textView != null) { - textView.setText(message); - } - AlertDialog dialog = builder.setView(customView).setCancelable(false).create(); - dialog.setCanceledOnTouchOutside(false); - mProgressDialog = dialog; - dialog.show(); }); } private void dismissConnectingDialog() { postOnMainThread(() -> { if (mProgressDialog != null) { - mProgressDialog.dismiss(); + Log.d(getLogTag(), "Dismiss connecting dialog."); + mProgressDialog.dismissAllowingStateLoss(); } }); } diff --git a/src/com/android/settings/bluetooth/ProgressDialogFragment.java b/src/com/android/settings/bluetooth/ProgressDialogFragment.java new file mode 100644 index 00000000000..15d53299e42 --- /dev/null +++ b/src/com/android/settings/bluetooth/ProgressDialogFragment.java @@ -0,0 +1,133 @@ +/* + * 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.bluetooth; + +import android.app.Dialog; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +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 androidx.lifecycle.Lifecycle; + +import com.android.settings.R; +import com.android.settings.core.instrumentation.InstrumentedDialogFragment; + +import com.google.common.base.Strings; + +public class ProgressDialogFragment extends InstrumentedDialogFragment { + private static final String TAG = "BTProgressDialog"; + + private static final String BUNDLE_KEY_MESSAGE = "bundle_key_message"; + + @Nullable private static FragmentManager sManager; + @Nullable private static Lifecycle sLifecycle; + private String mMessage = ""; + @Nullable private AlertDialog mAlertDialog; + + @Override + public int getMetricsCategory() { + // TODO: add metrics + return 0; + } + + /** + * Returns a new instance of {@link ProgressDialogFragment} dialog. + * + * @param host The Fragment this dialog will be hosted. + */ + @Nullable + public static ProgressDialogFragment newInstance(@Nullable Fragment host) { + if (host == null) return null; + try { + sManager = host.getChildFragmentManager(); + sLifecycle = host.getLifecycle(); + } catch (IllegalStateException e) { + Log.d(TAG, "Fail to create new instance: " + e.getMessage()); + return null; + } + return new ProgressDialogFragment(); + } + + /** + * Display {@link ProgressDialogFragment} dialog. + * + * @param message The message to be shown on the dialog + */ + public void show(@NonNull String message) { + if (sManager == null) return; + Lifecycle.State currentState = sLifecycle == null ? null : sLifecycle.getCurrentState(); + if (currentState == null || !currentState.isAtLeast(Lifecycle.State.STARTED)) { + Log.d(TAG, "Fail to show dialog with state: " + currentState); + return; + } + if (mAlertDialog != null && mAlertDialog.isShowing()) { + if (!mMessage.equals(message)) { + Log.d(TAG, "Update dialog message."); + TextView messageView = mAlertDialog.findViewById(R.id.message); + if (messageView != null) { + messageView.setText(message); + } + mMessage = message; + } + Log.d(TAG, "Dialog is showing, return."); + return; + } + mMessage = message; + Log.d(TAG, "Show up the progress dialog."); + Bundle args = new Bundle(); + args.putString(BUNDLE_KEY_MESSAGE, message); + setArguments(args); + show(sManager, TAG); + } + + /** Returns the current message on the dialog. */ + @VisibleForTesting + @NonNull + public String getMessage() { + return mMessage; + } + + private ProgressDialogFragment() { + } + + @Override + @NonNull + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + Bundle args = requireArguments(); + String message = args.getString(BUNDLE_KEY_MESSAGE, ""); + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + LayoutInflater inflater = LayoutInflater.from(builder.getContext()); + View customView = inflater.inflate( + R.layout.dialog_audio_sharing_progress, /* root= */ null); + TextView textView = customView.findViewById(R.id.message); + if (textView != null && !Strings.isNullOrEmpty(message)) { + textView.setText(message); + } + AlertDialog dialog = builder.setView(customView).setCancelable(false).create(); + dialog.setCanceledOnTouchOutside(false); + mAlertDialog = dialog; + return dialog; + } +} diff --git a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java index 53bfcf8f17c..a0cb6536570 100644 --- a/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java +++ b/src/com/android/settings/connecteddevice/audiosharing/AudioSharingProgressDialogFragment.java @@ -80,6 +80,7 @@ public class AudioSharingProgressDialogFragment extends InstrumentedDialogFragme if (messageView != null) { messageView.setText(message); } + sMessage = message; } Log.d(TAG, "Dialog is showing, return."); return; diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBaseTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBaseTest.java index 9f0cb6e35d9..949b3d83809 100644 --- a/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBaseTest.java +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothDevicePairingDetailBaseTest.java @@ -46,17 +46,21 @@ import android.os.Bundle; import android.os.Looper; import android.platform.test.flag.junit.SetFlagsRule; import android.util.Pair; -import android.widget.TextView; -import androidx.appcompat.app.AlertDialog; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.Lifecycle; import androidx.test.core.app.ApplicationProvider; import com.android.settings.R; import com.android.settings.SettingsActivity; import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; import com.android.settings.testutils.shadow.ShadowBluetoothAdapter; +import com.android.settings.testutils.shadow.ShadowFragment; import com.android.settingslib.bluetooth.CachedBluetoothDevice; import com.android.settingslib.bluetooth.LocalBluetoothManager; import com.android.settingslib.flags.Flags; @@ -73,8 +77,14 @@ import org.mockito.junit.MockitoRule; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; +import org.robolectric.annotation.RealObject; +import org.robolectric.annotation.Resetter; import org.robolectric.shadow.api.Shadow; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.Executor; /** Tests for {@link BluetoothDevicePairingDetailBase}. */ @@ -82,7 +92,7 @@ import java.util.concurrent.Executor; @Config(shadows = { ShadowBluetoothAdapter.class, ShadowAlertDialogCompat.class, - com.android.settings.testutils.shadow.ShadowFragment.class, + ShadowFragment.class, }) public class BluetoothDevicePairingDetailBaseTest { @@ -133,7 +143,6 @@ public class BluetoothDevicePairingDetailBaseTest { mFragment.mLocalManager = mLocalManager; mFragment.mBluetoothAdapter = mBluetoothAdapter; mFragment.initPreferencesFromPreferenceScreen(); - } @Test @@ -199,22 +208,26 @@ public class BluetoothDevicePairingDetailBaseTest { } @Test + @Config(shadows = ShadowDialogFragment.class) public void onDeviceBondStateChanged_bonded_pairAndJoinSharingEnabled_handle() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + ShadowDialogFragment.reset(); when(mCachedBluetoothDevice.getDevice()).thenReturn(mBluetoothDevice); mFragment.mSelectedList.add(mBluetoothDevice); setUpFragmentWithPairAndJoinSharingIntent(true); mFragment.onDeviceBondStateChanged(mCachedBluetoothDevice, BluetoothDevice.BOND_BONDED); shadowOf(Looper.getMainLooper()).idle(); - AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); - assertThat(dialog).isNotNull(); - TextView message = dialog.findViewById(R.id.message); - assertThat(message).isNotNull(); - assertThat(message.getText().toString()).isEqualTo( + ProgressDialogFragment progressDialog = mFragment.mProgressDialog; + assertThat(progressDialog).isNotNull(); + assertThat(progressDialog.getMessage()).isEqualTo( mContext.getString(R.string.progress_dialog_connect_device_content, TEST_DEVICE_ADDRESS)); + assertThat( + ShadowDialogFragment.isIsShowing(ProgressDialogFragment.class.getName())).isTrue(); verify(mFragment, never()).finish(); + + ShadowDialogFragment.reset(); } @Test @@ -283,9 +296,11 @@ public class BluetoothDevicePairingDetailBaseTest { } @Test + @Config(shadows = ShadowDialogFragment.class) public void onProfileConnectionStateChanged_deviceInSelectedListAndConnected_pairAndJoinSharing() { mSetFlagsRule.enableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING); + ShadowDialogFragment.reset(); when(mCachedBluetoothDevice.getDevice()).thenReturn(mBluetoothDevice); mFragment.mSelectedList.add(mBluetoothDevice); setUpFragmentWithPairAndJoinSharingIntent(true); @@ -309,6 +324,8 @@ public class BluetoothDevicePairingDetailBaseTest { assertThat(btDevice).isNotNull(); assertThat(btDevice).isEqualTo(mBluetoothDevice); verify(mFragment).finish(); + + ShadowDialogFragment.reset(); } @Test @@ -393,7 +410,13 @@ public class BluetoothDevicePairingDetailBaseTest { doReturn(intent).when(activity).getIntent(); doReturn(activity).when(mFragment).getActivity(); FragmentManager fragmentManager = mock(FragmentManager.class); + FragmentTransaction fragmentTransaction = mock(FragmentTransaction.class); + doReturn(fragmentTransaction).when(fragmentManager).beginTransaction(); doReturn(fragmentManager).when(mFragment).getFragmentManager(); + doReturn(fragmentManager).when(mFragment).getChildFragmentManager(); + Lifecycle lifecycle = mock(Lifecycle.class); + when(lifecycle.getCurrentState()).thenReturn(Lifecycle.State.RESUMED); + doReturn(lifecycle).when(mFragment).getLifecycle(); mFragment.mShouldTriggerAudioSharingShareThenPairFlow = mFragment.shouldTriggerAudioSharingShareThenPairFlow(); } @@ -425,4 +448,41 @@ public class BluetoothDevicePairingDetailBaseTest { return "test_tag"; } } + + /** Shadow of DialogFragment. */ + @Implements(value = DialogFragment.class) + public static class ShadowDialogFragment { + @RealObject + private DialogFragment mDialogFragment; + private static Map sDialogStatus = new HashMap<>(); + + /** Resetter of the shadow. */ + @Resetter + public static void reset() { + sDialogStatus.clear(); + } + + /** Implementation for DialogFragment#show. */ + @Implementation + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + sDialogStatus.put(mDialogFragment.getClass().getName(), true); + } + + /** Implementation for DialogFragment#dismissAllowingStateLoss. */ + @Implementation + public void dismissAllowingStateLoss() { + sDialogStatus.put(mDialogFragment.getClass().getName(), false); + } + + /** Implementation for DialogFragment#dismiss. */ + @Implementation + public void dismiss() { + sDialogStatus.put(mDialogFragment.getClass().getName(), false); + } + + /** Check if DialogFragment is showing. */ + public static boolean isIsShowing(String clazzName) { + return sDialogStatus.getOrDefault(clazzName, false); + } + } } diff --git a/tests/robotests/src/com/android/settings/bluetooth/ProgressDialogFragmentTest.java b/tests/robotests/src/com/android/settings/bluetooth/ProgressDialogFragmentTest.java new file mode 100644 index 00000000000..74687767b47 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/ProgressDialogFragmentTest.java @@ -0,0 +1,140 @@ +/* + * 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.bluetooth; + +import static com.google.common.truth.Truth.assertThat; + +import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; + +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; + +import com.android.settings.R; +import com.android.settings.testutils.shadow.ShadowAlertDialogCompat; + +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.shadows.androidx.fragment.FragmentController; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowAlertDialogCompat.class}) +public class ProgressDialogFragmentTest { + @Rule public final MockitoRule mocks = MockitoJUnit.rule(); + + private static final String TEST_MESSAGE1 = "message1"; + private static final String TEST_MESSAGE2 = "message2"; + + private Fragment mParent; + + @Before + public void setUp() { + ShadowAlertDialogCompat.reset(); + mParent = new Fragment(); + FragmentController.setupFragment( + mParent, FragmentActivity.class, /* containerViewId= */ 0, /* bundle= */ null); + } + + @After + public void tearDown() { + ShadowAlertDialogCompat.reset(); + } + + @Test + public void getMetricsCategory_correctValue() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + // TODO: update real metric + assertThat(fragment.getMetricsCategory()).isEqualTo(0); + } + + @Test + public void onCreateDialog_unattachedFragment_nullDialogFragment() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(new Fragment()); + assertThat(fragment).isNull(); + } + + @Test + public void onCreateDialog_showDialog() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + TextView view = dialog.findViewById(R.id.message); + assertThat(view).isNotNull(); + assertThat(view.getText().toString()).isEqualTo(TEST_MESSAGE1); + } + + @Test + public void dismissDialog_succeed() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + + fragment.dismissAllowingStateLoss(); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isFalse(); + } + + @Test + public void showDialog_sameMessage_keepExistingDialog() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isTrue(); + TextView view = dialog.findViewById(R.id.message); + assertThat(view).isNotNull(); + assertThat(view.getText().toString()).isEqualTo(TEST_MESSAGE1); + } + + @Test + public void showDialog_newMessage_keepAndUpdateDialog() { + ProgressDialogFragment fragment = ProgressDialogFragment.newInstance(mParent); + fragment.show(TEST_MESSAGE1); + shadowMainLooper().idle(); + AlertDialog dialog = ShadowAlertDialogCompat.getLatestAlertDialog(); + assertThat(dialog).isNotNull(); + assertThat(dialog.isShowing()).isTrue(); + TextView view = dialog.findViewById(R.id.message); + assertThat(view).isNotNull(); + assertThat(view.getText().toString()).isEqualTo(TEST_MESSAGE1); + + fragment.show(TEST_MESSAGE2); + shadowMainLooper().idle(); + assertThat(dialog.isShowing()).isTrue(); + assertThat(view.getText().toString()).isEqualTo(TEST_MESSAGE2); + } +}