View Javadoc

1   /***
2    *     Ambient - A music player for the Android platform
3    Copyright (C) 2007 Martin Vysny
4    
5    This program is free software: you can redistribute it and/or modify
6    it under the terms of the GNU General Public License as published by
7    the Free Software Foundation, either version 3 of the License, or
8    (at your option) any later version.
9    
10   This program is distributed in the hope that it will be useful,
11   but WITHOUT ANY WARRANTY; without even the implied warranty of
12   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   GNU General Public License for more details.
14  
15   You should have received a copy of the GNU General Public License
16   along with this program.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  package sk.baka.ambient.commons;
19  
20  import java.lang.reflect.Field;
21  import java.lang.reflect.Method;
22  import java.lang.reflect.Modifier;
23  import java.text.ParseException;
24  import java.util.Collections;
25  import java.util.HashMap;
26  import java.util.Map;
27  
28  import sk.baka.ambient.AmbientApplication;
29  import sk.baka.ambient.R;
30  import android.content.SharedPreferences.Editor;
31  import android.util.Log;
32  import android.view.View;
33  import android.widget.CheckBox;
34  import android.widget.EditText;
35  import android.widget.Spinner;
36  
37  /***
38   * Binds a POJO bean to a view. Currently only public fields are supported - no
39   * getters/setters.
40   * 
41   * @author Martin Vysny
42   */
43  public final class Binder {
44  	private Binder() {
45  		// prevent instantiation
46  	}
47  
48  	/***
49  	 * Copies all properties annotated by {@link Bind} to a map.
50  	 * 
51  	 * @param bean
52  	 *            the bean to copy from/to
53  	 * @param map
54  	 *            the map to use
55  	 * @param beanToMap
56  	 *            if <code>true</code> then data from the bean are copied into
57  	 *            the map. If <code>false</code> then map's data will be
58  	 *            copied into the bean.
59  	 * @param validate
60  	 *            if <code>true</code> then validation constraints are
61  	 *            checked.
62  	 * @return an empty map if no validation constraints were violated; a map of
63  	 *         view id to an error message if violation occured.
64  	 */
65  	public static Map<Integer, String> bindBeanMap(final Object bean,
66  			final Map<String, Object> map, final boolean beanToMap,
67  			final boolean validate) {
68  		final Map<Integer, String> result = new HashMap<Integer, String>();
69  		try {
70  			for (final Field field : bean.getClass().getFields()) {
71  				final Bind annotation = field.getAnnotation(Bind.class);
72  				if (annotation == null)
73  					continue;
74  				if (!Modifier.isPublic(field.getModifiers()))
75  					continue;
76  				if (beanToMap) {
77  					final Object fieldValue = field.get(bean);
78  					if (validate) {
79  						result.putAll(checkValidity(fieldValue, annotation));
80  					}
81  					map.put(field.getName(), fieldValue);
82  				} else {
83  					Object fieldValue = map.get(field.getName());
84  					if (fieldValue == null)
85  						continue;
86  					if (validate)
87  						result.putAll(checkValidity(fieldValue, annotation));
88  					// handle special Enum case
89  					if ((fieldValue instanceof Integer)
90  							&& field.getType().isEnum()) {
91  						final int ordinal = (Integer) fieldValue;
92  						fieldValue = ordinal < 0 ? null : field.getType()
93  								.getEnumConstants()[ordinal];
94  					}
95  					field.set(bean, fieldValue);
96  				}
97  			}
98  		} catch (IllegalAccessException e) {
99  			throw new RuntimeException(e);
100 		}
101 		return result;
102 	}
103 
104 	/***
105 	 * Copies all properties annotated by {@link Bind} to an editor.
106 	 * 
107 	 * @param bean
108 	 *            the bean to copy from
109 	 * @param editor
110 	 *            the map to use
111 	 * @param validate
112 	 *            if <code>true</code> then validation constraints are
113 	 *            checked.
114 	 * @return an empty map if no validation constraints were violated; a map of
115 	 *         view id to an error message if violation occured.
116 	 */
117 	public static Map<Integer, String> bindBeanToEditor(final Object bean,
118 			final Editor editor, final boolean validate) {
119 		final Map<Integer, String> result = new HashMap<Integer, String>();
120 		try {
121 			for (final Field field : bean.getClass().getFields()) {
122 				final Bind annotation = field.getAnnotation(Bind.class);
123 				if (annotation == null)
124 					continue;
125 				if (!Modifier.isPublic(field.getModifiers()))
126 					continue;
127 				final Object fieldValue = field.get(bean);
128 				final Class<?> type = field.getType();
129 				if (validate)
130 					result.putAll(checkValidity(fieldValue, annotation));
131 				final String k = field.getName();
132 				final Object v = fieldValue;
133 				put(k, v, type, editor);
134 			}
135 		} catch (IllegalAccessException e) {
136 			throw new RuntimeException(e);
137 		}
138 		return result;
139 	}
140 
141 	/***
142 	 * Puts given object into an editor.
143 	 * 
144 	 * @param <V>
145 	 *            the value type
146 	 * @param k
147 	 *            key name
148 	 * @param v
149 	 *            value
150 	 * @param type
151 	 *            the value class
152 	 * @param editor
153 	 *            the editor.
154 	 */
155 	public static <V> void put(final String k, final V v,
156 			final Class<? extends V> type, final Editor editor) {
157 		if (v instanceof String) {
158 			editor.putString(k, (String) v);
159 			return;
160 		}
161 		if (v instanceof Float) {
162 			editor.putFloat(k, (Float) v);
163 			return;
164 		}
165 		if (v instanceof Integer) {
166 			editor.putInt(k, (Integer) v);
167 			return;
168 		}
169 		if (v instanceof Boolean) {
170 			editor.putBoolean(k, (Boolean) v);
171 			return;
172 		}
173 		if (v instanceof Long) {
174 			editor.putLong(k, (Long) v);
175 			return;
176 		}
177 		if (type.isEnum()) {
178 			editor.putInt(k, v == null ? -1 : ((Enum<?>) v).ordinal());
179 			return;
180 		}
181 		throw new IllegalArgumentException("Object " + type.getName()
182 				+ " not writable to an editor");
183 	}
184 
185 	/***
186 	 * Copies all properties annotated by {@link Bind} to the view.
187 	 * 
188 	 * @param bean
189 	 *            the bean to copy from/to
190 	 * @param parent
191 	 *            the view to use for performing View lookup.
192 	 * @param beanToView
193 	 *            if <code>true</code> then data from the bean are copied into
194 	 *            the view. If <code>false</code> then view's data will be
195 	 *            copied into the bean.
196 	 * @param validate
197 	 *            if <code>true</code> then validation constraints are
198 	 *            checked.
199 	 * @return an empty map if no validation constraints were violated; a map of
200 	 *         view id to an error message if violation occurred.
201 	 */
202 	public static Map<Integer, String> bindBeanView(final Object bean,
203 			final View parent, final boolean beanToView, final boolean validate) {
204 		if (bean == null) {
205 			throw new NullArgumentException("bean");
206 		}
207 		if (parent == null) {
208 			throw new NullArgumentException("parent");
209 		}
210 		final Map<Integer, String> result = new HashMap<Integer, String>();
211 		try {
212 			for (final Field field : bean.getClass().getFields()) {
213 				final Bind annotation = field.getAnnotation(Bind.class);
214 				if (annotation == null)
215 					continue;
216 				final View view = parent.findViewById(annotation.viewId());
217 				if (view == null)
218 					throw new RuntimeException("No view with ID "
219 							+ annotation.viewId());
220 				if (beanToView) {
221 					final Object fieldValue = field.get(bean);
222 					if (validate)
223 						result.putAll(checkValidity(fieldValue, annotation));
224 					assignValueToView(fieldValue, view, annotation.escape());
225 				} else {
226 					final Class<?> fieldClass = field.getType();
227 					final Object fieldValue = assignViewToValue(fieldClass,
228 							view, annotation.escape());
229 					if (validate)
230 						result.putAll(checkValidity(fieldValue, annotation));
231 					field.set(bean, fieldValue);
232 				}
233 			}
234 		} catch (IllegalAccessException e) {
235 			throw new RuntimeException(e);
236 		}
237 		return result;
238 	}
239 
240 	private static String formatErrMsg(final Bind bind, final int errorMsgId,
241 			final Object... args) {
242 		final String errMsg = MiscUtils.format(errorMsgId, args);
243 		if (bind.captionId() == -1)
244 			return errMsg;
245 		final String caption = AmbientApplication.getInstance().getString(
246 				bind.captionId());
247 		return caption + ": " + errMsg;
248 	}
249 
250 	@SuppressWarnings("unchecked")
251 	private static Map<Integer, String> checkValidity(final Object object,
252 			Bind annotation) {
253 		if (object instanceof Number) {
254 			final int intVal = ((Number) object).intValue();
255 			if (intVal < annotation.min()) {
256 				final String errMsg = formatErrMsg(annotation,
257 						R.string.errNumberTooSmall, annotation.min());
258 				return Collections.singletonMap(annotation.viewId(), errMsg);
259 			}
260 			if (intVal > annotation.max()) {
261 				final String errMsg = formatErrMsg(annotation,
262 						R.string.errNumberTooBig, annotation.max());
263 				return Collections.singletonMap(annotation.viewId(), errMsg);
264 			}
265 		}
266 		if ((object instanceof String) && annotation.tagFormat()) {
267 			try {
268 				new TagFormatter((String) object);
269 			} catch (ParseException e) {
270 				Log.i(Binder.class.getSimpleName(), e.getMessage(), e);
271 				final String errMsg = formatErrMsg(annotation,
272 						R.string.errStringNotTagFormat, e.getMessage());
273 				return Collections.singletonMap(annotation.viewId(), errMsg);
274 			}
275 		}
276 		return Collections.EMPTY_MAP;
277 	}
278 
279 	/***
280 	 * Assigns given value to the view
281 	 * 
282 	 * @param fieldValue
283 	 *            the value
284 	 * @param view
285 	 *            the view
286 	 * @param escape
287 	 *            if <code>true</code> then the string value is
288 	 *            {@link #escape(String) escaped}.
289 	 */
290 	private static void assignValueToView(final Object fieldValue,
291 			final View view, boolean escape) {
292 		if (view instanceof CheckBox) {
293 			((CheckBox) view).setChecked((Boolean) fieldValue);
294 		} else if (view instanceof EditText) {
295 			String value = fieldValue == null ? "" : fieldValue.toString();
296 			if (escape) {
297 				value = escape(value);
298 			}
299 			((EditText) view).setText(value);
300 		} else if (view instanceof Spinner) {
301 			final int ordinal;
302 			if (fieldValue instanceof Enum<?>) {
303 				ordinal = ((Enum<?>) fieldValue).ordinal();
304 			} else {
305 				ordinal = ((Number) fieldValue).intValue();
306 			}
307 			((Spinner) view).setSelection(ordinal);
308 		} else
309 			throw new IllegalArgumentException("Unsupported view class: "
310 					+ view.getClass().getName());
311 	}
312 
313 	private static String escape(String value) {
314 		if (value == null)
315 			return null;
316 		return value.replace("\n", "//n");
317 	}
318 
319 	private static String unescape(String value) {
320 		if (value == null)
321 			return null;
322 		return value.replace("//n", "\n");
323 	}
324 
325 	/***
326 	 * Retrieves a value from a control.
327 	 * 
328 	 * @param fieldClass
329 	 *            class of the value.
330 	 * @param view
331 	 *            the control.
332 	 * @param unescape
333 	 *            if <code>true</code> then the string value is
334 	 *            {@link #unescape(String) unescaped}.
335 	 * @return value with given class.
336 	 */
337 	private static Object assignViewToValue(final Class<?> fieldClass,
338 			final View view, final boolean unescape) {
339 		final Class<?> fc = primitiveToClass(fieldClass);
340 		if (view instanceof CheckBox) {
341 			if (!Boolean.class.isAssignableFrom(fc))
342 				throw new IllegalArgumentException(fc.getName()
343 						+ " not assignable from CheckBox");
344 			return ((CheckBox) view).isChecked();
345 		} else if (view instanceof EditText) {
346 			String text = ((EditText) view).getText().toString();
347 			if (unescape) {
348 				text = unescape(text);
349 			}
350 			if (String.class.isAssignableFrom(fieldClass)) {
351 				return text;
352 			}
353 			final Class<?> targetClass = isNumber(fieldClass);
354 			if (targetClass != null) {
355 				// invoke .valueOf on the string
356 				try {
357 					final Method valueOf = targetClass.getMethod("valueOf",
358 							new Class[] { String.class });
359 					return valueOf.invoke(null, new Object[] { text });
360 				} catch (final Exception ex) {
361 					throw new RuntimeException(ex);
362 				}
363 			}
364 			throw new IllegalArgumentException(fieldClass.getName()
365 					+ " not assignable from EditText");
366 		} else if (view instanceof Spinner) {
367 			final int ordinal = ((Spinner) view).getSelectedItemPosition();
368 			if (fieldClass.isEnum()) {
369 				return fieldClass.getEnumConstants()[ordinal];
370 			}
371 			return Integer.valueOf(ordinal);
372 		}
373 		throw new IllegalArgumentException("Unsupported view class: "
374 				+ view.getClass().getName());
375 	}
376 
377 	private final static Map<Class<?>, Class<?>> primitiveToClass = new HashMap<Class<?>, Class<?>>();
378 	static {
379 		primitiveToClass.put(int.class, Integer.class);
380 		primitiveToClass.put(double.class, Double.class);
381 		primitiveToClass.put(float.class, Float.class);
382 		primitiveToClass.put(boolean.class, Boolean.class);
383 		primitiveToClass.put(byte.class, Byte.class);
384 		primitiveToClass.put(char.class, Character.class);
385 		primitiveToClass.put(long.class, Long.class);
386 	}
387 
388 	private static Class<?> primitiveToClass(final Class<?> clazz) {
389 		if (!clazz.isPrimitive())
390 			return clazz;
391 		return primitiveToClass.get(clazz);
392 	}
393 
394 	private static Class<?> isNumber(final Class<?> clazz) {
395 		final Class<?> counterpart = primitiveToClass(clazz);
396 		if (Number.class.isAssignableFrom(counterpart))
397 			return counterpart;
398 		return null;
399 	}
400 
401 	/***
402 	 * Copies all bindable properties from given bean to given bean.
403 	 * 
404 	 * @param <T>
405 	 *            the bean type
406 	 * @param from
407 	 *            source bean
408 	 * @param to
409 	 *            target bean
410 	 */
411 	public static <T> void copy(T from, T to) {
412 		try {
413 			for (final Field field : from.getClass().getFields()) {
414 				final Bind annotation = field.getAnnotation(Bind.class);
415 				if (annotation == null)
416 					continue;
417 				if (!Modifier.isPublic(field.getModifiers()))
418 					continue;
419 				final Object fieldValue = field.get(from);
420 				field.set(to, fieldValue);
421 			}
422 		} catch (IllegalAccessException e) {
423 			throw new RuntimeException(e);
424 		}
425 	}
426 }