/* MonkeyTalk - a cross-platform functional testing tool
Copyright (C) 2012 Gorilla Logic, Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. */
package com.gorillalogic.fonemonkey.automators;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import android.graphics.Rect;
import android.view.View;
import com.gorillalogic.fonemonkey.Log;
import com.gorillalogic.fonemonkey.PropertyUtil;
import com.gorillalogic.fonemonkey.Recorder;
import com.gorillalogic.fonemonkey.exceptions.FoneMonkeyScriptFailure;
import com.gorillalogic.monkeytalk.automators.AutomatorConstants;
/**
* @author sstern
*
*/
public abstract class AutomatorBase implements IAutomator {
public static final String PROP_VALUE = "value";
private String monkeyID = "";
private Object component = null;
protected static Pattern onListener = Pattern.compile(".*On(.*)Listener");
@Override
public boolean isHtmlAutomator() {
return false;
}
@Override
public void record(String operation, String... args) {
Recorder.record(operation, this, args);
}
@Override
public void record(String action, Object[] args) {
String[] sargs = null;
if (args != null && args.length > 0) {
sargs = new String[args.length - 1];
for (int i = 1; i < args.length; i++) {
sargs[i - 1] = args[i].toString();
}
}
record(action, sargs);
}
@Override
// By default, we don't hide anybody
public boolean hides(View view) {
return false;
}
@Override
public Class<?> getComponentClass() {
return null;
}
@Override
public void setMonkeyID(String id) {
monkeyID = id;
}
@Override
public String getMonkeyID() {
return monkeyID;
}
@Override
public abstract String getComponentType();
@Override
public Object getComponent() {
return component;
}
@Override
public void setComponent(Object o) {
component = o;
}
@Override
public String play(String action, String... args) {
//If not valid Verify action, throw error.
if(action.toLowerCase().startsWith(AutomatorConstants.ACTION_VERIFY.toLowerCase())
&& !isValidVerifyAction(action)) {
throw new IllegalArgumentException("Verify action \"" + action
+ "\" not valid for component type \"" + getComponentType() + "\"");
} else if (action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY_IMAGE)) {
return playVerifyImage();
} else if (action.toLowerCase().startsWith(AutomatorConstants.ACTION_VERIFY.toLowerCase())
|| action.equalsIgnoreCase(AutomatorConstants.ACTION_GET)) {
if (args.length == 0) {
if (action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY_NOT)) {
throw new FoneMonkeyScriptFailure(action + " " + getComponentType() + " "
+ getMonkeyID() + " unexpectedly found after timeout.");
} else if (action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY)) {
return "";
}
}
String vtype;
if (action.toLowerCase().endsWith("regex")) {
vtype = "regex";
} else if (action.toLowerCase().endsWith("wildcard")) {
vtype = "wildcard";
} else {
vtype = "exact";
}
if (args.length == 0 && !(action.equalsIgnoreCase(AutomatorConstants.ACTION_GET))) {
throw new IllegalArgumentException("Missing required argument specifying " + vtype
+ " value for verification");
}
String expectedValue = args[0];
String actualValue;
String propertyPath;
if (args.length == 1 || args.length > 1 && args[1].trim().length() == 0) {
propertyPath = PROP_VALUE;
actualValue = getValue();
} else {
propertyPath = args[1];
if (propertyPath.startsWith(".")) {
// Native property
actualValue = getValue(propertyPath.trim().substring(1));
} else if (propertyPath.equals(PROP_VALUE)) {
actualValue = getValue();
} else if (isArray(propertyPath)) {
actualValue = getArrayItem(propertyPath);
} else {
try {
actualValue = getProperty(propertyPath);
} catch (Exception e) {
// No built-in property of the specified name. See if there's a native one.
String path = propertyPath.startsWith(".") ? propertyPath.substring(1)
: propertyPath;
actualValue = getValue(path);
}
}
}
String fmsg = (args.length == 3) ? ": " + args[2] : "";
if (action.equalsIgnoreCase(AutomatorConstants.ACTION_GET)) {
return actualValue;
}
boolean not = action.toLowerCase().startsWith(
AutomatorConstants.ACTION_VERIFY_NOT.toLowerCase());
if (vtype.equals("regex")
&& actualValue.matches(expectedValue)
|| (vtype.equals("wildcard") && PropertyUtil.wildcardMatch(expectedValue,
actualValue))
|| (vtype.equals("exact") && expectedValue.equals(actualValue))) {
if (not) {
fmsg = getComponentType() + " " + getMonkeyID() + " " + action + " "
+ propertyPath + " : actualValue \"" + actualValue
+ "\" should not match \"" + expectedValue + "\"" + fmsg;
throw new FoneMonkeyScriptFailure(fmsg);
} else {
return "";
}
} else if (not) {
return "";
}
fmsg = getComponentType() + " " + getMonkeyID() + " " + action + " " + propertyPath
+ ": Expected \"" + expectedValue + "\" but found \"" + actualValue + "\""
+ fmsg;
;
throw new FoneMonkeyScriptFailure(fmsg);
}
throw new IllegalArgumentException("Action \"" + action
+ "\" not valid for component type \"" + getComponentType() + "\"");
}
protected String getProperty(String propertyPath) {
throw new IllegalArgumentException("Unrecognized property \"" + propertyPath + "\" for "
+ getComponentType());
}
private static DecimalFormat dec2 = new DecimalFormat("0.0");
/**
* @return the component's current value for the supplied propertyPath expression.
*/
public String getValue(String propertyPath) {
if (propertyPath.equals(PROP_VALUE)) {
return getValue();
}
String capitalized = Character.toUpperCase(propertyPath.charAt(0))
+ propertyPath.substring(1);
String getter = "get" + capitalized;
String iser = "is" + capitalized;
Method[] m = getComponent().getClass().getMethods();
for (int i = 0; i < m.length; i++) {
if (m[i].getName().equals(getter) && m[i].getParameterTypes().length == 0) {
try {
Object result = m[i].invoke(getComponent(), (Object[]) null);
if (result instanceof Number) {
Double d = ((Number) result).doubleValue();
return dec2.format(d);
}
return result == null ? "" : result.toString();
} catch (Exception e) {
throw new RuntimeException("Unexpected error executing " + getter);
}
}
if (m[i].getName().equals(iser) && m[i].getParameterTypes().length == 0) {
String result = null;
try {
result = ((Boolean) m[i].invoke(getComponent(), (Object[]) null)).toString();
return result.toString();
} catch (Exception e) {
throw new RuntimeException("Unexpected error executing " + iser);
}
}
}
throw new RuntimeException("Unable to find property \"" + propertyPath + "\" for "
+ getComponentType());
}
/**
* @return true if this automator handles this type or some subtype of this type
*/
public boolean forSubtypeOf(String type) {
if (type.equals(getComponentType())
|| (getAliases() != null && Arrays.asList(getAliases()).contains(type))) {
return true;
}
Class<?> sup = this.getClass().getSuperclass();
while (sup != AutomatorBase.class && IAutomator.class.isAssignableFrom(sup)) {
try {
if (((IAutomator) sup.newInstance()).getComponentType().equals(type)) {
return true;
}
} catch (Exception e) {
Log.log(e);
return false;
}
sup = sup.getSuperclass();
}
return false;
}
public String[] getAliases() {
return null;
}
public String getValue() {
return null;
}
/**
* Chains listeners implementing OnXxxListener interfaces. To chain a listener for a component,
* simply implement the interface on your automator. For example, if your automator subclass
* implements OnFocusChangeListener, then its onFocusChanged method will be called prior to
* calling the view's actual listener (if any was set).
*
*/
@Override
public boolean installDefaultListeners() {
Class<?> sup = this.getClass();
while (sup != null && IAutomator.class.isAssignableFrom(sup)) {
Class<?>[] xfaces = sup.getInterfaces();
for (Class<?> xface : xfaces) {
if (onListener.matcher(xface.getName()).matches() && !isExcludedFromChaining(xface)) {
chainListenerFor(xface);
}
}
sup = sup.getSuperclass();
}
return true;
}
/**
* Subclasses can override to prevent listeners being created for an interface. Spinner needs to
* do this to prevent onClick listener being added.
*
* @param xface
* @return true if no default handlers should be added for this interface
*/
protected boolean isExcludedFromChaining(Class<?> xface) {
// TODO Auto-generated method stub
return false;
}
/**
* used to warn about incumbent listeners for certain defaults
*
* @param klass
* - the listenre class which was found to be incumbent
* @param v
* - the view that had it installed
*/
// protected static void logWarn(String klass, View v) {
// Log.log("WARNING: You have a "
// + klass
// + " set on "
// + v
// + ". FoneMonkey needs to set its own listener "
// + "in order to learn about any views added after onCreate() has finished."
// + " See FoneMonkey's Multiplexed" + klass
// + " class for a way to get around this problem.");
// }
protected static boolean isAndroidBuiltin(Class<?> klass) {
if (klass != null) {
return klass.getName().startsWith("android.")
|| klass.getName().startsWith("com.android");
}
return false;
}
protected static boolean isAndroidBuiltin(Object o) {
if (o != null) {
return isAndroidBuiltin(o.getClass());
}
return false;
}
public void assertArgCount(String action, String[] args, int required) {
if (args.length < required) {
throw new IllegalArgumentException(action + " requires " + required
+ " arguments, but found " + args.length);
}
}
public void assertMaxArg(int arg, int max) {
if (arg > max) {
throw new IllegalArgumentException("Argument value " + arg + " " + "exceeds maximum "
+ max);
}
}
public int getIndexArg(String action, String arg) {
return getIntegerArg(action, arg, 1);
}
public int getIntegerArg(String action, String arg, int min) {
int i;
try {
i = Integer.valueOf(arg);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(action + " requires integer argument, but found \""
+ arg + "\"");
}
if (i < min) {
throw new IllegalArgumentException(action + " requires integer argument greater than "
+ min + " , but found \"" + arg + "\"");
}
return i;
}
public int getOrdinal() {
return 1;
}
protected String getListenerName(Class<?> listenerClass) {
String[] parts = listenerClass.getCanonicalName().split("\\.");
String listenerName = parts[parts.length - 1];
return listenerName;
}
protected void chainListenerFor(Class<?> listenerClass) {
String listenerName = getListenerName(listenerClass);
String setterName = "set" + listenerName;
chainListenerFor(listenerClass, setterName);
}
protected void chainListenerFor(Class<?> listenerClass, String setterName) {
Class<?> klass = getComponent().getClass();
String listenerName = getListenerName(listenerClass);
String listenerField = "m" + listenerName;
Object listener = null;
Object target = getComponent();
boolean found = false;
Field f = null;
while (!found) {
try {
try {
f = klass.getDeclaredField(listenerField);
f.setAccessible(true);
listener = f.get(target);
} catch (NoSuchFieldException e) {
// listeners moved into inner ListenerInfo class in Android 4.0.3.
Method m = klass.getDeclaredMethod("getListenerInfo", (Class<?>[]) null);
m.setAccessible(true);
target = m.invoke(target, (Object[]) null);
klass = target.getClass();
f = klass.getDeclaredField(listenerField);
f.setAccessible(true);
listener = f.get(target);
}
found = true;
} catch (Exception e) {
klass = klass.getSuperclass();
if (klass == null) {
throw new IllegalStateException("Unable to find field " + listenerField
+ " in any superclass of " + target.getClass().getName());
}
}
}
if (listener instanceof Proxy
&& Proxy.getInvocationHandler(listener) instanceof MonkeyInvocationHandler) {
// Already chained. Should probably cache this fact somewhere.
return;
}
Object proxy = Proxy.newProxyInstance(listenerClass.getClassLoader(),
new Class<?>[] { listenerClass }, new MonkeyInvocationHandler(listener));
// Calling the setter can result in Android PassthroughClickListener referencing us and vice
// versa (and subsequent stackoverflow)
// So we've got to assign the private field directly
// if (setterName == null) {
try {
f.set(target, proxy);
return;
} catch (Exception e) {
throw new IllegalStateException("Unable to assign field " + f.getName() + ": "
+ e.getMessage());
}
// }
// Method meth;
// try {
// meth = klass.getDeclaredMethod(setterName, listenerClass);
// } catch (Exception e) {
// throw new IllegalStateException("Unable to find setter for "
// + listenerName + ": " + e.getMessage());
// }
// try {
// meth.invoke(getComponent(), proxy);
// } catch (Exception e) {
// // if (e.getCause() != null
// // && e.getCause().getMessage().contains("cannot be used")) { //
// // Unfortunately, components throw indistinguishable runtime
// // exceptions if a listener is set that they don't support (even
// // though they have the setter
// // Probably a subclass that doesn't support a listener defined
// // on a superclass.
// // eg, Spinner is a subclass of AdapterView but throws a
// // RuntimeException if you
// // try to set its onItemClickListener.
// //
// // Should probably cache so we don't repeatedly wind up here.
// //
// return;
// // }
// // throw new IllegalStateException("Error invoking " +
// // meth.getName()
// // + ": " + e.getMessage());
// }
}
/**
* Intercepts method calls for recording by calling the impl'd listener interface on
* corresponding IAutomator, before calling the actual listener, if any.
*
* @author sstern
*
*/
private final class MonkeyInvocationHandler implements InvocationHandler {
/**
*
*/
private final Object listener;
/**
* @param l
*/
private MonkeyInvocationHandler(Object listener) {
this.listener = listener;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
// Call the automator's listener impl
Object rc = method.invoke(AutomatorBase.this, args);
if (listener == null) {
return rc;
}
// Call the actual listener
return method.invoke(listener, args);
}
}
@Override
public boolean canAutomate(String componentType, String monkeyID) {
if (forSubtypeOf(componentType)) {
if (getIdentifyingValues().contains(monkeyID) // using app-level ids
|| matchesOrdinal(monkeyID)) { // using explicit or implicit ordinal
return true;
}
}
return false;
}
private boolean matchesOrdinal(String monkeyID) {
if (monkeyID == null || monkeyID.trim().length() == 0 || monkeyID.equals("*")) {
monkeyID = "#1";
}
return monkeyID.equals("#" + getOrdinal());
}
public List<String> getIdentifyingValues() {
ArrayList<String> list = new ArrayList<String>();
String id = getMonkeyID();
if (id != null) {
list.add(id);
}
return list;
}
// matches arrayName[n]...
private static Pattern arrayPattern = Pattern.compile("^(\\w+)\\([\\d,]+\\)$");
// matches [n]
private static Pattern arrayIndexPattern = Pattern.compile("\\((\\d+)[,\\)]|(\\d+)[,\\)]");
private boolean isArray(String propertyPath) {
return arrayPattern.matcher(propertyPath).matches();
}
private String getArrayItem(String propertyPath) {
Matcher m = arrayPattern.matcher(propertyPath);
m.find();
String name = m.group(1);
ArrayList<Integer> indices = new ArrayList<Integer>();
m = arrayIndexPattern.matcher(propertyPath);
int group = 1;
while (m.find()) {
String s = m.group(group);
int i = getIndexArg(AutomatorConstants.ACTION_GET, s);
indices.add(i - 1);
group++;
}
return getArrayItem(name, indices);
}
/**
* Subclasses should override to return array valued property items
*
* @param arrayName
* - the name of the array, eg, items
* @param indices
* - A list containing (zero-based) index value for each dimension of the array
* @return the item at the specified indices
*/
protected String getArrayItem(String arrayName, List<Integer> indices) {
throw new IllegalArgumentException(getComponentType() + " has no such array: " + arrayName);
}
/**
* performs the agent-side part of the verifyImage command take a screenshot and return it,
* along with the position and size of the target component
*
* @return
*/
private String playVerifyImage() {
Rect br = getBoundingRectangle();
return takeScreenshot(br.left + " " + br.top + " " + br.width() + " " + br.height());
}
private String takeScreenshot(String msg) {
if (msg == null) {
msg = "no message";
}
Log.log("VerifyImage SCREENSHOT - " + msg + " - taking screenshot...");
DeviceAutomator device = (DeviceAutomator) AutomationManager.findAutomatorByType("Device");
if (device != null) {
try {
String screenshot = device.play(AutomatorConstants.ACTION_SCREENSHOT);
if (screenshot != null && screenshot.startsWith("{screenshot")) {
Log.log("SCREENSHOT - done!");
return "{message:\"" + msg.replaceAll("\"", "'") + "\","
+ screenshot.substring(1);
}
} catch (Exception ex) {
String exMsg = ex.getMessage();
if (exMsg != null) {
exMsg = exMsg.replaceAll("\"", "'");
} else {
exMsg = ex.getClass().getName();
}
return msg + " -- " + exMsg;
}
}
return msg;
}
protected Rect getBoundingRectangle() {
return new Rect(0, 0, 1, 1);
}
private boolean isValidVerifyAction(String action) {
return (action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY_IMAGE)
|| action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY)
|| action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY_NOT)
|| action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY_REGEX)
|| action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY_NOT_REGEX)
|| action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY_WILDCARD)
|| action.equalsIgnoreCase(AutomatorConstants.ACTION_VERIFY_NOT_WILDCARD));
}
@Override
public String toString() {
return getClass().getSimpleName() + " [" + getComponentType() + "]";
}
}