/* * Copyright 2012 AndroidPlot.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.androidplot.util; import android.content.Context; import android.content.res.XmlResourceParser; import android.graphics.Color; import android.util.Log; import android.util.TypedValue; import org.xmlpull.v1.XmlPullParserException; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Type; import java.util.HashMap; /** * Utility class for "configuring" objects via XML config files. Supports the following field types: * String * Enum * int * float * boolean * <p/> * Config files should be stored in /res/xml. Given the XML configuration /res/xml/myConfig.xml, one can apply the * configuration to an Object instance as follows: * <p/> * MyObject obj = new MyObject(); * Configurator.configure(obj, R.xml.myConfig); * <p/> * WHAT IT DOES: * Given a series of parameters stored in an XML file, Configurator iterates through each parameter, using the name * as a map to the field within a given object. For example: * <p/> * <pre> * {@code * <config car.engine.sparkPlug.condition="poor"/> * } * </pre> * <p/> * Given a Car instance car and assuming the method setCondition(String) exists within the SparkPlug class, * Configurator does the following: * <p/> * <pre> * {@code * car.getEngine().getSparkPlug().setCondition("poor"); * } * </pre> * <p/> * Now let's pretend that setCondition takes an instance of the Condition enum as it's argument. * Configurator then does the following: * <p/> * car.getEngine().getSparkPlug().setCondition(Condition.valueOf("poor"); * <p/> * Now let's look at how ints are handled. Given the following xml: * <p/> * <config car.engine.miles="100000"/> * <p/> * would result in: * car.getEngine.setMiles(Integer.ParseInt("100000"); * <p/> * That's pretty straight forward. But colors are expressed as ints too in Android * but can be defined using hex values or even names of colors. When Configurator * attempts to parse a parameter for a method that it knows takes an int as it's argument, * Configurator will first attempt to parse the parameter as a color. Only after this * attempt fails will Configurator resort to Integer.ParseInt. So: * <p/> * <config car.hood.paint.color="Red"/> * <p/> * would result in: * car.getHood().getPaint().setColor(Color.parseColor("Red"); * <p/> * Next lets talk about float. Floats can appear in XML a few different ways in Android, * especially when it comes to defining dimensions: * <p/> * <config height="10dp" depth="2mm" width="5em"/> * <p/> * Configurator will correctly parse each of these into their corresponding real pixel value expressed as a float. * <p/> * One last thing to keep in mind when using Configurator: * Values for Strings and ints can be assigned to localized values, allowing * a cleaner solution for those developing apps to run on multiple form factors * or in multiple languages: * <p/> * <config thingy.description="@string/thingyDescription" * thingy.titlePaint.textSize=""/> */ @SuppressWarnings("WeakerAccess") public abstract class Configurator { private static final String TAG = Configurator.class.getName(); protected static final String CFG_ELEMENT_NAME = "config"; protected static int parseResId(Context ctx, String prefix, String value) { String[] split = value.split("/"); // is this a localized resource? if (split.length > 1 && split[0].equalsIgnoreCase(prefix)) { String pack = split[0].replace("@", ""); String name = split[1]; return ctx.getResources().getIdentifier(name, pack, ctx.getPackageName()); } else { throw new IllegalArgumentException(); } } protected static int parseIntAttr(Context ctx, String value) { try { return ctx.getResources().getColor(parseResId(ctx, "@color", value)); } catch (IllegalArgumentException e1) { try { return Color.parseColor(value); } catch (IllegalArgumentException e2) { // wasn't a color so try parsing as a plain old int: return Integer.parseInt(value); } } } /** * Treats value as a float parameter. First value is tested to see whether * it contains a resource identifier. Failing that, it is tested to see whether * a dimension suffix (dp, em, mm etc.) exists. Failing that, it is evaluated as * a plain old float. * @param ctx * @param value * @return */ protected static float parseFloatAttr(Context ctx, String value) { try { return ctx.getResources().getDimension(parseResId(ctx, "@dimen", value)); } catch (IllegalArgumentException e1) { try { return PixelUtils.stringToDimension(value); } catch (Exception e2) { return Float.parseFloat(value); } } } protected static String parseStringAttr(Context ctx, String value) { try { return ctx.getResources().getString(parseResId(ctx, "@string", value)); } catch (IllegalArgumentException e1) { return value; } } protected static Method getSetter(Class clazz, final String fieldId) throws NoSuchMethodException { Method[] methods = clazz.getMethods(); String methodName = "set" + fieldId; for (Method method : methods) { if (method.getName().equalsIgnoreCase(methodName)) { return method; } } throw new NoSuchMethodException("No such public method (case insensitive): " + methodName + " in " + clazz); } @SuppressWarnings("unchecked") protected static Method getGetter(Class clazz, final String fieldId) throws NoSuchMethodException { Log.d(TAG, "Attempting to find getter for " + fieldId + " in class " + clazz.getName()); String firstLetter = fieldId.substring(0, 1); String methodName = "get" + firstLetter.toUpperCase() + fieldId.substring(1, fieldId.length()); return clazz.getMethod(methodName); } /** * Returns the object containing the field specified by path. * @param obj * @param path Path through member hierarchy to the destination field. * @return null if the object at path cannot be found. * @throws java.lang.reflect.InvocationTargetException * * @throws IllegalAccessException */ protected static Object getObjectContaining(Object obj, String path) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException { if(obj == null) { throw new NullPointerException("Attempt to call getObjectContaining(Object obj, String path) " + "on a null Object instance. Path was: " + path); } Log.d(TAG, "Looking up object containing: " + path); int separatorIndex = path.indexOf("."); // not there yet, descend deeper: if (separatorIndex > 0) { String lhs = path.substring(0, separatorIndex); String rhs = path.substring(separatorIndex + 1, path.length()); // use getter to retrieve the instance Method m = getGetter(obj.getClass(), lhs); if(m == null) { throw new NullPointerException("No getter found for field: " + lhs + " within " + obj.getClass()); } Log.d(TAG, "Invoking " + m.getName() + " on instance of " + obj.getClass().getName()); Object o = m.invoke(obj); // delve into o return getObjectContaining(o, rhs); //} catch (NoSuchMethodException e) { // TODO: log a warning // return null; //} } else { // found it! return obj; } } @SuppressWarnings("unchecked") private static Object[] inflateParams(Context ctx, Class[] params, String[] vals) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { Object[] out = new Object[params.length]; int i = 0; for (Class param : params) { if (Enum.class.isAssignableFrom(param)) { out[i] = param.getMethod("valueOf", String.class).invoke(null, vals[i].toUpperCase()); } else if (param.equals(Float.TYPE)) { out[i] = parseFloatAttr(ctx, vals[i]); } else if (param.equals(Integer.TYPE)) { out[i] = parseIntAttr(ctx, vals[i]); } else if (param.equals(Boolean.TYPE)) { out[i] = Boolean.valueOf(vals[i]); } else if (param.equals(String.class)) { out[i] = parseStringAttr(ctx, vals[i]); } else { throw new IllegalArgumentException( "Error inflating XML: Setter requires param of unsupported type: " + param); } i++; } return out; } /** * * @param ctx * @param obj * @param xmlFileId ID of the XML config file within /res/xml */ public static void configure(Context ctx, Object obj, int xmlFileId) { XmlResourceParser xrp = ctx.getResources().getXml(xmlFileId); try { HashMap<String, String> params = new HashMap<String, String>(); while (xrp.getEventType() != XmlResourceParser.END_DOCUMENT) { xrp.next(); String name = xrp.getName(); if (xrp.getEventType() == XmlResourceParser.START_TAG) { if (name.equalsIgnoreCase(CFG_ELEMENT_NAME)) for (int i = 0; i < xrp.getAttributeCount(); i++) { params.put(xrp.getAttributeName(i), xrp.getAttributeValue(i)); } break; } } configure(ctx, obj, params); } catch (XmlPullParserException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } finally { xrp.close(); } } public static void configure(Context ctx, Object obj, HashMap<String, String> params) { for (String key : params.keySet()) { try { configure(ctx, obj, key, params.get(key)); } catch (InvocationTargetException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (NoSuchMethodException e) { Log.w(TAG, "Error inflating XML: Setter for field \"" + key + "\" does not exist. "); e.printStackTrace(); } } } /** * Recursively descend into an object using key as the pathway and invoking the corresponding setter * if one exists. * * @param key * @param value */ protected static void configure(Context ctx, Object obj, String key, String value) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException { Object o = getObjectContaining(obj, key); if (o != null) { int idx = key.lastIndexOf("."); String fieldId = idx > 0 ? key.substring(idx + 1, key.length()) : key; Method m = getSetter(o.getClass(), fieldId); Class[] paramTypes = m.getParameterTypes(); // TODO: add support for generic type params if (paramTypes.length >= 1) { // split on "|" // TODO: add support for String args containing a | String[] paramStrs = value.split("\\|"); if (paramStrs.length == paramTypes.length) { Object[] oa = inflateParams(ctx, paramTypes, paramStrs); Log.d(TAG, "Invoking " + m.getName() + " with arg(s) " + argArrToString(oa)); m.invoke(o, oa); } else { throw new IllegalArgumentException("Error inflating XML: Unexpected number of argments passed to \"" + m.getName() + "\". Expected: " + paramTypes.length + " Got: " + paramStrs.length); } } else { // Obvious this is not a setter throw new IllegalArgumentException("Error inflating XML: no setter method found for param \"" + fieldId + "\"."); } } } protected static String argArrToString(Object[] args) { String out = ""; for(Object obj : args) { out += (obj == null ? (out += "[null] ") : ("[" + obj.getClass() + ": " + obj + "] ")); } return out; } }