sillyandroid/src/main/java/me/angrybyte/sillyandroid/parsable/AnnotationParser.java
package me.angrybyte.sillyandroid.parsable;
import android.content.Context;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.VisibleForTesting;
import android.util.Log;
import android.util.SparseArray;
import android.view.View;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
/**
* A runtime annotation processor for annotations like {@link Annotations.FindView}, {@link Annotations.Layout}, {@link Annotations.Clickable} and similar.
* Note that using this parser slows down your initialization due to JVM reflection cost, but it gets quicker as the parser uses its {@link #FIELD_CACHE}
* more and more.
* <br>
* To parse annotations like {@link Annotations.Layout} you need to use {@link #parseType(Context, Object)}, and to parse annotations like
* {@link Annotations.FindView} you need to use {@link #parseFields(Context, Object, LayoutWrapper)}.
*
* @see me.angrybyte.sillyandroid.parsable.Annotations
*/
@SuppressWarnings("WeakerAccess")
public final class AnnotationParser {
private static final String TAG = AnnotationParser.class.getSimpleName();
private static final String MENU_ID_FIELD_NAME = "mMenuId";
private static final String LAYOUT_ID_FIELD_NAME = "mLayoutId";
private static final Map<String, List<Field>> FIELD_CACHE = new HashMap<>();
/**
* Making sure that this class's default constructor is private.
*/
private AnnotationParser() {}
/**
* Checks for usable annotations on the given Object, and sets the corresponding properties on the instance.
* For example, the 'menu' annotation will set the {@code mMenuId} field, while the 'layout' annotation sets the {@code mLayoutId} field.
*
* @param context Which context to use
* @param instance A non-{@code null} Object that was instantiated from the type being parsed
*/
public static void parseType(@NonNull final Context context, @NonNull final Object instance) {
final Class<?> parsedClass = instance.getClass();
// look for the @Menu annotation
if (parsedClass.isAnnotationPresent(Annotations.Menu.class)) {
Annotations.Menu annotation = parsedClass.getAnnotation(Annotations.Menu.class);
int menuId = annotation.value();
if (menuId == -1) {
// ID not provided, check the name
menuId = context.getResources().getIdentifier(annotation.name(), "menu", context.getPackageName());
}
if (menuId < 1) {
throw new IllegalArgumentException("Menu ID must be provided in the @Menu annotation");
}
if (!setIntFieldValue(instance, menuId, MENU_ID_FIELD_NAME)) {
throw new IllegalArgumentException("Failed to set the menu ID");
}
}
// look for the @Layout annotation
if (parsedClass.isAnnotationPresent(Annotations.Layout.class)) {
Annotations.Layout annotation = parsedClass.getAnnotation(Annotations.Layout.class);
int layoutId = annotation.value();
if (layoutId == -1) {
// ID not provided, check the name
layoutId = context.getResources().getIdentifier(annotation.name(), "layout", context.getPackageName());
}
if (layoutId < 1) {
throw new IllegalArgumentException("Layout ID must be provided in the @Layout annotation");
}
if (!setIntFieldValue(instance, layoutId, LAYOUT_ID_FIELD_NAME)) {
throw new IllegalArgumentException("Failed to set the layout ID");
}
}
}
/**
* Tries to parse the given Object, looking for Views with usable annotations such as {@link Annotations.FindView}, {@link Annotations.Clickable},
* {@link Annotations.LongClickable} and similar.
* For example, to find a 'title text view' and make it clickable, in your {@link LayoutWrapper} implementation (i.e. activity, fragment, etc) specify:
* <br>
* <pre>
* @Clickable
* @FindView(R.id.titleTextView)
* private TextView mTitleTextView;
* </pre>
* After this, you need to call this method to properly initialize all fields. It's best to do it in
* your {@link android.app.Activity#onCreate(android.os.Bundle)} or
* {@link android.app.Fragment#onCreateView(android.view.LayoutInflater, android.view.ViewGroup, android.os.Bundle)} methods after setting up the content
* view. To be able to use {@link Annotations.Clickable} and {@link Annotations.LongClickable}, your {@code instance} needs to implement
* {@link android.view.View.OnClickListener} and {@link android.view.View.OnLongClickListener} respectively.
* <p>
* <b>Note</b>: Some library classes already implement these operation chains by default; for examples see: {@link me.angrybyte.sillyandroid.parsable}
* </p>
*
* @param context Which context to use
* @param instance A non-{@code null} instance that holds the annotated fields being parsed
* @param wrapper A non-{@code null} {@link LayoutWrapper}, used to find the Views
* @return A map (sparse array) of Views found while parsing the {@link Annotations.FindView} annotation
*/
public static SparseArray<View> parseFields(@NonNull final Context context, @NonNull final Object instance, @NonNull final LayoutWrapper wrapper) {
// find all fields
final SparseArray<View> parsedFields = new SparseArray<>();
final List<Field> allFields = getAllFields(instance.getClass());
// run through all fields
for (final Field iField : allFields) {
// check for annotations - click/long-click makes no sense when field is not parsed through this
if (iField.isAnnotationPresent(Annotations.FindView.class)) {
verifyTypeOfView(iField, instance);
final View v = findAndSetView(context, instance, wrapper, iField);
if (v == null) {
continue; // happens when 'safe' is set for @FindView and View is not found
}
parsedFields.put(v.getId(), v);
// add listeners
if (iField.isAnnotationPresent(Annotations.Clickable.class) && (instance instanceof View.OnClickListener)) {
setClickListener(v, (View.OnClickListener) instance);
}
if (iField.isAnnotationPresent(Annotations.LongClickable.class) && (instance instanceof View.OnLongClickListener)) {
setLongClickListener(v, (View.OnLongClickListener) instance);
}
}
}
return parsedFields;
}
/**
* Returns all fields from the given class, including its superclass fields. If cached fields are available, they will be used; instead, a new list will
* be saved to the cache.
*
* @param classInstance Which class to look into
* @return A list of declared class' fields. Do not modify this instance
*/
@NonNull
@VisibleForTesting
static List<Field> getAllFields(@NonNull final Class<?> classInstance) {
Class<?> parsedClass = classInstance;
final String name = parsedClass.getName();
List<Field> allFields = FIELD_CACHE.get(name);
if (allFields == null || allFields.isEmpty()) {
allFields = new LinkedList<>();
while (parsedClass != null && parsedClass != Object.class) {
allFields.addAll(Arrays.asList(parsedClass.getDeclaredFields()));
parsedClass = parsedClass.getSuperclass();
}
FIELD_CACHE.put(name, allFields);
}
return allFields;
}
/**
* Verifies that the given field is a {@link View} or crashes.
*
* @param field The field you are checking
* @param object The object instance holding the field
* @throws IllegalArgumentException When field is not a {@link View}
*/
@VisibleForTesting
static void verifyTypeOfView(@NonNull final Field field, @NonNull final Object object) {
try {
field.setAccessible(true);
Object value = field.get(object);
if (value instanceof View || View.class.isAssignableFrom(field.getType())) {
return;
}
} catch (IllegalAccessException ignored) {}
throw new IllegalArgumentException("Field \n\t'" + String.valueOf(field) + "\n is not a View, instead it is a " + field.getType().getSimpleName());
}
/**
* Tries to find the View declared through the {@link Annotations.FindView} annotation and set it to the given instance field.
*
* @param context Which context to use
* @param instance A non-{@code null} instance that holds the field
* @param wrapper A non-{@code null} {@link LayoutWrapper}, used to find the Views
* @param field The field to assign the View instance to
* @return The View that was set to the field, or {@code null} if nothing was set
*/
@Nullable
@VisibleForTesting
static View findAndSetView(@NonNull final Context context, @NonNull final Object instance, @NonNull final LayoutWrapper wrapper,
@NonNull final Field field) {
// check 'find view' annotation, don't crash when 'safe' is set
final Annotations.FindView annotation = field.getAnnotation(Annotations.FindView.class);
final boolean safeFail = annotation.safeFail();
int viewId = annotation.value();
if (viewId == -1) {
// ID not provided, check the name
viewId = context.getResources().getIdentifier(annotation.name(), "id", context.getPackageName());
}
if (viewId < 1 && !safeFail) {
throw new IllegalStateException("View not found for " + field.getName());
} else if (viewId < 1) {
Log.e(TAG, "Failed to find View for " + field.getName());
return null;
}
try {
// view ID is valid, try to find it
final View v = wrapper.findView(viewId);
if (v == null && !safeFail) {
throw new IllegalStateException("View not found for " + field + " in " + instance.getClass().getName());
} else if (v == null) {
Log.e(TAG, "View not found for " + field + " in " + instance.getClass().getName());
return null;
}
// set the View instance to the field
field.setAccessible(true);
field.set(instance, v);
return v;
} catch (IllegalAccessException e) {
throw new IllegalStateException(e);
}
}
// <editor-fold desc="Private helpers">
/**
* Assigns an integer to the given field.
*
* @param instance A non-{@code null} instance that holds the field
* @param value The value being set
* @param fieldName The name of the field being modified
* @return {@code True} if the value was successfully assigned, {@code false} otherwise
*/
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
private static boolean setIntFieldValue(@NonNull final Object instance, final int value, @NonNull final String fieldName) {
// check the cache first (iterating is much faster than reflection)
Field fieldReference = null;
List<Field> classFields = getAllFields(instance.getClass());
for (final Field iField : classFields) {
if (iField.getName().equals(fieldName)) {
// found it!
fieldReference = iField;
break;
}
}
// if not found, die.
if (fieldReference == null) {
throw new IllegalArgumentException("Class '" + instance.getClass().getName() + "' needs to have a '" + fieldName + "' field");
}
// finally, set the value
try {
fieldReference.setAccessible(true);
fieldReference.setInt(instance, value);
return true;
} catch (IllegalAccessException e) {
Log.w(TAG, "Failed to set " + value + " to " + fieldName, e);
}
return false;
}
/**
* Sets the click listener to the given {@link View}.
*
* @param view The View to set the listener to
* @param listener The listener
*/
private static void setClickListener(@Nullable final View view, @Nullable final View.OnClickListener listener) {
if (view != null && listener != null) {
view.setOnClickListener(listener);
} else {
throw new IllegalArgumentException("Cannot set a click listener " + listener + " to " + view);
}
}
/**
* Sets the long-click listener to the given {@link View}.
*
* @param view The View to set the listener to
* @param listener The listener
*/
private static void setLongClickListener(@Nullable final View view, @Nullable final View.OnLongClickListener listener) {
if (view != null && listener != null) {
view.setOnLongClickListener(listener);
} else {
throw new IllegalArgumentException("Cannot set a long-click listener " + listener + " to " + view);
}
}
// </editor-fold>
}