diff --git a/src/com/android/settings/bluetooth/BluetoothPairingController.java b/src/com/android/settings/bluetooth/BluetoothPairingController.java index 38b30a88cdb..ce82612685b 100644 --- a/src/com/android/settings/bluetooth/BluetoothPairingController.java +++ b/src/com/android/settings/bluetooth/BluetoothPairingController.java @@ -188,7 +188,7 @@ public class BluetoothPairingController implements OnCheckedChangeListener, * * @return - The message ID to show the user. */ - public int getDeviceVariantMessageID() { + public int getDeviceVariantMessageId() { switch (mType) { case BluetoothDevice.PAIRING_VARIANT_PIN_16_DIGITS: case BluetoothDevice.PAIRING_VARIANT_PIN: @@ -198,7 +198,7 @@ public class BluetoothPairingController implements OnCheckedChangeListener, return R.string.bluetooth_enter_passkey_other_device; default: - return -1; + return INVALID_DIALOG_TYPE; } } @@ -208,7 +208,7 @@ public class BluetoothPairingController implements OnCheckedChangeListener, * * @return - The message ID to show the user. */ - public int getDeviceVariantMessageHint() { + public int getDeviceVariantMessageHintId() { switch (mType) { case BluetoothDevice.PAIRING_VARIANT_PIN_16_DIGITS: return R.string.bluetooth_pin_values_hint_16_digits; @@ -218,7 +218,7 @@ public class BluetoothPairingController implements OnCheckedChangeListener, return R.string.bluetooth_pin_values_hint; default: - return -1; + return INVALID_DIALOG_TYPE; } } diff --git a/src/com/android/settings/bluetooth/BluetoothPairingDialogFragment.java b/src/com/android/settings/bluetooth/BluetoothPairingDialogFragment.java index d4247c0e5d9..abeb8625c9d 100644 --- a/src/com/android/settings/bluetooth/BluetoothPairingDialogFragment.java +++ b/src/com/android/settings/bluetooth/BluetoothPairingDialogFragment.java @@ -45,10 +45,9 @@ public class BluetoothPairingDialogFragment extends InstrumentedDialogFragment i private static final String TAG = "BTPairingDialogFragment"; private AlertDialog.Builder mBuilder; - private BluetoothPairingController mPairingController; private AlertDialog mDialog; + private BluetoothPairingController mPairingController; private EditText mPairingView; - /** * The interface we expect a listener to implement. Typically this should be done by * the controller. @@ -105,12 +104,26 @@ public class BluetoothPairingDialogFragment extends InstrumentedDialogFragment i return MetricsEvent.BLUETOOTH_DIALOG_FRAGMENT; } + /** + * Used in testing to get a reference to the dialog. + * @return - The fragments current dialog + */ + protected AlertDialog getmDialog() { + return mDialog; + } + /** * Sets the controller that the fragment should use. this method MUST be called * before you try to show the dialog or an error will be thrown. An implementation - * of a pairing controller can be found at {@link BluetoothPairingController}. + * of a pairing controller can be found at {@link BluetoothPairingController}. A + * controller may not be substituted once it is assigned. Forcibly switching a + * controller for a new one will lead to undefined behavior. */ public void setPairingController(BluetoothPairingController pairingController) { + if (mPairingController != null) { + throw new IllegalStateException("The controller can only be set once. " + + "Forcibly replacing it will lead to undefined behavior"); + } mPairingController = pairingController; } @@ -146,7 +159,7 @@ public class BluetoothPairingDialogFragment extends InstrumentedDialogFragment i mBuilder.setPositiveButton(getString(android.R.string.ok), this); mBuilder.setNegativeButton(getString(android.R.string.cancel), this); AlertDialog dialog = mBuilder.create(); - dialog.getButton(Dialog.BUTTON_POSITIVE).setEnabled(false); + dialog.setOnShowListener(d -> mDialog.getButton(Dialog.BUTTON_POSITIVE).setEnabled(false)); return dialog; } @@ -171,6 +184,7 @@ public class BluetoothPairingDialogFragment extends InstrumentedDialogFragment i mPairingView = pairingView; + pairingView.setInputType(InputType.TYPE_CLASS_NUMBER); pairingView.addTextChangedListener(this); alphanumericPin.setOnCheckedChangeListener((buttonView, isChecked) -> { // change input type for soft keyboard to numeric or alphanumeric @@ -181,15 +195,21 @@ public class BluetoothPairingDialogFragment extends InstrumentedDialogFragment i } }); - int messageId = mPairingController.getDeviceVariantMessageID(); - int messageIdHint = mPairingController.getDeviceVariantMessageHint(); + int messageId = mPairingController.getDeviceVariantMessageId(); + int messageIdHint = mPairingController.getDeviceVariantMessageHintId(); int maxLength = mPairingController.getDeviceMaxPasskeyLength(); alphanumericPin.setVisibility(mPairingController.pairingCodeIsAlphanumeric() ? View.VISIBLE : View.GONE); - - messageViewCaptionHint.setText(messageIdHint); - messageView2.setText(messageId); - pairingView.setInputType(InputType.TYPE_CLASS_NUMBER); + if (messageId != BluetoothPairingController.INVALID_DIALOG_TYPE) { + messageView2.setText(messageId); + } else { + messageView2.setVisibility(View.GONE); + } + if (messageIdHint != BluetoothPairingController.INVALID_DIALOG_TYPE) { + messageViewCaptionHint.setText(messageIdHint); + } else { + messageViewCaptionHint.setVisibility(View.GONE); + } pairingView.setFilters(new InputFilter[]{ new LengthFilter(maxLength)}); @@ -203,10 +223,8 @@ public class BluetoothPairingDialogFragment extends InstrumentedDialogFragment i mBuilder.setTitle(getString(R.string.bluetooth_pairing_request, mPairingController.getDeviceName())); mBuilder.setView(createView()); - mBuilder.setPositiveButton(getString(R.string.bluetooth_pairing_accept), - this); - mBuilder.setNegativeButton(getString(R.string.bluetooth_pairing_decline), - this); + mBuilder.setPositiveButton(getString(R.string.bluetooth_pairing_accept), this); + mBuilder.setNegativeButton(getString(R.string.bluetooth_pairing_decline), this); AlertDialog dialog = mBuilder.create(); return dialog; } diff --git a/tests/robotests/src/com/android/settings/SettingsRobolectricTestRunner.java b/tests/robotests/src/com/android/settings/SettingsRobolectricTestRunner.java new file mode 100644 index 00000000000..e15671685ba --- /dev/null +++ b/tests/robotests/src/com/android/settings/SettingsRobolectricTestRunner.java @@ -0,0 +1,76 @@ +/* + * Copyright (C) 2016 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; + +import org.junit.runners.model.InitializationError; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.manifest.AndroidManifest; +import org.robolectric.res.Fs; +import org.robolectric.res.ResourcePath; + +import java.util.List; + +/** + * Custom test runner for the testing of BluetoothPairingDialogs. This is needed because the + * default behavior for robolectric is just to grab the resource directory in the target package. + * We want to override this to add several spanning different projects. + */ +public class SettingsRobolectricTestRunner extends RobolectricTestRunner { + + /** + * We don't actually want to change this behavior, so we just call super. + */ + public SettingsRobolectricTestRunner(Class testClass) throws InitializationError { + super(testClass); + } + + /** + * We are going to create our own custom manifest so that we can add multiple resource + * paths to it. This lets us access resources in both Settings and SettingsLib in our tests. + */ + @Override + protected AndroidManifest getAppManifest(Config config) { + // Using the manifest file's relative path, we can figure out the application directory. + final String appRoot = "packages/apps/Settings"; + final String manifestPath = appRoot + "/AndroidManifest.xml"; + final String resDir = appRoot + "/res"; + final String assetsDir = appRoot + "/assets"; + + // By adding any resources from libraries we need to the AndroidManifest, we can access + // them from within the parallel universe's resource loader. + final AndroidManifest manifest = new AndroidManifest(Fs.fileFromPath(manifestPath), + Fs.fileFromPath(resDir), Fs.fileFromPath(assetsDir)) { + @Override + public List getIncludedResourcePaths() { + List paths = super.getIncludedResourcePaths(); + paths.add(new ResourcePath( + getPackageName(), + Fs.fileFromPath("./packages/apps/Settings/res"), + null)); + paths.add(new ResourcePath( + getPackageName(), + Fs.fileFromPath("./frameworks/base/packages/SettingsLib/res"), + null)); + return paths; + } + }; + + // Set the package name to the renamed one + manifest.setPackageName("com.android.settings"); + return manifest; + } +} \ No newline at end of file diff --git a/tests/robotests/src/com/android/settings/bluetooth/BluetoothPairingDialogTest.java b/tests/robotests/src/com/android/settings/bluetooth/BluetoothPairingDialogTest.java new file mode 100644 index 00000000000..004e4f97917 --- /dev/null +++ b/tests/robotests/src/com/android/settings/bluetooth/BluetoothPairingDialogTest.java @@ -0,0 +1,340 @@ +/* + * Copyright (C) 2016 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.junit.Assert.fail; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import android.app.AlertDialog; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; +import com.android.settings.SettingsRobolectricTestRunner; +import com.android.settings.R; +import com.android.settings.TestConfig; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.annotation.Config; +import org.robolectric.util.FragmentTestUtil; + +@RunWith(SettingsRobolectricTestRunner.class) +@Config(manifest = TestConfig.MANIFEST_PATH, sdk = TestConfig.SDK_VERSION) +public class BluetoothPairingDialogTest { + + private static final String FILLER = "text that goes in a view"; + private static final String FAKE_DEVICE_NAME = "Fake Bluetooth Device"; + + @Mock + private BluetoothPairingController controller; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + } + + @Test + public void dialogUpdatesControllerWithUserInput() { + // set the correct dialog type + when(controller.getDialogType()).thenReturn(BluetoothPairingController.USER_ENTRY_DIALOG); + + // we don't care about these for this test + when(controller.getDeviceVariantMessageId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + when(controller.getDeviceVariantMessageHintId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + + // build fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // test that controller is updated on text change + frag.afterTextChanged(new SpannableStringBuilder(FILLER)); + verify(controller, times(1)).updateUserInput(any()); + } + + @Test + public void dialogEnablesSubmitButtonOnValidationFromController() { + // set the correct dialog type + when(controller.getDialogType()).thenReturn(BluetoothPairingController.USER_ENTRY_DIALOG); + + // we don't care about these for this test + when(controller.getDeviceVariantMessageId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + when(controller.getDeviceVariantMessageHintId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + + // force the controller to say that any passkey is valid + when(controller.isPasskeyValid(any())).thenReturn(true); + + // build fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // test that the positive button is enabled when passkey is valid + frag.afterTextChanged(new SpannableStringBuilder(FILLER)); + View button = frag.getmDialog().getButton(AlertDialog.BUTTON_POSITIVE); + assertThat(button).isNotNull(); + assertThat(button.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void dialogDoesNotAskForPairCodeOnConsentVariant() { + // set the dialog variant to confirmation/consent + when(controller.getDialogType()).thenReturn(BluetoothPairingController.CONFIRMATION_DIALOG); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // check that the input field used by the entry dialog fragment does not exist + View view = frag.getmDialog().findViewById(R.id.text); + assertThat(view).isNull(); + } + + @Test + public void dialogAsksForPairCodeOnUserEntryVariant() { + // set the dialog variant to user entry + when(controller.getDialogType()).thenReturn(BluetoothPairingController.USER_ENTRY_DIALOG); + + // we don't care about these for this test + when(controller.getDeviceVariantMessageId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + when(controller.getDeviceVariantMessageHintId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // check that the pin/passkey input field is visible to the user + View view = frag.getmDialog().findViewById(R.id.text); + assertThat(view.getVisibility()).isEqualTo(View.VISIBLE); + } + + @Test + public void dialogDisplaysPairCodeOnDisplayPasskeyVariant() { + // set the dialog variant to display passkey + when(controller.getDialogType()) + .thenReturn(BluetoothPairingController.DISPLAY_PASSKEY_DIALOG); + + // ensure that the controller returns good values to indicate a passkey needs to be shown + when(controller.isDisplayPairingKeyVariant()).thenReturn(true); + when(controller.hasPairingContent()).thenReturn(true); + when(controller.getPairingContent()).thenReturn(FILLER); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // get the relevant views + View messagePairing = frag.getmDialog().findViewById(R.id.pairing_code_message); + TextView pairingViewContent = + (TextView) frag.getmDialog().findViewById(R.id.pairing_subhead); + View pairingViewCaption = frag.getmDialog().findViewById(R.id.pairing_caption); + + // check that the relevant views are visible and that the passkey is shown + assertThat(messagePairing.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(pairingViewCaption.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(pairingViewContent.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(TextUtils.equals(FILLER, pairingViewContent.getText())).isTrue(); + } + + @Test(expected = IllegalStateException.class) + public void dialogThrowsExceptionIfNoControllerSet() { + // instantiate a fragment + BluetoothPairingDialogFragment frag = new BluetoothPairingDialogFragment(); + + // this should throw an error + FragmentTestUtil.startFragment(frag); + fail("Starting the fragment with no controller set should have thrown an exception."); + } + + @Test + public void dialogCallsHookOnPositiveButtonPress() { + // set the dialog variant to confirmation/consent + when(controller.getDialogType()).thenReturn(BluetoothPairingController.CONFIRMATION_DIALOG); + + // we don't care what this does, just that it is called + doNothing().when(controller).onDialogPositiveClick(any()); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // click the button and verify that the controller hook was called + frag.onClick(frag.getmDialog(), AlertDialog.BUTTON_POSITIVE); + verify(controller, times(1)).onDialogPositiveClick(any()); + } + + @Test + public void dialogCallsHookOnNegativeButtonPress() { + // set the dialog variant to confirmation/consent + when(controller.getDialogType()).thenReturn(BluetoothPairingController.CONFIRMATION_DIALOG); + + // we don't care what this does, just that it is called + doNothing().when(controller).onDialogNegativeClick(any()); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // click the button and verify that the controller hook was called + frag.onClick(frag.getmDialog(), AlertDialog.BUTTON_NEGATIVE); + verify(controller, times(1)).onDialogNegativeClick(any()); + } + + @Test(expected = IllegalStateException.class) + public void dialogDoesNotAllowSwappingController() { + // instantiate a fragment + BluetoothPairingDialogFragment frag = new BluetoothPairingDialogFragment(); + frag.setPairingController(controller); + + // this should throw an error + frag.setPairingController(controller); + fail("Setting the controller multiple times should throw an exception."); + } + + @Test + public void dialogPositiveButtonDisabledWhenUserInputInvalid() { + // set the correct dialog type + when(controller.getDialogType()).thenReturn(BluetoothPairingController.USER_ENTRY_DIALOG); + + // we don't care about these for this test + when(controller.getDeviceVariantMessageId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + when(controller.getDeviceVariantMessageHintId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + + // force the controller to say that any passkey is valid + when(controller.isPasskeyValid(any())).thenReturn(false); + + // build fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // test that the positive button is enabled when passkey is valid + frag.afterTextChanged(new SpannableStringBuilder(FILLER)); + View button = frag.getmDialog().getButton(AlertDialog.BUTTON_POSITIVE); + assertThat(button).isNotNull(); + assertThat(button.isEnabled()).isFalse(); + } + + @Test + public void dialogShowsContactSharingCheckboxWhenBluetoothProfileNotReady() { + // set the dialog variant to confirmation/consent + when(controller.getDialogType()).thenReturn(BluetoothPairingController.CONFIRMATION_DIALOG); + + // set a fake device name and pretend the profile has not been set up for it + when(controller.getDeviceName()).thenReturn(FAKE_DEVICE_NAME); + when(controller.isProfileReady()).thenReturn(false); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // verify that the checkbox is visible and that the device name is correct + CheckBox sharingCheckbox = (CheckBox) frag.getmDialog() + .findViewById(R.id.phonebook_sharing_message_confirm_pin); + assertThat(sharingCheckbox.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(sharingCheckbox.getText().toString().contains(FAKE_DEVICE_NAME)).isTrue(); + } + + @Test + public void dialogHidesContactSharingCheckboxWhenBluetoothProfileIsReady() { + // set the dialog variant to confirmation/consent + when(controller.getDialogType()).thenReturn(BluetoothPairingController.CONFIRMATION_DIALOG); + + // set a fake device name and pretend the profile has been set up for it + when(controller.getDeviceName()).thenReturn(FAKE_DEVICE_NAME); + when(controller.isProfileReady()).thenReturn(true); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // verify that the checkbox is gone + CheckBox sharingCheckbox = (CheckBox) frag.getmDialog() + .findViewById(R.id.phonebook_sharing_message_confirm_pin); + assertThat(sharingCheckbox.getVisibility()).isEqualTo(View.GONE); + } + + @Test + public void dialogShowsMessageOnPinEntryView() { + // set the correct dialog type + when(controller.getDialogType()).thenReturn(BluetoothPairingController.USER_ENTRY_DIALOG); + + // Set the message id to something specific to verify later + when(controller.getDeviceVariantMessageId()).thenReturn(R.string.cancel); + when(controller.getDeviceVariantMessageHintId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // verify message is what we expect it to be and is visible + TextView message = (TextView) frag.getmDialog().findViewById(R.id.message_below_pin); + assertThat(message.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(TextUtils.equals(frag.getString(R.string.cancel), message.getText())).isTrue(); + } + + @Test + public void dialogShowsMessageHintOnPinEntryView() { + // set the correct dialog type + when(controller.getDialogType()).thenReturn(BluetoothPairingController.USER_ENTRY_DIALOG); + + // Set the message id hint to something specific to verify later + when(controller.getDeviceVariantMessageHintId()).thenReturn(R.string.cancel); + when(controller.getDeviceVariantMessageId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // verify message is what we expect it to be and is visible + TextView hint = (TextView) frag.getmDialog().findViewById(R.id.pin_values_hint); + assertThat(hint.getVisibility()).isEqualTo(View.VISIBLE); + assertThat(TextUtils.equals(frag.getString(R.string.cancel), hint.getText())).isTrue(); + } + + @Test + public void dialogHidesMessageAndHintWhenNotProvidedOnPinEntryView() { + // set the correct dialog type + when(controller.getDialogType()).thenReturn(BluetoothPairingController.USER_ENTRY_DIALOG); + + // Set the id's to what is returned when it is not provided + when(controller.getDeviceVariantMessageHintId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + when(controller.getDeviceVariantMessageId()) + .thenReturn(BluetoothPairingController.INVALID_DIALOG_TYPE); + + // build the fragment + BluetoothPairingDialogFragment frag = makeFragment(); + + // verify message is what we expect it to be and is visible + TextView hint = (TextView) frag.getmDialog().findViewById(R.id.pin_values_hint); + assertThat(hint.getVisibility()).isEqualTo(View.GONE); + TextView message = (TextView) frag.getmDialog().findViewById(R.id.message_below_pin); + assertThat(message.getVisibility()).isEqualTo(View.GONE); + } + + private BluetoothPairingDialogFragment makeFragment() { + BluetoothPairingDialogFragment frag = new BluetoothPairingDialogFragment(); + frag.setPairingController(controller); + FragmentTestUtil.startFragment(frag); + assertThat(frag.getmDialog()).isNotNull(); + return frag; + } +}