diff --git a/src/main/java/com/musala/atmosphere/agent/devicewrapper/AbstractWrapDevice.java b/src/main/java/com/musala/atmosphere/agent/devicewrapper/AbstractWrapDevice.java index 0954d93..ca5f04b 100755 --- a/src/main/java/com/musala/atmosphere/agent/devicewrapper/AbstractWrapDevice.java +++ b/src/main/java/com/musala/atmosphere/agent/devicewrapper/AbstractWrapDevice.java @@ -48,7 +48,9 @@ import com.musala.atmosphere.agent.devicewrapper.util.ondevicecomponent.ServiceCommunicator; import com.musala.atmosphere.agent.devicewrapper.util.ondevicecomponent.UIAutomatorCommunicator; import com.musala.atmosphere.agent.entity.DeviceSettingsEntity; +import com.musala.atmosphere.agent.entity.EntityTypeResolver; import com.musala.atmosphere.agent.entity.GestureEntity; +import com.musala.atmosphere.agent.entity.GpsLocationEntity; import com.musala.atmosphere.agent.entity.HardwareButtonEntity; import com.musala.atmosphere.agent.entity.ImageEntity; import com.musala.atmosphere.agent.entity.ImeEntity; @@ -176,6 +178,8 @@ public abstract class AbstractWrapDevice extends UnicastRemoteObject implements private ImageEntity imageEntity; + private GpsLocationEntity gpsLocationEntity; + /** * Creates an abstract wrapper of the given {@link IDevice device}. * @@ -451,10 +455,16 @@ public Object route(RoutingAction action, Object... args) throws RemoteException returnValue = serviceCommunicator.isLocked(); break; case OPEN_LOCATION_SETTINGS: - serviceCommunicator.openLocationSettings(); + gpsLocationEntity.openLocationSettings(); break; case IS_GPS_LOCATION_ENABLED: - returnValue = serviceCommunicator.isGpsLocationEnabled(); + returnValue = gpsLocationEntity.isGpsLocationEnabled(); + break; + case ENABLE_GPS_LOCATION: + returnValue = gpsLocationEntity.enableGpsLocation(); + break; + case DISABLE_GPS_LOCATION: + returnValue = gpsLocationEntity.disableGpsLocation(); break; case SHOW_TAP_LOCATION: serviceCommunicator.showTapLocation(args); @@ -765,6 +775,8 @@ public DeviceInformation getDeviceInformation() { } private void setupDeviceEntities(DeviceInformation deviceInformation) { + EntityTypeResolver typeResolver = new EntityTypeResolver(deviceInformation); + try { Constructor hardwareButtonEntityConstructor = HardwareButtonEntity.class.getDeclaredConstructor(ShellCommandExecutor.class); hardwareButtonEntityConstructor.setAccessible(true); @@ -805,6 +817,20 @@ private void setupDeviceEntities(DeviceInformation deviceInformation) { wrappedDevice }); this.imageEntity = imageEntity; + + Class locationEntityClass = typeResolver.getEntityClass(GpsLocationEntity.class); + Constructor locationEntityConstructor = locationEntityClass.getDeclaredConstructor(ServiceCommunicator.class, + UIAutomatorCommunicator.class, + HardwareButtonEntity.class, + GestureEntity.class); + locationEntityConstructor.setAccessible(true); + GpsLocationEntity locationEntity = ((GpsLocationEntity) locationEntityConstructor.newInstance(new Object[] { + serviceCommunicator, + automatorCommunicator, + hardwareButtonEntity, + gestureEntity + })); + this.gpsLocationEntity = locationEntity; } catch (NoSuchMethodException | SecurityException | InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException e) { throw new UnresolvedEntityTypeException("Failed to find the correct set of entities implementations matching the given device information.", diff --git a/src/main/java/com/musala/atmosphere/agent/entity/EntityTypeResolver.java b/src/main/java/com/musala/atmosphere/agent/entity/EntityTypeResolver.java new file mode 100644 index 0000000..e9da4be --- /dev/null +++ b/src/main/java/com/musala/atmosphere/agent/entity/EntityTypeResolver.java @@ -0,0 +1,93 @@ +package com.musala.atmosphere.agent.entity; + +import java.lang.annotation.Annotation; +import java.util.Set; + +import org.reflections.Reflections; + +import com.musala.atmosphere.agent.entity.annotations.Restriction; +import com.musala.atmosphere.commons.DeviceInformation; + +/** + * Class responsible for resolving the correct implementation of the entities, defined for all device specific + * operations, depending on the provided {@link DeviceInformation}. + * + * @author filareta.yordanova + * + */ +public class EntityTypeResolver { + private static final String ENTITIES_PACKAGE = "com.musala.atmosphere.agent.entity"; + + private DeviceInformation deviceInformation; + + private Reflections reflections; + + public EntityTypeResolver(DeviceInformation information) { + this.deviceInformation = information; + reflections = new Reflections(ENTITIES_PACKAGE); + } + + /** + * Finds entity implementation for a device specific operation depending on the {@link DeviceInformation device + * information} and the hierarchy type given. + * + * @param baseEntityClass + * - base class of the entity hierarchy for a device specific operation + * @return {@link Class} of the entity that matches the required {@link DeviceInformation device information} and is + * from type baseEntityClass + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public Class getEntityClass(Class baseEntityClass) { + Set> subClasses = reflections.getSubTypesOf(baseEntityClass); + Class defaultImplementation = null; + + for (Class subClass : subClasses) { + Annotation annotation = subClass.getAnnotation(Restriction.class); + if (annotation == null) { + defaultImplementation = subClass; + } else if (isApplicable((Restriction) annotation)) { + return subClass; + } + } + + return defaultImplementation; + + } + + /** + * Checks if a certain implementation annotated with {@link @Restriction} is applicable for a device with the + * provided {@link DeviceInformation information}. + * + * @param restriction + * - restrictions provided for a certain entity implementation + * @return true if the given restrictions are compatible with the {@link DeviceInformation information} + * for the current device, false otherwise + */ + // TODO: Check for default values in the annotation methods, if the parameter has default value and is not present + // in the annotation it is not considered when checking for applicability. + private boolean isApplicable(Restriction restriction) { + String manufacturer = restriction.manufacturer(); + + if (!manufacturer.equals(DeviceInformation.FALLBACK_MANUFACTURER_NAME) + && !manufacturer.equalsIgnoreCase(deviceInformation.getManufacturer())) { + return false; + } + + boolean isApplicable = true; + int[] apiLevels = restriction.apiLevel(); + + if (apiLevels.length > 0) { + int deviceApiLevel = deviceInformation.getApiLevel(); + isApplicable = false; + + for (int applicableApiLevel : apiLevels) { + if (applicableApiLevel == deviceApiLevel) { + isApplicable = true; + break; + } + } + } + + return isApplicable; + } +} diff --git a/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationCheckBoxEntity.java b/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationCheckBoxEntity.java new file mode 100644 index 0000000..82c90f2 --- /dev/null +++ b/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationCheckBoxEntity.java @@ -0,0 +1,49 @@ +package com.musala.atmosphere.agent.entity; + +import java.util.List; + +import com.musala.atmosphere.agent.devicewrapper.util.ondevicecomponent.ServiceCommunicator; +import com.musala.atmosphere.agent.devicewrapper.util.ondevicecomponent.UIAutomatorCommunicator; +import com.musala.atmosphere.agent.entity.annotations.Restriction; +import com.musala.atmosphere.commons.exceptions.CommandFailedException; +import com.musala.atmosphere.commons.exceptions.UiElementFetchingException; +import com.musala.atmosphere.commons.ui.UiElementPropertiesContainer; +import com.musala.atmosphere.commons.ui.selector.CssAttribute; +import com.musala.atmosphere.commons.ui.selector.UiElementSelector; +import com.musala.atmosphere.commons.ui.tree.AccessibilityElement; + +/** + * {@link GpsLocationEntity} responsible for setting the GPS location state on all Samsung devices. + * + * @author yavor.stankov + * + */ +@Restriction(apiLevel = {17, 18}) +public class GpsLocationCheckBoxEntity extends GpsLocationEntity { + private static final String ANDROID_WIDGET_CHECK_BOX_CLASS_NAME = "android.widget.CheckBox"; + + GpsLocationCheckBoxEntity(ServiceCommunicator serviceCommunicator, + UIAutomatorCommunicator automatorCommunicator, + HardwareButtonEntity hardwareButtonEntity, + GestureEntity gestureEntity) { + super(serviceCommunicator, automatorCommunicator, hardwareButtonEntity, gestureEntity); + } + + @Override + protected UiElementPropertiesContainer getChangeStateWidget() throws UiElementFetchingException, CommandFailedException { + UiElementSelector checkBoxWidgetSelector = new UiElementSelector(); + checkBoxWidgetSelector.addSelectionAttribute(CssAttribute.CLASS_NAME, ANDROID_WIDGET_CHECK_BOX_CLASS_NAME); + + automatorCommunicator.waitForExists(checkBoxWidgetSelector, CHANGE_STATE_WIDGET_TIMEOUT); + + List widgetList = automatorCommunicator.getUiElements(checkBoxWidgetSelector, true); + + if (!widgetList.isEmpty()) { + // There are more than one check box on the screen, but only the first one is for setting the GPS location + // state. + return widgetList.get(0); + } + + return null; + } +} diff --git a/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationEntity.java b/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationEntity.java new file mode 100644 index 0000000..e1cb3dc --- /dev/null +++ b/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationEntity.java @@ -0,0 +1,165 @@ +package com.musala.atmosphere.agent.entity; + +import java.util.List; + +import org.apache.log4j.Logger; + +import com.musala.atmosphere.agent.devicewrapper.util.ondevicecomponent.ServiceCommunicator; +import com.musala.atmosphere.agent.devicewrapper.util.ondevicecomponent.UIAutomatorCommunicator; +import com.musala.atmosphere.commons.exceptions.CommandFailedException; +import com.musala.atmosphere.commons.exceptions.UiElementFetchingException; +import com.musala.atmosphere.commons.geometry.Bounds; +import com.musala.atmosphere.commons.geometry.Point; +import com.musala.atmosphere.commons.ui.UiElementPropertiesContainer; +import com.musala.atmosphere.commons.ui.selector.CssAttribute; +import com.musala.atmosphere.commons.ui.selector.UiElementSelector; +import com.musala.atmosphere.commons.ui.tree.AccessibilityElement; + +/** + * Base entity responsible for handling GPS location state changing. + * + * @author yavor.stankov + * + */ +public abstract class GpsLocationEntity { + private static final Logger LOGGER = Logger.getLogger(GpsLocationEntity.class.getCanonicalName()); + + private static final String AGREE_BUTTON_RESOURCE_ID = "android:id/button1"; + + private static final int AGREE_BUTTON_TIMEOUT = 3000; + + protected static final int CHANGE_STATE_WIDGET_TIMEOUT = 5000; + + private static final int UI_ELEMENT_OPERATION_WAIT_TIME = 500; + + protected ServiceCommunicator serviceCommunicator; + + protected UIAutomatorCommunicator automatorCommunicator; + + protected HardwareButtonEntity hardwareButtonEntity; + + protected GestureEntity gestureEntity; + + GpsLocationEntity(ServiceCommunicator serviceCommunicator, + UIAutomatorCommunicator automatorCommunicator, + HardwareButtonEntity hardwareButtonEntity, + GestureEntity gestureEntity) { + this.serviceCommunicator = serviceCommunicator; + this.automatorCommunicator = automatorCommunicator; + this.hardwareButtonEntity = hardwareButtonEntity; + this.gestureEntity = gestureEntity; + } + + /** + * Gets the right widget that is responsible for setting the GPS location state. + * + * @return the widget that should be used for setting the GPS location state. + * @throws UiElementFetchingException + * if the required widget is not present on the screen + * @throws MultipleElementsFoundException + * if there are more than one widgets present on the screen + */ + protected abstract UiElementPropertiesContainer getChangeStateWidget() + throws UiElementFetchingException, CommandFailedException; + + /** + * Enables the GPS location on this device. + * + * @return true if the GPS location enabling is successful, false if it fails + * @throws CommandFailedException + */ + public boolean enableGpsLocation() throws CommandFailedException { + return setGpsLocationState(true); + } + + /** + * Disables the GPS location on this device. + * + * @return true if the GPS location disabling is successful, false if it fails + * @throws CommandFailedException + */ + public boolean disableGpsLocation() throws CommandFailedException { + return setGpsLocationState(false); + } + + /** + * Check if the GPS location is enabled on this device. + * + * @return true if the GPS location is enabled, false if it's disabled + * @throws CommandFailedException + */ + public boolean isGpsLocationEnabled() throws CommandFailedException { + return serviceCommunicator.isGpsLocationEnabled(); + } + + private boolean setGpsLocationState(boolean state) throws CommandFailedException { + if (isGpsLocationEnabled() == state) { + return true; + } + + openLocationSettings(); + + try { + UiElementPropertiesContainer changeStateWidget = getChangeStateWidget(); + if (tap(changeStateWidget)) { + pressAgreeButton(); + } + } catch (UiElementFetchingException e) { + LOGGER.error("Failed to get the wanted widget, or there are more than one widgets on the screen that are matching the given selector.", + e); + return false; + } + + // TODO: If needed, move the HardwareButton enumeration from com.musala.atmosphere.client.device to atmosphere-commons, + // so the enumeration could be used here instead of an integer. + hardwareButtonEntity.pressButton(4); // Back button + + return true; + } + + public void openLocationSettings() throws CommandFailedException { + serviceCommunicator.openLocationSettings(); + } + + private void pressAgreeButton() throws UiElementFetchingException, CommandFailedException { + UiElementSelector agreeButtonSelector = new UiElementSelector(); + agreeButtonSelector.addSelectionAttribute(CssAttribute.RESOURCE_ID, AGREE_BUTTON_RESOURCE_ID); + + if (automatorCommunicator.waitForExists(agreeButtonSelector, AGREE_BUTTON_TIMEOUT)) { + List elementsList = automatorCommunicator.getUiElements(agreeButtonSelector, true); + + AccessibilityElement agreeButton = elementsList.get(0); + tap(agreeButton); + } + } + + private boolean tap(UiElementPropertiesContainer element) { + Bounds elementBounds = element.getBounds(); + Point centerPoint = elementBounds.getCenter(); + Point point = elementBounds.getRelativePoint(centerPoint); + Point tapPoint = elementBounds.getUpperLeftCorner(); + tapPoint.addVector(point); + + boolean tapSuccessful = false; + if (elementBounds.contains(tapPoint)) { + tapSuccessful = gestureEntity.tapScreenLocation(tapPoint); + finalizeUiElementOperation(); + } else { + String message = String.format("Point %s not in element bounds.", point.toString()); + LOGGER.error(message); + throw new IllegalArgumentException(message); + } + + return tapSuccessful; + } + + private void finalizeUiElementOperation() { + // Should be invoked exactly once in the end of all element-operating + // methods, whether its directly or indirectly invoked. + try { + Thread.sleep(UI_ELEMENT_OPERATION_WAIT_TIME); + } catch (InterruptedException e) { + LOGGER.info(e); + } + } +} diff --git a/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationSwitchViewEntity.java b/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationSwitchViewEntity.java new file mode 100644 index 0000000..3384916 --- /dev/null +++ b/src/main/java/com/musala/atmosphere/agent/entity/GpsLocationSwitchViewEntity.java @@ -0,0 +1,49 @@ +package com.musala.atmosphere.agent.entity; + +import java.util.List; + +import com.musala.atmosphere.agent.devicewrapper.util.ondevicecomponent.ServiceCommunicator; +import com.musala.atmosphere.agent.devicewrapper.util.ondevicecomponent.UIAutomatorCommunicator; +import com.musala.atmosphere.agent.entity.annotations.Default; +import com.musala.atmosphere.commons.exceptions.CommandFailedException; +import com.musala.atmosphere.commons.exceptions.UiElementFetchingException; +import com.musala.atmosphere.commons.ui.UiElementPropertiesContainer; +import com.musala.atmosphere.commons.ui.selector.CssAttribute; +import com.musala.atmosphere.commons.ui.selector.UiElementSelector; +import com.musala.atmosphere.commons.ui.tree.AccessibilityElement; + +/** + * {@link GpsLocationEntity} responsible for all devices that are using switch widgets for setting the GPS location + * state. + * + * @author yavor.stankov + * + */ +@Default +public class GpsLocationSwitchViewEntity extends GpsLocationEntity { + private static final String ANDROID_WIDGET_SWITCH_CLASS_NAME = "android.widget.Switch"; + + GpsLocationSwitchViewEntity(ServiceCommunicator serviceCommunicator, + UIAutomatorCommunicator automatorCommunicator, + HardwareButtonEntity hardwareButtonEntity, + GestureEntity gestureEntity) { + super(serviceCommunicator, automatorCommunicator, hardwareButtonEntity, gestureEntity); + } + + @Override + protected UiElementPropertiesContainer getChangeStateWidget() throws UiElementFetchingException, CommandFailedException { + + UiElementSelector switchWidgetSelector = new UiElementSelector(); + switchWidgetSelector.addSelectionAttribute(CssAttribute.CLASS_NAME, ANDROID_WIDGET_SWITCH_CLASS_NAME); + + automatorCommunicator.waitForExists(switchWidgetSelector, CHANGE_STATE_WIDGET_TIMEOUT); + + List widgetList = automatorCommunicator.getUiElements(switchWidgetSelector, true); + + if (!widgetList.isEmpty()) { + return widgetList.get(0); + } + + return null; + } +} diff --git a/src/test/java/com/musala/atmosphere/agent/entity/EntityTypeResolverTest.java b/src/test/java/com/musala/atmosphere/agent/entity/EntityTypeResolverTest.java new file mode 100644 index 0000000..c1e833c --- /dev/null +++ b/src/test/java/com/musala/atmosphere/agent/entity/EntityTypeResolverTest.java @@ -0,0 +1,40 @@ +package com.musala.atmosphere.agent.entity; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import com.musala.atmosphere.commons.DeviceInformation; + +/** + * Tests {@link EntityTypeResolver}. + * + * @author filareta.yordanova + * + */ +// TODO: Add more cases when complex criteria are available, e.g more fields are added to @Restriction. +public class EntityTypeResolverTest { + private static final String ERROR_MESSAGE = "Returned entity instance is not from expected type."; + + private EntityTypeResolver entityFactory; + + @Test + public void testGetGpsLocationEntityForApiLevel() { + DeviceInformation requiredInformation = new DeviceInformation(); + requiredInformation.setApiLevel(17); + entityFactory = new EntityTypeResolver(requiredInformation); + + Class entityClass = entityFactory.getEntityClass(GpsLocationEntity.class); + assertEquals(ERROR_MESSAGE, entityClass, GpsLocationCheckBoxEntity.class); + } + + @Test + public void testGetGpsLocationEntityWhenNoMatchFound() { + DeviceInformation requiredInformation = new DeviceInformation(); + requiredInformation.setApiLevel(21); + entityFactory = new EntityTypeResolver(requiredInformation); + + Class entityClass = entityFactory.getEntityClass(GpsLocationEntity.class); + assertEquals(ERROR_MESSAGE, entityClass, GpsLocationSwitchViewEntity.class); + } +}