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
17package com.androidplot.util;
18
19import android.content.Context;
20import android.content.res.XmlResourceParser;
21import android.graphics.Color;
22import android.util.Log;
23import android.util.TypedValue;
24import org.xmlpull.v1.XmlPullParserException;
25
26import java.io.IOException;
27import java.lang.reflect.InvocationTargetException;
28import java.lang.reflect.Method;
29import java.lang.reflect.Type;
30import 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")
104public abstract class Configurator {
105
106    private static final String TAG = Configurator.class.getName();
107    protected static final String CFG_ELEMENT_NAME = "config";
108
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
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     */
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
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
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")
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     */
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")
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     */
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
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     */
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
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