Thursday, April 20, 2017

Android NFC radio control using instrumentation

I have always worked a lot with NFC on Android. For this reason, I tend to favor real devices over emulators, since missing an NFC radio means there's no way to truly test the intricacies of radio communication. Unfortunately, one can not power cycle the NFC radio using any official API unless going through hoops and using rooted devices, so ensuring NFC radio power state during testing is an uphill battle. For instrumented test scenarios however, there is actually a way forward.

UIAutomator to the rescue

While not as elegant as using an API, we can launch the settings screen for NFC and manipulate it through the use of instrumentation. This is NOT possible using modern Espresso which limits you to the app under test, but thankfully the UIAutomator framework is still available. The accompanying UIAutomator Viewer tool (which has now moved to sdk/tools/bin/uiautomatorviewer) is a great asset in this regard as it helps us identify the widget we need to manipulate.


What the NFC toggle button is named is not consistent across devices and versions of the operating system, so we have to get a bit heuristic here. In practice, looking through my some 10 devices with various versions of Android using various custom skinning, I have identified 3 unique resourceId's for the toggle button. These are com.android.settings:id/switch_widget, android:id/switchWidget and android:id/switch_widget. Unfortunately, on Android 7 (for Huawei devices anyway) it seems as if launching the ACTION_NFC_SETTINGS intent will not actually get you to where you want, but requires an additional navigational step. This complicates the code a bit but it's still possible to make it work.

To launch the Settings activity prior to any Activity under test, we need to pass along the Intent.FLAG_ACTIVITY_NEW_TASK flag. From there, we can write our logic to help us toggle NFC state.

    private void toggleNfc(final Context context) {

        final Intent intent = new Intent(Settings.ACTION_NFC_SETTINGS);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);

        final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

        findAndToggleNfcInUI(device);
    }

    private void findAndToggleNfcInUI(final UiDevice device) {

        final UiObject toggleButton = device.findObject(new UiSelector()
                .resourceIdMatches("(com.android.settings:id/switch_widget|android:id/switchWidget|android:id/switch_widget)"));

        try{
            toggleButton.click();
            device.pressBack();
            return;
        }catch(UiObjectNotFoundException e){
            UiObject2 nfcMenuItem = device.findObject(By.textContains("NFC"));

            // Move up in the view hierachy until we're at a clickable item
            while(!nfcMenuItem.isClickable()){
                nfcMenuItem = nfcMenuItem.getParent();
            }

            // Issue click to navigate into menu
            nfcMenuItem.click();

            // Wait for any UI jitter to settle
            getInstrumentation().waitForIdleSync();

            // Try to toggle NFC button using this new child activity
            findAndToggleNfcInUI(device);
        }
    }


Composable test aspect using a JUnit rule

The code above is fine and dandy, but I'm a big proponent of composable and reusable aspects, so lets take advantage of the fact, that we can encapsulate the functionality nicely using JUnit's rule mechanism. If you're new to these you may read up on them here. The resulting NfcStateRule.class can be seen below.

/**
 * JUnit test rule for controlling NFC radio power state. Useful in order to ensure NFC is
 * enabled or disabled prior to executing a test.
 */
public class NfcStateRule implements TestRule {

    private static final String NFC_TOGGLE_WIDGET_RESOURCEIDS =
            "(com.android.settings:id/switch_widget|android:id/switchWidget|android:id/switch_widget)";

    private final boolean desiredState;

    public NfcStateRule(boolean desiredState) {
        this.desiredState = desiredState;
    }

    @Override
    public Statement apply(final Statement base, final Description description) {
        return new Statement() {
            public void evaluate() throws Throwable {

                try{
                    final Context context = InstrumentationRegistry.getTargetContext();
                    ensureNfcState(context, desiredState);

                }catch(final Throwable e){
                    e.printStackTrace();
                }
                base.evaluate();
            }
        };
    }

    private void ensureNfcState(final Context context, final boolean desiredState) {
        if(desiredState){
            ensureNfcIsEnabled(context);
        }else{
            ensureNfcIsDisabled(context);
        }
    }

    private void ensureNfcIsDisabled(final Context context) {
        if(isNfcEnabled(context)){
            toggleNfc(context);
        }
    }

    private void ensureNfcIsEnabled(final Context context) {
        if(!isNfcEnabled(context)){
            toggleNfc(context);
        }
    }

    private boolean isNfcEnabled(final Context context) {
        final NfcAdapter nfcAdapter = NfcAdapter.getDefaultAdapter(context);

        if (nfcAdapter == null) {
            return false;
        }

        return nfcAdapter.isEnabled();
    }

    private void toggleNfc(final Context context) {

        final Intent intent = new Intent(Settings.ACTION_NFC_SETTINGS);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        context.startActivity(intent);

        final UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());

        findAndToggleNfcInUI(device);
    }

    private void findAndToggleNfcInUI(final UiDevice device) {

        final UiObject toggleButton = device.findObject(new UiSelector()
                .resourceIdMatches(NFC_TOGGLE_WIDGET_RESOURCEIDS));

        try{
            toggleButton.click();
            device.pressBack();
            return;
        }catch(UiObjectNotFoundException e){
            UiObject2 nfcMenuItem = device.findObject(By.textContains("NFC"));

            // Move up in the view hierachy until we're at a clickable item
            while(!nfcMenuItem.isClickable()){
                nfcMenuItem = nfcMenuItem.getParent();
            }

            // Issue click to navigate into menu
            nfcMenuItem.click();

            // Wait for any UI jitter to settle
            getInstrumentation().waitForIdleSync();

            // Try to toggle NFC button using this new child activity
            findAndToggleNfcInUI(device);
        }
    }
}

To use our NfcStateRule in an actual test, simply include it as a member and specify the desired NFC radio power state by passing a boolean with the constructor.

@LargeTest
@RunWith(AndroidJUnit4.class)
public class NfcDisabledTest {

    @ClassRule
    public static final NfcStateRule nfcStateRule = new NfcStateRule(false); // Make sure NFC is disabled

    ...actual test...
}

Voila, now it's possible to setup test scenarios correctly using the NFC radio. This is important for many of my Espresso tests to work consistently and reliably every time, as demonstrated by a screen below which tests the UI when the user has disabled NFC.

An example of an Activity/Fragment whos UI-state depends of the state of the NFC radio.

Conclusion

Where there is a will, there is a way! The above is not nearly as clean as having an API which we have available for WiFi, GPS etc. For acceptance testing however, I much prefer this kind of automated UI manipulation over mocking or polluting short-circuiting logic within the app itself.

By definition, the approach must be considered fragile since the NFC toggle button can be called something different on devices I have not yet had my hands on! If you run into this problem, the fix is easy - simply use the UIAutomator Viewer and expand the regular expression to work with this custom view. In a test scenario you usually have full control of the devices anyway so it's not really a practical concern since end-users will never be exposed to the code.

As usual, the code may be buggy, may not work on all versions of Android and is definitely not production safe. You may assume a Public Domain license of the code snippets above. Feel free to contribute back in the comments if you want to share your findings or experiences regarding the matter.

Post a Comment