1 /*
2  * Copyright 2012 AndroidPlot.com
3  *
4  *    Licensed under the Apache License, Version 2.0 (the "License");
5  *    you may not use this file except in compliance with the License.
6  *    You may obtain a copy of the License at
7  *
8  *        http://www.apache.org/licenses/LICENSE-2.0
9  *
10  *    Unless required by applicable law or agreed to in writing, software
11  *    distributed under the License is distributed on an "AS IS" BASIS,
12  *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  *    See the License for the specific language governing permissions and
14  *    limitations under the License.
15  */
16 
17 package com.androidplot.util;
18 
19 import android.content.Context;
20 import android.content.res.XmlResourceParser;
21 import android.graphics.Color;
22 import android.util.Log;
23 import android.util.TypedValue;
24 import org.xmlpull.v1.XmlPullParserException;
25 
26 import java.io.IOException;
27 import java.lang.reflect.InvocationTargetException;
28 import java.lang.reflect.Method;
29 import java.lang.reflect.Type;
30 import java.util.HashMap;
31 
32 /**
33  * Utility class for "configuring" objects via XML config files.  Supports the following field types:
34  * String
35  * Enum
36  * int
37  * float
38  * boolean
39  * <p/>
40  * Config files should be stored in /res/xml.  Given the XML configuration /res/xml/myConfig.xml, one can apply the
41  * configuration to an Object instance as follows:
42  * <p/>
43  * MyObject obj = new MyObject();
44  * Configurator.configure(obj, R.xml.myConfig);
45  * <p/>
46  * WHAT IT DOES:
47  * Given a series of parameters stored in an XML file, Configurator iterates through each parameter, using the name
48  * as a map to the field within a given object.  For example:
49  * <p/>
50  * <pre>
51  * {@code
52  * <config car.engine.sparkPlug.condition="poor"/>
53  * }
54  * </pre>
55  * <p/>
56  * Given a Car instance car and assuming the method setCondition(String) exists within the SparkPlug class,
57  * Configurator does the following:
58  * <p/>
59  * <pre>
60  * {@code
61  * car.getEngine().getSparkPlug().setCondition("poor");
62  * }
63  * </pre>
64  * <p/>
65  * Now let's pretend that setCondition takes an instance of the Condition enum as it's argument.
66  * Configurator then does the following:
67  * <p/>
68  * car.getEngine().getSparkPlug().setCondition(Condition.valueOf("poor");
69  * <p/>
70  * Now let's look at how ints are handled.  Given the following xml:
71  * <p/>
72  * <config car.engine.miles="100000"/>
73  * <p/>
74  * would result in:
75  * car.getEngine.setMiles(Integer.ParseInt("100000");
76  * <p/>
77  * That's pretty straight forward.  But colors are expressed as ints too in Android
78  * but can be defined using hex values or even names of colors.  When Configurator
79  * attempts to parse a parameter for a method that it knows takes an int as it's argument,
80  * Configurator will first attempt to parse the parameter as a color.  Only after this
81  * attempt fails will Configurator resort to Integer.ParseInt.  So:
82  * <p/>
83  * <config car.hood.paint.color="Red"/>
84  * <p/>
85  * would result in:
86  * car.getHood().getPaint().setColor(Color.parseColor("Red");
87  * <p/>
88  * Next lets talk about float.  Floats can appear in XML a few different ways in Android,
89  * especially when it comes to defining dimensions:
90  * <p/>
91  * <config height="10dp" depth="2mm" width="5em"/>
92  * <p/>
93  * Configurator will correctly parse each of these into their corresponding real pixel value expressed as a float.
94  * <p/>
95  * One last thing to keep in mind when using Configurator:
96  * Values for Strings and ints can be assigned to localized values, allowing
97  * a cleaner solution for those developing apps to run on multiple form factors
98  * or in multiple languages:
99  * <p/>
100  * <config thingy.description="@string/thingyDescription"
101  * thingy.titlePaint.textSize=""/>
102  */
103 @SuppressWarnings("WeakerAccess")
104 public abstract class Configurator {
105 
106     private static final String TAG = Configurator.class.getName();
107     protected static final String CFG_ELEMENT_NAME = "config";
108 
parseResId(Context ctx, String prefix, String value)109     protected static int parseResId(Context ctx, String prefix, String value) {
110         String[] split = value.split("/");
111         // is this a localized resource?
112         if (split.length > 1 && split[0].equalsIgnoreCase(prefix)) {
113             String pack = split[0].replace("@", "");
114             String name = split[1];
115             return ctx.getResources().getIdentifier(name, pack, ctx.getPackageName());
116         } else {
117             throw new IllegalArgumentException();
118         }
119     }
120 
parseIntAttr(Context ctx, String value)121     protected static int parseIntAttr(Context ctx, String value) {
122         try {
123             return ctx.getResources().getColor(parseResId(ctx, "@color", value));
124         } catch (IllegalArgumentException e1) {
125             try {
126                 return Color.parseColor(value);
127             } catch (IllegalArgumentException e2) {
128                 // wasn't a color so try parsing as a plain old int:
129                 return Integer.parseInt(value);
130             }
131         }
132     }
133 
134     /**
135      * Treats value as a float parameter.  First value is tested to see whether
136      * it contains a resource identifier.  Failing that, it is tested to see whether
137      * a dimension suffix (dp, em, mm etc.) exists.  Failing that, it is evaluated as
138      * a plain old float.
139      * @param ctx
140      * @param value
141      * @return
142      */
parseFloatAttr(Context ctx, String value)143     protected static float parseFloatAttr(Context ctx, String value) {
144         try {
145             return ctx.getResources().getDimension(parseResId(ctx, "@dimen", value));
146         } catch (IllegalArgumentException e1) {
147             try {
148                 return PixelUtils.stringToDimension(value);
149             } catch (Exception e2) {
150                 return Float.parseFloat(value);
151             }
152         }
153     }
154 
parseStringAttr(Context ctx, String value)155     protected static String parseStringAttr(Context ctx, String value) {
156         try {
157             return ctx.getResources().getString(parseResId(ctx, "@string", value));
158         } catch (IllegalArgumentException e1) {
159             return value;
160         }
161     }
162 
163 
getSetter(Class clazz, final String fieldId)164     protected static Method getSetter(Class clazz, final String fieldId) throws NoSuchMethodException {
165         Method[] methods = clazz.getMethods();
166 
167         String methodName = "set" + fieldId;
168         for (Method method : methods) {
169             if (method.getName().equalsIgnoreCase(methodName)) {
170                 return method;
171             }
172         }
173         throw new NoSuchMethodException("No such public method (case insensitive): " +
174                 methodName + " in " + clazz);
175     }
176 
177     @SuppressWarnings("unchecked")
getGetter(Class clazz, final String fieldId)178     protected static Method getGetter(Class clazz, final String fieldId) throws NoSuchMethodException {
179         Log.d(TAG, "Attempting to find getter for " + fieldId + " in class " + clazz.getName());
180         String firstLetter = fieldId.substring(0, 1);
181         String methodName = "get" + firstLetter.toUpperCase() + fieldId.substring(1, fieldId.length());
182         return clazz.getMethod(methodName);
183     }
184 
185     /**
186      * Returns the object containing the field specified by path.
187      * @param obj
188      * @param path Path through member hierarchy to the destination field.
189      * @return null if the object at path cannot be found.
190      * @throws java.lang.reflect.InvocationTargetException
191      *
192      * @throws IllegalAccessException
193      */
getObjectContaining(Object obj, String path)194     protected static Object getObjectContaining(Object obj, String path) throws
195             InvocationTargetException, IllegalAccessException, NoSuchMethodException {
196         if(obj == null) {
197             throw new NullPointerException("Attempt to call getObjectContaining(Object obj, String path) " +
198                     "on a null Object instance.  Path was: " + path);
199         }
200         Log.d(TAG, "Looking up object containing: " + path);
201         int separatorIndex = path.indexOf(".");
202 
203         // not there yet, descend deeper:
204         if (separatorIndex > 0) {
205             String lhs = path.substring(0, separatorIndex);
206             String rhs = path.substring(separatorIndex + 1, path.length());
207 
208             // use getter to retrieve the instance
209             Method m = getGetter(obj.getClass(), lhs);
210             if(m == null) {
211                 throw new NullPointerException("No getter found for field: " + lhs + " within " + obj.getClass());
212             }
213             Log.d(TAG, "Invoking " + m.getName() + " on instance of " + obj.getClass().getName());
214             Object o = m.invoke(obj);
215             // delve into o
216             return getObjectContaining(o, rhs);
217             //} catch (NoSuchMethodException e) {
218             // TODO: log a warning
219             //    return null;
220             //}
221         } else {
222             // found it!
223             return obj;
224         }
225     }
226 
227     @SuppressWarnings("unchecked")
inflateParams(Context ctx, Class[] params, String[] vals)228     private static Object[] inflateParams(Context ctx, Class[] params, String[] vals) throws NoSuchMethodException,
229             InvocationTargetException, IllegalAccessException {
230         Object[] out = new Object[params.length];
231         int i = 0;
232         for (Class param : params) {
233             if (Enum.class.isAssignableFrom(param)) {
234                 out[i] = param.getMethod("valueOf", String.class).invoke(null, vals[i].toUpperCase());
235             } else if (param.equals(Float.TYPE)) {
236                 out[i] = parseFloatAttr(ctx, vals[i]);
237             } else if (param.equals(Integer.TYPE)) {
238                 out[i] = parseIntAttr(ctx, vals[i]);
239             } else if (param.equals(Boolean.TYPE)) {
240                 out[i] = Boolean.valueOf(vals[i]);
241             } else if (param.equals(String.class)) {
242                 out[i] = parseStringAttr(ctx, vals[i]);
243             } else {
244                 throw new IllegalArgumentException(
245                         "Error inflating XML: Setter requires param of unsupported type: " + param);
246             }
247             i++;
248         }
249         return out;
250     }
251 
252     /**
253      *
254      * @param ctx
255      * @param obj
256      * @param xmlFileId ID of the XML config file within /res/xml
257      */
configure(Context ctx, Object obj, int xmlFileId)258     public static void configure(Context ctx, Object obj, int xmlFileId) {
259         XmlResourceParser xrp = ctx.getResources().getXml(xmlFileId);
260         try {
261             HashMap<String, String> params = new HashMap<String, String>();
262             while (xrp.getEventType() != XmlResourceParser.END_DOCUMENT) {
263                 xrp.next();
264                 String name = xrp.getName();
265                 if (xrp.getEventType() == XmlResourceParser.START_TAG) {
266                     if (name.equalsIgnoreCase(CFG_ELEMENT_NAME))
267                         for (int i = 0; i < xrp.getAttributeCount(); i++) {
268                             params.put(xrp.getAttributeName(i), xrp.getAttributeValue(i));
269                         }
270                     break;
271                 }
272             }
273             configure(ctx, obj, params);
274         } catch (XmlPullParserException e) {
275             e.printStackTrace();
276         } catch (IOException e) {
277             e.printStackTrace();
278         } finally {
279             xrp.close();
280         }
281     }
282 
configure(Context ctx, Object obj, HashMap<String, String> params)283     public static void configure(Context ctx, Object obj, HashMap<String, String> params) {
284         for (String key : params.keySet()) {
285             try {
286                 configure(ctx, obj, key, params.get(key));
287             } catch (InvocationTargetException e) {
288                 e.printStackTrace();
289             } catch (IllegalAccessException e) {
290                 e.printStackTrace();
291             } catch (NoSuchMethodException e) {
292                 Log.w(TAG, "Error inflating XML: Setter for field \"" + key + "\" does not exist. ");
293                 e.printStackTrace();
294             }
295         }
296     }
297 
298     /**
299      * Recursively descend into an object using key as the pathway and invoking the corresponding setter
300      * if one exists.
301      *
302      * @param key
303      * @param value
304      */
configure(Context ctx, Object obj, String key, String value)305     protected static void configure(Context ctx, Object obj, String key, String value)
306             throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {
307         Object o = getObjectContaining(obj, key);
308         if (o != null) {
309             int idx = key.lastIndexOf(".");
310             String fieldId = idx > 0 ? key.substring(idx + 1, key.length()) : key;
311 
312             Method m = getSetter(o.getClass(), fieldId);
313             Class[] paramTypes = m.getParameterTypes();
314             // TODO: add support for generic type params
315             if (paramTypes.length >= 1) {
316 
317                 // split on "|"
318                 // TODO: add support for String args containing a |
319                 String[] paramStrs = value.split("\\|");
320                 if (paramStrs.length == paramTypes.length) {
321 
322                     Object[] oa = inflateParams(ctx, paramTypes, paramStrs);
323                     Log.d(TAG, "Invoking " + m.getName() + " with arg(s) " + argArrToString(oa));
324                     m.invoke(o, oa);
325                 } else {
326                     throw new IllegalArgumentException("Error inflating XML: Unexpected number of argments passed to \""
327                             + m.getName() + "\".  Expected: " + paramTypes.length + " Got: " + paramStrs.length);
328                 }
329             } else {
330                 // Obvious this is not a setter
331                 throw new IllegalArgumentException("Error inflating XML: no setter method found for param \"" +
332                         fieldId + "\".");
333             }
334         }
335     }
336 
argArrToString(Object[] args)337     protected static String argArrToString(Object[] args) {
338         String out = "";
339         for(Object obj : args) {
340             out += (obj == null ? (out += "[null] ") :
341                     ("[" + obj.getClass() + ": " + obj + "] "));
342         }
343         return out;
344     }
345 }
346 
347