Add device.setLocation support to Android (#2783)

This commit is contained in:
Emilis Baliukonis
2021-06-15 17:45:49 +03:00
committed by GitHub
parent 2b8c6011a6
commit 576879a674
26 changed files with 238 additions and 60 deletions
+2
View File
@@ -120,6 +120,8 @@ dependencies {
}
implementation 'org.apache.commons:commons-lang3:3.7' // Needed by invoke. Warning: Upgrading to newer versions is not seamless.
implementation 'com.github.anrwatchdog:anrwatchdog:1.4.0'
implementation fileTree(dir: '../libs', includes: ['*.jar']) // Includes: Genymotion SDK
}
// Unit-testing deps.
@@ -0,0 +1,19 @@
package com.wix.detox.genymotion;
import android.content.Context;
import androidx.test.platform.app.InstrumentationRegistry;
import com.genymotion.api.GenymotionManager;
public class DetoxGenymotionManager {
public static void setLocation(double lat, double lon) {
GenymotionManager genymotion = getGenymotionManager();
genymotion.getGps().setLatitude(lat).setLongitude(lon);
}
private static GenymotionManager getGenymotionManager() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
return GenymotionManager.getGenymotionManager(context);
}
}
Binary file not shown.
+8 -1
View File
@@ -528,7 +528,14 @@ declare global {
setOrientation(orientation: Orientation): Promise<void>;
/**
* Note: setLocation is dependent on fbsimctl. if fbsimctl is not installed, the command will fail, it must be installed. Sets the simulator location to the given latitude and longitude.
* Sets the simulator/emulator location to the given latitude and longitude.
*
* <p/>On iOS `setLocation` is dependent on [fbsimctl](https://github.com/facebook/idb/tree/4b7929480c3c0f158f33f78a5b802c1d0e7030d2/fbsimctl)
* which [is now deprecated](https://github.com/wix/Detox/issues/1371).
* If `fbsimctl` is not installed, the command will fail, asking for it to be installed.
*
* <p/>On Android `setLocation` will work with both Android Emulator (bundled with Android development tools) and Genymotion.
* The correct permissions must be set in your app manifest.
*
* @example await device.setLocation(32.0853, 34.7818);
*/
@@ -0,0 +1,42 @@
/**
This code is generated.
For more information see generation/README.md.
*/
class DetoxGenymotionManager {
static setLocation(lat, lon) {
if (typeof lat !== "number") throw new Error("lat should be a number, but got " + (lat + (" (" + (typeof lat + ")"))));
if (typeof lon !== "number") throw new Error("lon should be a number, but got " + (lon + (" (" + (typeof lon + ")"))));
return {
target: {
type: "Class",
value: "com.wix.detox.genymotion.DetoxGenymotionManager"
},
method: "setLocation",
args: [{
type: "Double",
value: lat
}, {
type: "Double",
value: lon
}]
};
}
static getGenymotionManager() {
return {
target: {
type: "Class",
value: "com.wix.detox.genymotion.DetoxGenymotionManager"
},
method: "getGenymotionManager",
args: []
};
}
}
module.exports = DetoxGenymotionManager;
@@ -85,6 +85,10 @@ class EmulatorDriver extends AndroidDriver {
await this._emulatorLauncher.shutdown(deviceId);
}
async setLocation(deviceId, lat, lon) {
await this.adb.setLocation(deviceId, lat, lon);
}
async _fixAvdConfigIniSkinNameIfNeeded(avdName) {
const binaryVersion = _.get(await this.binaryVersion(), 'major');
if (!binaryVersion) {
@@ -115,6 +115,28 @@ class ADB {
await this.shell(deviceId, `am force-stop ${appId}`);
}
async setLocation(deviceId, lat, lon) {
// NOTE: QEMU for Android for the telnet part relies on C stdlib
// function `strtod` which is locale-sensitive, meaning that depending
// on user environment you'll have to send either comma-separated
// numbers or dot-separated ones.
//
// See: https://android.googlesource.com/platform/external/qemu/+/ae0eaf51751391abea2639a65200e724131dc3d6/android/console.c#2273
//
// As by default Node.js is distributed without ICU, the locale issue
// becomes tricky to solve across different platforms, that's why
// it's easier for us just to send 2 commands in a row, ignoring one
// which will obviosuly fail.
//
// Since `adb emu` commands fail silently, .catch() is not necessary.
const dot = `${lon} ${lat}`;
const comma = dot.replace(/\./g, ',');
await this.emu(deviceId, `geo fix ${dot}`);
await this.emu(deviceId, `geo fix ${comma}`);
}
async pidof(deviceId, bundleId) {
const bundleIdRegex = escape.inQuotedRegexp(bundleId) + '$';
@@ -268,6 +290,10 @@ class ADB {
return (await this.adbCmd(deviceId, `shell "${escape.inQuotedString(cmd)}"`, options)).stdout.trim();
}
async emu(deviceId, cmd, options) {
return (await this.adbCmd(deviceId, `emu "${escape.inQuotedString(cmd)}"`, options)).stdout.trim();
}
async reverse(deviceId, port) {
return this.adbCmd(deviceId, `reverse tcp:${port} tcp:${port}`);
}
@@ -101,6 +101,21 @@ describe('ADB', () => {
expect(exec).toHaveBeenCalledTimes(1);
});
it(`sets location both with commas and dots due to the locale issue`, async () => {
const lat = 30.5;
const lon = -70.5;
await adb.setLocation(deviceId, lat, lon);
expect(exec).toHaveBeenCalledWith(
expect.stringContaining(`-s mockEmulator emu "geo fix -70.5 30.5"`),
expect.anything());
expect(exec).toHaveBeenCalledWith(
expect.stringContaining(`-s mockEmulator emu "geo fix -70,5 30,5"`),
expect.anything());
});
it(`pidof (success)`, async () => {
jest.spyOn(adb, 'shell').mockImplementation(async () =>
`u0_a19 2199 1701 3554600 70264 0 0 s com.google.android.ext.services `);
@@ -1,6 +1,7 @@
const semver = require('semver');
const onSignalExit = require('signal-exit');
const DetoxGenymotionManager = require('../../../../android/espressoapi/DetoxGenymotionManager');
const DetoxRuntimeError = require('../../../../errors/DetoxRuntimeError');
const environment = require('../../../../utils/environment');
const logger = require('../../../../utils/logger').child({ __filename });
@@ -86,6 +87,10 @@ class GenyCloudDriver extends AndroidDriver {
await this._instanceLauncher.shutdown(instance);
}
async setLocation(instance, lat, lon) {
await this.invocationManager.execute(DetoxGenymotionManager.setLocation(parseFloat(lat), parseFloat(lon)));
}
_assertRecipe(deviceQuery, recipe) {
if (!recipe) {
throw new DetoxRuntimeError({
@@ -46,6 +46,8 @@ describe('Genymotion-cloud driver', () => {
let instanceLauncher;
let instanceAllocation;
let authServiceObj;
let detoxGenymotionManager;
beforeEach(() => {
signalExit = require('signal-exit');
@@ -89,6 +91,9 @@ describe('Genymotion-cloud driver', () => {
const AuthService = require('./services/GenyAuthService');
authServiceObj = () => latestInstanceOf(AuthService);
jest.mock('../../../../android/espressoapi/DetoxGenymotionManager');
detoxGenymotionManager = require('../../../../android/espressoapi/DetoxGenymotionManager');
});
let instanceLifecycleService;
@@ -310,6 +315,20 @@ describe('Genymotion-cloud driver', () => {
expect(instanceLauncher().shutdown).toHaveBeenCalledWith(instance);
});
});
describe('setLocation', () => {
it('should invoke `DetoxGenymotionManager.setLocation` with specified coordinates', async () => {
const invocation = {
method: 'setLocation'
};
detoxGenymotionManager.setLocation.mockReturnValue(invocation);
const instance = anInstance();
await uut.setLocation(instance, '40.5', '55.5');
expect(invocationManager.execute).toHaveBeenCalledWith(invocation);
expect(detoxGenymotionManager.setLocation).toHaveBeenCalledWith(40.5, 55.5);
});
});
});
describe('class scope', () => {
+2 -2
View File
@@ -102,10 +102,10 @@ dependencies {
BINTRAY_SUNSET_HACK end
*/
// noinspection GradleDynamicVersion
implementation project(path: ':AsyncStorage')
implementation project(':AsyncStorage')
implementation project(':react-native-webview')
implementation project(':react-native-community-checkbox')
implementation project(':react-native-community-geolocation')
androidTestImplementation(project(path: ':detox'))
androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.2.1'
@@ -4,6 +4,7 @@ import android.app.Application;
import com.facebook.react.ReactApplication;
import com.reactnativecommunity.checkbox.ReactCheckBoxPackage;
import com.reactnativecommunity.geolocation.GeolocationPackage;
import com.reactnativecommunity.webview.RNCWebViewPackage;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
@@ -26,6 +27,7 @@ public class MainApplication extends Application implements ReactApplication {
protected List<ReactPackage> getPackages() {
return Arrays.asList(
new MainReactPackage(),
new GeolocationPackage(),
new RNCWebViewPackage(),
new NativeModulePackage(),
new AsyncStoragePackage(),
+3
View File
@@ -15,3 +15,6 @@ project(':react-native-webview').projectDir = new File(rootProject.projectDir, '
include ':react-native-community-checkbox'
project(':react-native-community-checkbox').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/checkbox/android')
include ':react-native-community-geolocation'
project(':react-native-community-geolocation').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/geolocation/android')
+15 -8
View File
@@ -11,8 +11,12 @@ async function isFbsimctlInstalled() {
}
}
describe(':ios: location', () => {
it('Location should be unavailable', async () => {
describe('location', () => {
const lat = -80.125;
const lon = 66.5;
// Skipped on Android because there is no Android permissions support yet
it(':ios: Location should be unavailable', async () => {
if (!await isFbsimctlInstalled()) {
return;
}
@@ -22,17 +26,20 @@ describe(':ios: location', () => {
await expect(element(by.id('error'))).toBeVisible();
});
it('Should receive location (20,20)', async () => {
if (!await isFbsimctlInstalled()) {
it('Should accept a location', async () => {
const isIOS = device.getPlatform() === 'ios';
if (isIOS && !await isFbsimctlInstalled()) {
return;
}
await device.relaunchApp({ permissions: { location: 'always' } });
await device.setLocation(20.1, 20.2);
await device.setLocation(lat, lon);
await element(by.text('Location')).tap();
await element(by.id('getLocationButton')).tap();
await waitFor(element(by.text('Latitude: 20.1'))).toBeVisible().withTimeout(3000);
await waitFor(element(by.text(`Latitude: ${lat}`))).toBeVisible().withTimeout(5000);
await expect(element(by.text('Latitude: 20.1'))).toBeVisible();
await expect(element(by.text('Longitude: 20.2'))).toBeVisible();
await expect(element(by.text(`Latitude: ${lat}`))).toBeVisible();
await expect(element(by.text(`Longitude: ${lon}`))).toBeVisible();
});
});
+1
View File
@@ -30,6 +30,7 @@
"dependencies": {
"@react-native-community/async-storage": "^1.12.0",
"@react-native-community/checkbox": "^0.5.7",
"@react-native-community/geolocation": "^2.0.2",
"moment": "^2.24.0",
"react": "16.11.x",
"react-native": "0.62.x",
+63 -38
View File
@@ -4,64 +4,89 @@ import {
View,
Button
} from 'react-native';
import Geolocation from '@react-native-community/geolocation';
function Frame({ children }) {
return (
<View style={{ flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center' }}>
{children}
</View>
);
}
function Label({ testID, children }) {
return (
<Text testID={testID} style={{ marginBottom: 20 }}>
{children}
</Text>
);
}
export default class LocationScreen extends Component {
constructor(props) {
super(props);
this.state = {
locationRequested: false,
coordinates: null
coordinates: null,
error: '',
}
this.getLocation();
}
async getLocation() {
function success(pos) {
this.setState({
coordinates: pos.coords
});
};
onLocationReceived = (pos) => {
this.setState({
coordinates: pos.coords,
error: '',
});
};
function error(err) {
this.setState({
coordinates: null
});
};
onLocationError = (error) => {
this.setState({
coordinates: null,
error: error.message,
});
};
await navigator.geolocation.getCurrentPosition(success.bind(this), error.bind(this));
requestLocation = async () => {
this.setState({ locationRequested: true });
await Geolocation.getCurrentPosition(this.onLocationReceived, this.onLocationError, {
enableHighAccuracy: true,
timeout: 4500,
maximumAge: 0,
});
}
render() {
if(!this.state.locationRequested) {
if (!this.state.locationRequested) {
return (
<View style={{ flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center' }}>
<Button
testID="getLocationButton"
title="get location"
onPress={async () => {
await this.getLocation();
this.setState({locationRequested: true});
}}
/>
</View>
<Frame>
<Button testID="getLocationButton" title="Get location" onPress={this.requestLocation} />
</Frame>
);
}
if (this.state.coordinates) {
return (
<View style={{ flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center' }}>
<Text testID="latitude" style={{ marginBottom: 20 }}>Latitude: {this.state.coordinates.latitude}</Text>
<Text testID="longitude" style={{ marginBottom: 20 }}>Longitude: {this.state.coordinates.longitude}</Text>
</View>
);
} else {
return (
<View style={{ flex: 1, paddingTop: 20, justifyContent: 'center', alignItems: 'center' }}>
<Text testID="error" style={{ marginBottom: 20 }}>Location unavailable</Text>
</View>
<Frame>
<Label testID="latitude">Latitude: {this.state.coordinates.latitude}</Label>
<Label testID="longitude">Longitude: {this.state.coordinates.longitude}</Label>
</Frame>
);
}
if (this.state.error) {
return (
<Frame>
<Label testID="error">{this.state.error}</Label>
</Frame>
);
}
return (
<Frame>
<Label testID="loading">Locating...</Label>
</Frame>
);
}
}
}
+9 -8
View File
@@ -38,19 +38,18 @@ device.appLaunchArgs.reset();
This is the most flexible way of editing the launch arguments. Refer to the [launch arguments guide](APIRef.LaunchArgs.md) for complete details.
## Methods
- [`device.selectApp(name)`](#deviceselectappname)
- [`device.launchApp()`](#devicelaunchappparams)
- [`device.launchApp(params)`](#devicelaunchappparams)
- [`device.terminateApp()`](#deviceterminateapp)
- [`device.sendToHome()`](#devicesendtohome)
- [`device.reloadReactNative()`](#devicereloadreactnative)
- [`device.installApp()`](#deviceinstallapp)
- [`device.uninstallApp()`](#deviceuninstallapp)
- [`device.openURL(url)`](#deviceopenurlurl-sourceappoptional)
- [`device.openURL({url, sourceApp[optional]})`](#deviceopenurlurl-sourceappoptional)
- [`device.sendUserNotification(params)`](#devicesendusernotificationparams)
- [`device.sendUserActivity(params)` **iOS Only**](#devicesenduseractivityparams-ios-only)
- [`device.setOrientation(orientation)`](#devicesetorientationorientation)
- [`device.setLocation(lat, lon)` **iOS Only**](#devicesetlocationlat-lon-ios-only)
- [`device.setLocation(lat, lon)`](#devicesetlocationlat-lon)
- [`device.enableSynchronization()`](#deviceenablesynchronization)
- [`device.disableSynchronization()`](#devicedisablesynchronization)
- [`device.setURLBlacklist([urls])`](#deviceseturlblacklisturls)
@@ -69,7 +68,7 @@ This is the most flexible way of editing the launch arguments. Refer to the [lau
- [`device.reverseTcpPort()` **Android Only**](#devicereversetcpport-android-only)
- [`device.unreverseTcpPort()` **Android Only**](#deviceunreversetcpport-android-only)
- [`device.pressBack()` **Android Only**](#devicepressback-android-only)
- [`device.getUIDevice()` **Android Only**](#devicegetuidevice-android-only)
- [`device.getUiDevice()` **Android Only**](#devicegetuidevice-android-only)
### `device.selectApp(name)`
@@ -317,11 +316,13 @@ Takes `"portrait"` or `"landscape"` and rotates the device to the given orientat
Check out Detox's [own test suite.](../detox/test/e2e/06.device-orientation.test.js)
### `device.setLocation(lat, lon)` **iOS Only**
### `device.setLocation(lat, lon)`
> Note: `setLocation` is dependent on [fbsimctl](https://github.com/facebook/idb/tree/4b7929480c3c0f158f33f78a5b802c1d0e7030d2/fbsimctl) which [is now deprecated](https://github.com/wix/Detox/issues/1371). If `fbsimctl` is not installed, the command will fail, asking for it to be installed.
Sets the simulator/emulator location to the given latitude and longitude.
Sets the simulator location to the given latitude and longitude.
> On iOS `setLocation` is dependent on [fbsimctl](https://github.com/facebook/idb/tree/4b7929480c3c0f158f33f78a5b802c1d0e7030d2/fbsimctl) which [is now deprecated](https://github.com/wix/Detox/issues/1371). If `fbsimctl` is not installed, the command will fail, asking for it to be installed.
>
> On Android `setLocation` will work with both Android Emulator (bundled with Android development tools) and Genymotion. The correct permissions must be set in your app manifest.
```js
await device.setLocation(32.0853, 34.7818);
@@ -83,11 +83,10 @@ dependencies {
BINTRAY_SUNSET_HACK end
*/
// noinspection GradleDynamicVersion
implementation(project(path: ':AsyncStorage')) // Note: not required unless effectively used by your app
// noinspection GradleDynamicVersion
androidTestImplementation('com.wix:detox:+')
androidTestImplementation 'com.wix:detox:+'
androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.2.1'
}
+2 -1
View File
@@ -49,7 +49,8 @@ function createAndroidFiles() {
'../detox/android/detox/src/full/java/com/wix/detox/espresso/web/DetoxWebAtomMatcher.java': '../detox/src/android/espressoapi/web/DetoxWebAtomMatcher.js',
'../detox/android/detox/src/full/java/com/wix/detox/espresso/web/WebViewElement.java': '../detox/src/android/espressoapi/web/WebViewElement.js',
'../detox/android/detox/src/full/java/com/wix/detox/espresso/web/WebElement.java': '../detox/src/android/espressoapi/web/WebElement.js',
'../detox/android/detox/src/full/java/com/wix/detox/espresso/web/WebExpect.java': '../detox/src/android/espressoapi/web/WebExpect.js'
'../detox/android/detox/src/full/java/com/wix/detox/espresso/web/WebExpect.java': '../detox/src/android/espressoapi/web/WebExpect.js',
'../detox/android/detox/src/full/java/com/wix/detox/genymotion/DetoxGenymotionManager.java': '../detox/src/android/espressoapi/DetoxGenymotionManager.js'
};
}