Skip to main content
The cover image for "Detox and React Native: UI testing Android permission flows"

Detox and React Native: UI testing Android permission flows

Sidebar

I'm currently learning React Native and Expo as an alternative to [native app development](/2023/05/04/android/). To do this, I've been working on an app so that I can get hands-on experience with React Native app development. This includes end-to-end UI testing, using Detox. I was surprised that Detox doesn't have an API to interact with permissions. The `device.launchApp` function does have a `permissions` field, but this only works on iOS and runs on app start-up - it doesn't allow you to test the actual user flow. In this article, I will explain how you can do end-to-end testing with Android permissions, including simulating user interaction with the Android permission request modal.

I’m currently learning React Native and Expo as an alternative to native app development. To do this, I’ve been working on an app so that I can get hands-on experience with React Native app development. This includes end-to-end UI testing, using Detox.

I was surprised that Detox doesn’t have an API to interact with permissions. The device.launchApp function does have a permissions field, but this only works on iOS and runs on app start-up - it doesn’t allow you to test the actual user flow.

In this article, I will explain how you can do end-to-end testing with Android permissions, including simulating user interaction with the Android permission request modal.

Starting the app without permissions #

By default, Detox will start the app with all permissions granted. This will not be the case when an actual user runs the app. To work around this, we’ll need to reset all permissions using adb.

import { expect } from "detox";
import { execSync } from "child_process";

const packageName = "com.example.app_package_name";

function resetPermissions() {
	execSync(`adb -e -s ${device.id} shell pm reset-permissions -p ${packageName}`);
}

describe("Permission request card", () => {
	beforeAll(async () => {
		resetPermissions();

		await device.launchApp({
			newInstance: true,
		});
	});
});

Programmatically granting and revoking permissions #

You may want to programmatically grant or revoke a permission without having to go through the system UI. You can do that using the following adb commands:

function grantPermission(permission: string) {
	execSync(`adb -e -s ${device.id} shell pm grant ${packageName} ${permission}`);
}

function revokePermission(permission: string) {
	execSync(`adb -e -s ${device.id} shell pm revoke ${packageName} ${permission}`);
}

// Inside a test:
grantPermission("android.permission.ACCESS_FINE_LOCATION");
revokePermission("android.permission.ACCESS_FINE_LOCATION");

Interacting with the Android permission request modal #

A card with a permission justification.
A card with a permission justification.
The Android permission request modal.
The Android permission request modal.

Let’s say you have a UI where the user is shown a permission justification. They can then click a button to open up the permission request modal and grant permission.

Using key events to control system UIs #

Unfortunately, Detox does not provide an API for interacting with System UIs like the permission request modal.footnote 1 Instead, we can use adb to interact with the modal using the keyboard.

Detox has a feature called synchronization, where it waits for the app to become idle before proceeding with the test. This means that tap() will freeze when a system UI modal is open. We’ll need to disable this before tapping.

import { expect } from "detox";
import { execSync } from "child_process";

const Key_Tab = "KEYCODE_TAB";
const Key_Enter = "KEYCODE_ENTER";

function sleep(ms: number): Promise<void> {
	return new Promise(resolve => setTimeout(resolve, ms));
}

async function pressKeys(keys: string[]) {
	for (let key of keys) {
		execSync(`adb -e -s ${device.id} shell input keyevent ${key}`);
		await sleep(100);
	}
}

describe("Permission request card", () => {
	it("should ask for permission", async () => {
		await expect(element(by.id("current-location-msg"))).toHaveText(
			"Grant location permission to see nearby locations here."
		);

		// Disable synchronization, otherwise "tap" will never return due
		// to the appearance of System UI
		await device.disableSynchronization();
		await element(by.id("btn-grant")).tap();
		await sleep(1000);
		await device.enableSynchronization();

		// Use the keyboard to select the button
		//
		// For location permissions, there are 4 tabs as there is a selector
		// between precise and coarse locations.
		//
		// For other permissions, you may only need 2 tabs.
		await pressKeys([ Key_Tab, Key_Tab, Key_Tab, Key_Tab, Key_Enter ]);

		// Check UI updates
		await expect(element(by.id("current-location-msg"))).not.toHaveText(
			"Grant location permission to see nearby locations here."
		);
	});
});

Using uiautomator and touch events to control system UIs #

In the above solution, we hardcode the number of “tab” presses to select the correct button in the system modal. This is a fairly hacky solution that is likely to break on Android OS updates.

A much better solution would be using uiautomator to find the on-screen position of the button we’re looking for. This is more complicated but should be more reliable. To do this, we can use the dump command to export an xml file representing the GUI hierarchy:

adb shell uiautomator dump
adb shell cat /sdcard/window_dump.xml

The button looks like this in the XML:

<node index="0" text="While using the app"
	resource-id="com.android.permissioncontroller:id/permission_allow_foreground_only_button"
	class="android.widget.Button"
	package="com.google.android.permissioncontroller"
	content-desc="" checkable="false" checked="false"
	clickable="true" enabled="true" focusable="true" focused="false"
	scrollable="false" long-clickable="false" password="false"
	selected="false" bounds="[137,1273][943,1427]" />

With an XML parser, we can find the correct node and read the “bounds” attribute:

import { JSDOM } from "jsdom";

function findSystemUINodePosition(resourceId: string): [number, number] | undefined {
	// Dump window hierarchy
	execSync(`adb -e -s ${device.id} shell uiautomator dump`);
	const windowDump = execSync(`adb -e -s ${device.id} shell cat /sdcard/window_dump.xml`);

	// Parse XML
	const jsdom = new JSDOM(windowDump.toString(), { contentType: "text/xml" });
	const document = jsdom.window.document;

	// Find the node
	const buttonNode = document.querySelector(`node[resource-id='${resourceId}']`);
	if (!buttonNode) {
		console.error("Unable to find button");
		return undefined;
	}

	// Get bounds
	const bounds = buttonNode.getAttribute("bounds");
	const parts = bounds?.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
	if (!parts) {
		console.error("Unable to match bounds");
		return undefined;
	}

	// Return center point
	const x = (parseInt(parts[1]) + parseInt(parts[3])) / 2;
	const y = (parseInt(parts[2]) + parseInt(parts[4])) / 2;
	return [x, y];
}

We can then tap on the button:

import jestExpect from "expect";

function tapAt(x: number, y: number) {
	execSync(`adb -e -s ${device.id} shell input tap ${x} ${y}`);
}

// Instead of "pressKeys" above
const resourceId = "com.android.permissioncontroller:id/permission_allow_foreground_only_button";
const point = findSystemUINodePosition(resourceId);
jestExpect(point).toBeTruthy();
tapAt(point[0], point[1]);

Conclusion #

Hopefully, Detox will have built-in support for permissions in the future. Until then, I hope this article is helpful to others.


  1. Detox feature request for a permissions API: https://github.com/wix/Detox/issues/2184 ↩︎

rubenwardy's profile picture, the letter R

Hi, I'm Andrew Ward. I'm a software developer, an open source contributor, and a graduate from the University of Bristol. I’m a core developer for Minetest, an open source voxel game engine.

Comments

Leave comment

Shown publicly next to your comment. Leave blank to show as "Anonymous".
Optional, to notify you if rubenwardy replies. Not shown publicly.
Max 1800 characters. You may use plain text, HTML, or Markdown.