package com.android.pc.ioc.verification; import java.lang.annotation.Annotation; import java.lang.reflect.Field; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import android.os.AsyncTask; import android.util.Log; import android.view.View; import android.widget.TextView; import com.android.pc.ioc.verification.annotation.Checked; import com.android.pc.ioc.verification.annotation.ConfirmPassword; import com.android.pc.ioc.verification.annotation.Email; import com.android.pc.ioc.verification.annotation.IpAddress; import com.android.pc.ioc.verification.annotation.NumberRule; import com.android.pc.ioc.verification.annotation.Password; import com.android.pc.ioc.verification.annotation.Regex; import com.android.pc.ioc.verification.annotation.Required; import com.android.pc.ioc.verification.annotation.TextRule; /** * A processor that checks all the {@link Rule}s against their {@link View}s. * * @author Ragunath Jawahar <rj@mobsandgeeks.com> */ public class Validator { // Debug static final String TAG = Validator.class.getSimpleName(); static final boolean DEBUG = false; private Object mController; private boolean mAnnotationsProcessed; private List<ViewRulePair> mViewsAndRules; private Map<String, Object> mProperties; private AsyncTask<Void, Void, ViewRulePair> mAsyncValidationTask; private ValidationListener mValidationListener; /** * Private constructor. Cannot be instantiated. */ private Validator() { mAnnotationsProcessed = false; mViewsAndRules = new ArrayList<Validator.ViewRulePair>(); mProperties = new HashMap<String, Object>(); } /** * Creates a new {@link Validator}. * * @param controller * The instance that holds references to the Views that are being validated. Usually an {@code Activity} or a {@code Fragment}. Also accepts controller instances that have annotated {@code View} references. */ public Validator(Object controller) { this(); if (controller == null) { throw new IllegalArgumentException("'controller' cannot be null"); } mController = controller; } /** * Interface definition for a callback to be invoked when {@code validate()} is called. */ public interface ValidationListener { /** * Called when all the {@link Rule}s added to this Validator are valid. */ public void onValidationSucceeded(); /** * Called if any of the {@link Rule}s fail. * * @param failedView * The {@link View} that did not pass validation. * @param failedRule * The failed {@link Rule} associated with the {@link View}. */ public void onValidationFailed(View failedView, Rule<?> failedRule); } /** * Add a {@link View} and it's associated {@link Rule} to the Validator. * * @param view * The {@link View} to be validated. * @param rule * The {@link Rule} associated with the view. * * @throws IllegalArgumentException * If {@code rule} is {@code null}. */ public void put(View view, Rule<?> rule) { if (rule == null) { throw new IllegalArgumentException("'rule' cannot be null"); } mViewsAndRules.add(new ViewRulePair(view, rule)); } /** * Convenience method for adding multiple {@link Rule}s for a single {@link View}. * * @param view * The {@link View} to be validated. * @param rules * {@link List} of {@link Rule}s associated with the view. * * @throws IllegalArgumentException * If {@code rules} is {@code null}. */ public void put(View view, List<Rule<?>> rules) { if (rules == null) { throw new IllegalArgumentException("\'rules\' cannot be null"); } for (Rule<?> rule : rules) { put(view, rule); } } /** * Convenience method for adding just {@link Rule}s to the Validator. * * @param rule * A {@link Rule}, usually composite or custom. */ public void put(Rule<?> rule) { put(null, rule); } /** * Validate all the {@link Rule}s against their {@link View}s. * * @throws IllegalStateException * If a {@link ValidationListener} is not registered. */ public synchronized void validate() { if (mValidationListener == null) { throw new IllegalStateException("Set a " + ValidationListener.class.getSimpleName() + " before attempting to validate."); } ViewRulePair failedViewRulePair = validateAllRules(); if (failedViewRulePair == null) { mValidationListener.onValidationSucceeded(); } else { mValidationListener.onValidationFailed(failedViewRulePair.view, failedViewRulePair.rule); } } /** * Asynchronously validates all the {@link Rule}s against their {@link View}s. Subsequent calls to this method will cancel any pending asynchronous validations and start a new one. * * @throws IllegalStateException * If a {@link ValidationListener} is not registered. */ public void validateAsync() { if (mValidationListener == null) { throw new IllegalStateException("Set a " + ValidationListener.class.getSimpleName() + " before attempting to validate."); } // Cancel the existing task if (mAsyncValidationTask != null) { mAsyncValidationTask.cancel(true); mAsyncValidationTask = null; } // Start a new one ;) mAsyncValidationTask = new AsyncTask<Void, Void, ViewRulePair>() { @Override protected ViewRulePair doInBackground(Void... params) { return validateAllRules(); } @Override protected void onPostExecute(ViewRulePair pair) { if (pair == null) { mValidationListener.onValidationSucceeded(); } else { mValidationListener.onValidationFailed(pair.view, pair.rule); } mAsyncValidationTask = null; } @Override protected void onCancelled() { mAsyncValidationTask = null; } }; mAsyncValidationTask.execute((Void[]) null); } /** * Used to find if the asynchronous validation task is running, useful only when you run the Validator in asynchronous mode using the {@code validateAsync} method. * * @return True if the asynchronous task is running, false otherwise. */ public boolean isValidating() { return mAsyncValidationTask != null && mAsyncValidationTask.getStatus() != AsyncTask.Status.FINISHED; } /** * Cancels the asynchronous validation task if running, useful only when you run the Validator in asynchronous mode using the {@code validateAsync} method. * * @return True if the asynchronous task was cancelled. */ public boolean cancelAsync() { boolean cancelled = false; if (mAsyncValidationTask != null) { cancelled = mAsyncValidationTask.cancel(true); mAsyncValidationTask = null; } return cancelled; } /** * Returns the callback registered for this Validator. * * @return The callback, or null if one is not registered. */ public ValidationListener getValidationListener() { return mValidationListener; } /** * Register a callback to be invoked when {@code validate()} is called. * * @param validationListener * The callback that will run. */ public void setValidationListener(ValidationListener validationListener) { this.mValidationListener = validationListener; } /** * Updates a property value if it exists, else creates a new one. * * @param name * The property name. * @param value * Value of the property. * * @throws IllegalArgumentException * If {@code name} is {@code null}. */ public void setProperty(String name, Object value) { if (name == null) { throw new IllegalArgumentException("\'name\' cannot be null"); } mProperties.put(name, value); } /** * Retrieves the value of the given property. * * @param name * The property name. * * @throws IllegalArgumentException * If {@code name} is {@code null}. * * @return Value of the property or {@code null} if the property does not exist. */ public Object getProperty(String name) { if (name == null) { throw new IllegalArgumentException("\'name\' cannot be null"); } return mProperties.get(name); } /** * Removes the property from this Validator. * * @param name * The property name. * * @return The value of the removed property or {@code null} if the property was not found. */ public Object removeProperty(String name) { return name != null ? mProperties.remove(name) : null; } /** * Checks if the specified property exists in this Validator. * * @param name * The property name. * * @return True if the property exists. */ public boolean containsProperty(String name) { return name != null ? mProperties.containsKey(name) : false; } /** * Removes all properties from this Validator. */ public void removeAllProperties() { mProperties.clear(); } /** * Removes all the rules for the matching {@link View} * * @param view * The {@code View} whose rules must be removed. */ public void removeRulesFor(View view) { if (view == null) { throw new IllegalArgumentException("'view' cannot be null"); } int index = 0; while (index < mViewsAndRules.size()) { ViewRulePair pair = mViewsAndRules.get(index); if (pair.view == view) { mViewsAndRules.remove(index); continue; } index++; } } /** * Validates all rules added to this Validator. * * @return {@code null} if all {@code Rule}s are valid, else returns the failed {@code ViewRulePair}. */ private ViewRulePair validateAllRules() { if (!mAnnotationsProcessed) { createRulesFromAnnotations(getSaripaarAnnotatedFields()); mAnnotationsProcessed = true; } if (mViewsAndRules.size() == 0) { Log.i(TAG, "No rules found. Passing validation by default."); return null; } ViewRulePair failedViewRulePair = null; for (ViewRulePair pair : mViewsAndRules) { if (pair == null) continue; // Validate views only if they are visible and enabled if (pair.view != null) { if (!pair.view.isShown() || !pair.view.isEnabled()) continue; } if (!pair.rule.isValid(pair.view)) { failedViewRulePair = pair; break; } } return failedViewRulePair; } private void createRulesFromAnnotations(List<AnnotationFieldPair> annotationFieldPairs) { TextView passwordTextView = null; TextView confirmPasswordTextView = null; for (AnnotationFieldPair pair : annotationFieldPairs) { // Password if (pair.annotation.annotationType().equals(Password.class)) { if (passwordTextView == null) { passwordTextView = (TextView) getView(pair.field); } else { throw new IllegalStateException("You cannot annotate " + "two fields in the same Activity with @Password."); } } // Confirm password if (pair.annotation.annotationType().equals(ConfirmPassword.class)) { if (passwordTextView == null) { throw new IllegalStateException("A @Password annotated field is required " + "before you can use @ConfirmPassword."); } else if (confirmPasswordTextView != null) { throw new IllegalStateException("You cannot annotate " + "two fields in the same Activity with @ConfirmPassword."); } else if (confirmPasswordTextView == null) { confirmPasswordTextView = (TextView) getView(pair.field); } } // Others ViewRulePair viewRulePair = null; if (pair.annotation.annotationType().equals(ConfirmPassword.class)) { viewRulePair = getViewAndRule(pair.field, pair.annotation, passwordTextView); } else { viewRulePair = getViewAndRule(pair.field, pair.annotation); } if (viewRulePair != null) { if (DEBUG) { Log.d(TAG, String.format("Added @%s rule for %s.", pair.annotation.annotationType().getSimpleName(), pair.field.getName())); } mViewsAndRules.add(viewRulePair); } } } private ViewRulePair getViewAndRule(Field field, Annotation annotation, Object... params) { View view = getView(field); if (view == null) { Log.w(TAG, String.format("Your %s - %s is null. Please check your field assignment(s).", field.getType().getSimpleName(), field.getName())); return null; } Rule<?> rule = null; if (params != null && params.length > 0) { rule = AnnotationToRuleConverter.getRule(field, view, annotation, params); } else { rule = AnnotationToRuleConverter.getRule(field, view, annotation); } return rule != null ? new ViewRulePair(view, rule) : null; } private View getView(Field field) { try { field.setAccessible(true); Object instance = mController; return (View) field.get(instance); } catch (IllegalArgumentException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } return null; } private List<AnnotationFieldPair> getSaripaarAnnotatedFields() { List<AnnotationFieldPair> annotationFieldPairs = new ArrayList<AnnotationFieldPair>(); List<Field> fieldsWithAnnotations = getViewFieldsWithAnnotations(); for (Field field : fieldsWithAnnotations) { Annotation[] annotations = field.getAnnotations(); for (Annotation annotation : annotations) { if (isSaripaarAnnotation(annotation)) { annotationFieldPairs.add(new AnnotationFieldPair(annotation, field)); } } } Collections.sort(annotationFieldPairs, new AnnotationFieldPairCompartor()); return annotationFieldPairs; } private List<Field> getViewFieldsWithAnnotations() { List<Field> fieldsWithAnnotations = new ArrayList<Field>(); List<Field> viewFields = getAllViewFields(); for (Field field : viewFields) { Annotation[] annotations = field.getAnnotations(); if (annotations == null || annotations.length == 0) { continue; } fieldsWithAnnotations.add(field); } return fieldsWithAnnotations; } private List<Field> getAllViewFields() { List<Field> viewFields = new ArrayList<Field>(); // Declared fields Class<?> superClass = null; if (mController != null) { viewFields.addAll(getDeclaredViewFields(mController.getClass())); superClass = mController.getClass().getSuperclass(); } // Inherited fields while (superClass != null && !superClass.equals(Object.class)) { List<Field> declaredViewFields = getDeclaredViewFields(superClass); if (declaredViewFields.size() > 0) { viewFields.addAll(declaredViewFields); } superClass = superClass.getSuperclass(); } return viewFields; } private List<Field> getDeclaredViewFields(Class<?> clazz) { List<Field> viewFields = new ArrayList<Field>(); Field[] declaredFields = clazz.getDeclaredFields(); for (Field f : declaredFields) { if (View.class.isAssignableFrom(f.getType())) { viewFields.add(f); } } return viewFields; } private boolean isSaripaarAnnotation(Annotation annotation) { Class<?> annotationType = annotation.annotationType(); return annotationType.equals(Checked.class) || annotationType.equals(ConfirmPassword.class) || annotationType.equals(Email.class) || annotationType.equals(IpAddress.class) || annotationType.equals(NumberRule.class) || annotationType.equals(Password.class) || annotationType.equals(Regex.class) || annotationType.equals(Required.class) || annotationType.equals(TextRule.class); } private class ViewRulePair { public View view; public Rule rule; public ViewRulePair(View view, Rule<?> rule) { this.view = view; this.rule = rule; } } private class AnnotationFieldPair { public Annotation annotation; public Field field; public AnnotationFieldPair(Annotation annotation, Field field) { this.annotation = annotation; this.field = field; } } private class AnnotationFieldPairCompartor implements Comparator<AnnotationFieldPair> { @Override public int compare(AnnotationFieldPair lhs, AnnotationFieldPair rhs) { int lhsOrder = getAnnotationOrder(lhs.annotation); int rhsOrder = getAnnotationOrder(rhs.annotation); return lhsOrder < rhsOrder ? -1 : lhsOrder == rhsOrder ? 0 : 1; } private int getAnnotationOrder(Annotation annotation) { Class<?> annotatedClass = annotation.annotationType(); if (annotatedClass.equals(Checked.class)) { return ((Checked) annotation).order(); } else if (annotatedClass.equals(ConfirmPassword.class)) { return ((ConfirmPassword) annotation).order(); } else if (annotatedClass.equals(Email.class)) { return ((Email) annotation).order(); } else if (annotatedClass.equals(IpAddress.class)) { return ((IpAddress) annotation).order(); } else if (annotatedClass.equals(NumberRule.class)) { return ((NumberRule) annotation).order(); } else if (annotatedClass.equals(Password.class)) { return ((Password) annotation).order(); } else if (annotatedClass.equals(Regex.class)) { return ((Regex) annotation).order(); } else if (annotatedClass.equals(Required.class)) { return ((Required) annotation).order(); } else if (annotatedClass.equals(TextRule.class)) { return ((TextRule) annotation).order(); } else { throw new IllegalArgumentException(String.format("%s is not a Saripaar annotation", annotatedClass.getName())); } } } }