diff --git a/common-src/com/todoroo/andlib/data/AbstractDatabase.java b/common-src/com/todoroo/andlib/data/AbstractDatabase.java new file mode 100644 index 000000000..ac68b5e5c --- /dev/null +++ b/common-src/com/todoroo/andlib/data/AbstractDatabase.java @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2009, Todoroo Inc + * All Rights Reserved + * http://www.todoroo.com + */ +package com.todoroo.andlib.data; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteDatabase.CursorFactory; +import android.util.Log; + +import com.todoroo.andlib.data.Property.PropertyVisitor; +import com.todoroo.andlib.service.ContextManager; + +/** + * AbstractDatabase is a database abstraction which wraps a SQLite database. + *

+ * Users of this class are in charge of the database's lifecycle - ensuring that + * the database is open when needed and closed when usage is finished. Within an + * activity, this is typically accomplished through the onResume and onPause + * methods, though if the database is not needed for the activity's entire + * lifecycle, it can be closed earlier. + *

+ * Direct querying is not recommended for type safety reasons. Instead, use one + * of the service classes to issue the request and return a {@link TodorooCursor}. + * + * @author Tim Su + * + */ +@SuppressWarnings("nls") +abstract public class AbstractDatabase { + + // --- abstract methods + + /** + * @return database name + */ + protected abstract String getName(); + + /** + * @return all tables in this database + */ + protected abstract Table[] getTables(); + + /** + * @return database version + */ + protected abstract int getVersion(); + + /** + * Called after database and tables are created. Use this method to + * create indices and perform other database maintenance + */ + protected abstract void onCreateTables(); + + /** + * Upgrades an open database from one version to the next + * @param oldVersion + * @param newVersion + * @return true if upgrade was handled, false otherwise + */ + protected abstract boolean onUpgrade(int oldVersion, int newVersion); + + // --- protected variables + + /** + * SQLiteOpenHelper that takes care of database operations + */ + protected SQLiteOpenHelper helper = null; + + /** + * Internal pointer to open database. Hides the fact that there is a + * database and a wrapper by making a single monolithic interface + */ + protected SQLiteDatabase database = null; + + // --- internal implementation + + /** + * Return the name of the table containing these models + * @param modelType + * @return + */ + public final Table getTable(Class modelType) { + for(Table table : getTables()) { + if(table.modelClass.equals(modelType)) + return table; + } + throw new UnsupportedOperationException("Unknown model class " + modelType); //$NON-NLS-1$ + } + + protected final void initializeHelper() { + if(helper == null) + helper = new DatabaseHelper(ContextManager.getContext(), + getName(), null, getVersion()); + } + + /** + * Open the database for writing. Must be closed afterwards. If user is + * out of disk space, database may be opened for reading instead + */ + public synchronized final void openForWriting() { + initializeHelper(); + + try { + database = helper.getWritableDatabase(); + } catch (SQLiteException writeException) { + Log.e("database-" + getName(), "Error opening db", + writeException); + try { + // provide read-only database + openForReading(); + } catch (SQLiteException readException) { + // throw original write exception + throw writeException; + } + } + } + + /** + * Open the database for reading. Must be closed afterwards + */ + public synchronized final void openForReading() { + initializeHelper(); + database = helper.getReadableDatabase(); + } + + /** + * Close the database if it has been opened previously + */ + public synchronized final void close() { + if(database != null) + database.close(); + database = null; + } + + /** + * Clear all data in database. Warning: this does what it says. Any open + * database resources will be abruptly closed. + */ + public synchronized final void clear() { + close(); + ContextManager.getContext().deleteDatabase(getName()); + } + + /** + * @return sql database. throws error if database was not opened + */ + public final SQLiteDatabase getDatabase() { + if(database == null) + throw new IllegalStateException("Database was not opened!"); + return database; + } + + // --- helper classes + + /** + * Default implementation of Astrid database helper + */ + private class DatabaseHelper extends SQLiteOpenHelper { + + public DatabaseHelper(Context context, String name, + CursorFactory factory, int version) { + super(context, name, factory, version); + } + + /** + * Called to create the database tables + */ + @Override + public synchronized void onCreate(SQLiteDatabase db) { + StringBuilder sql = new StringBuilder(); + SqlConstructorVisitor sqlVisitor = new SqlConstructorVisitor(); + + // create tables + for(Table table : getTables()) { + sql.append("CREATE TABLE IF NOT EXISTS ").append(table.name).append('('). + append(AbstractModel.ID_PROPERTY).append(" INTEGER PRIMARY KEY AUTOINCREMENT"); + for(Property property : table.getProperties()) { + if(AbstractModel.ID_PROPERTY.name.equals(property.name)) + continue; + sql.append(',').append(property.accept(sqlVisitor, null)); + } + sql.append(')'); + db.execSQL(sql.toString()); + sql.setLength(0); + } + + // post-table-creation + database = db; + onCreateTables(); + } + + /** + * Called to upgrade the database to a new version + */ + @Override + public synchronized void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + Log.w("database-" + getName(), String.format("Upgrading database from version %d to %d.", + oldVersion, newVersion)); + + database = db; + if(!AbstractDatabase.this.onUpgrade(oldVersion, newVersion)) { + // We don't know how to handle this case because someone forgot to + // implement the upgrade. We can't drop tables, we can only + // throw a nasty exception at this time + + throw new IllegalStateException("Missing database migration " + + "from " + oldVersion + " to " + newVersion); + } + } + } + + /** + * Visitor that returns SQL constructor for this property + * + * @author Tim Su + * + */ + public static class SqlConstructorVisitor implements PropertyVisitor { + + public String visitDouble(Property property, Void data) { + return String.format("%s REAL", property.name); + } + + public String visitInteger(Property property, Void data) { + return String.format("%s INTEGER", property.name); + } + + public String visitLong(Property property, Void data) { + return String.format("%s INTEGER", property.name); + } + + public String visitString(Property property, Void data) { + return String.format("%s TEXT", property.name); + } + } +} + diff --git a/common-src/com/todoroo/andlib/data/AbstractModel.java b/common-src/com/todoroo/andlib/data/AbstractModel.java new file mode 100644 index 000000000..61682c9e1 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/AbstractModel.java @@ -0,0 +1,354 @@ +/* + * Copyright (c) 2009, Todoroo Inc + * All Rights Reserved + * http://www.todoroo.com + */ +package com.todoroo.andlib.data; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; + +import android.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; + +import com.todoroo.andlib.data.Property.IntegerProperty; +import com.todoroo.andlib.data.Property.LongProperty; +import com.todoroo.andlib.data.Property.PropertyVisitor; + +/** + * AbstractModel represents a row in a database. + *

+ * A single database can be represented by multiple AbstractModels + * corresponding to different queries that return a different set of columns. + * Each model exposes a set of properties that it contains. + * + * @author Tim Su + * + */ +public abstract class AbstractModel implements Parcelable { + + // --- static variables + + private static final ContentValuesSavingVisitor saver = new ContentValuesSavingVisitor(); + + // --- constants + + /** id property common to all models */ + protected static final String ID_PROPERTY_NAME = "_id"; //$NON-NLS-1$ + + /** id field common to all models */ + public static final IntegerProperty ID_PROPERTY = new IntegerProperty(null, ID_PROPERTY_NAME); + + /** sentinel for objects without an id */ + public static final long NO_ID = 0; + + // --- abstract methods + + /** Get the default values for this object */ + abstract public ContentValues getDefaultValues(); + + // --- data store variables and management + + /* Data Source Ordering: + * + * In order to return the best data, we want to check first what the user + * has explicitly set (setValues), then the values we have read out of + * the database (values), then defaults (getDefaultValues) + */ + + /** User set values */ + protected ContentValues setValues = null; + + /** Values from database */ + protected ContentValues values = null; + + /** Get database-read values for this object */ + public ContentValues getDatabaseValues() { + return values; + } + + /** Get the user-set values for this object */ + public ContentValues getSetValues() { + return setValues; + } + + /** Get a list of all field/value pairs merged across data sources */ + public ContentValues getMergedValues() { + ContentValues mergedValues = new ContentValues(); + + ContentValues defaultValues = getDefaultValues(); + if(defaultValues != null) + mergedValues.putAll(defaultValues); + if(values != null) + mergedValues.putAll(values); + if(setValues != null) + mergedValues.putAll(setValues); + + return mergedValues; + } + + /** + * Clear all data on this model + */ + public void clear() { + values = null; + setValues = null; + } + + /** + * Use merged values to compare two models to each other. Must be of + * exactly the same class. + */ + @Override + public boolean equals(Object other) { + if(other == null || other.getClass() != getClass()) + return false; + + return getMergedValues().equals(((AbstractModel)other).getMergedValues()); + } + + @Override + public int hashCode() { + return getMergedValues().hashCode() ^ getClass().hashCode(); + } + + // --- data retrieval + + /** + * Reads all properties from the supplied cursor and store + */ + protected synchronized void readPropertiesFromCursor(TodorooCursor cursor) { + if (values == null) + values = new ContentValues(); + + // clears user-set values + setValues = null; + + for (Property property : cursor.getProperties()) { + saver.save(property, values, cursor.get(property)); + } + } + + /** + * Reads the given property. Make sure this model has this property! + */ + public TYPE getValue(Property property) { + if(setValues != null && setValues.containsKey(property.name)) + return (TYPE)setValues.get(property.name); + + if(values != null && values.containsKey(property.name)) + return (TYPE)values.get(property.name); + + if(getDefaultValues().containsKey(property.name)) + return (TYPE)getDefaultValues().get(property.name); + + throw new UnsupportedOperationException( + "Model Error: Did not read property " + property.name); //$NON-NLS-1$ + } + + /** + * Utility method to get the identifier of the model, if it exists. + * Returns 0 + */ + abstract public long getId(); + + protected long getIdHelper(LongProperty id) { + if(setValues != null && setValues.containsKey(id.name)) + return setValues.getAsLong(id.name); + else if(values != null && values.containsKey(id.name)) + return values.getAsLong(id.name); + else + return NO_ID; + } + + public void setId(long id) { + if (setValues == null) + setValues = new ContentValues(); + + if(id == NO_ID) + setValues.remove(ID_PROPERTY_NAME); + else + setValues.put(ID_PROPERTY_NAME, id); + } + + // --- data storage + + /** + * Check whether the user has changed this property value and it should be + * stored for saving in the database + */ + protected synchronized boolean shouldSaveValue( + Property property, TYPE newValue) { + + // we've already decided to save it, so overwrite old value + if (setValues.containsKey(property.name)) + return true; + + // values contains this key, we should check it out + if(values != null && values.containsKey(property.name)) { + TYPE value = getValue(property); + if (value == null) { + if (newValue == null) + return false; + } else if (value.equals(newValue)) + return false; + } + + // otherwise, good to save + return true; + } + + /** + * Sets the given property. Make sure this model has this property! + */ + public synchronized void setValue(Property property, + TYPE value) { + if (setValues == null) + setValues = new ContentValues(); + if (!shouldSaveValue(property, value)) + return; + + saver.save(property, setValues, value); + } + + /** + * Clear the key for the given property + * @param property + */ + public synchronized void clearValue(Property property) { + if(setValues != null) + setValues.remove(property.name); + } + + // --- property management + + /** + * Looks inside the given class and finds all declared properties + */ + protected static Property[] generateProperties(Class cls) { + ArrayList> properties = new ArrayList>(); + if(cls.getSuperclass() != AbstractModel.class) + properties.addAll(Arrays.asList(generateProperties( + (Class) cls.getSuperclass()))); + + // a property is public, static & extends Property + for(Field field : cls.getFields()) { + if((field.getModifiers() & Modifier.STATIC) == 0) + continue; + if(!Property.class.isAssignableFrom(field.getType())) + continue; + try { + properties.add((Property) field.get(null)); + } catch (IllegalArgumentException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + return properties.toArray(new Property[properties.size()]); + } + + /** + * Visitor that saves a value into a content values store + * + * @author Tim Su + * + */ + public static class ContentValuesSavingVisitor implements PropertyVisitor { + + private ContentValues store; + + public synchronized void save(Property property, ContentValues newStore, Object value) { + this.store = newStore; + property.accept(this, value); + } + + public Void visitDouble(Property property, Object value) { + store.put(property.name, (Double) value); + return null; + } + + public Void visitInteger(Property property, Object value) { + store.put(property.name, (Integer) value); + return null; + } + + public Void visitLong(Property property, Object value) { + store.put(property.name, (Long) value); + return null; + } + + public Void visitString(Property property, Object value) { + store.put(property.name, (String) value); + return null; + } + } + + // --- parcelable helpers + + /** + * {@inheritDoc} + */ + public int describeContents() { + return 0; + } + + /** + * {@inheritDoc} + */ + public void writeToParcel(Parcel dest, int flags) { + dest.writeParcelable(setValues, 0); + dest.writeParcelable(values, 0); + } + + /** + * In addition to overriding this class, model classes should create + * a static final variable named "CREATOR" in order to satisfy the + * requirements of the Parcelable interface. + */ + abstract protected Parcelable.Creator getCreator(); + + /** + * Parcelable creator helper + */ + protected static final class ModelCreator + implements Parcelable.Creator { + + private Class cls; + + public ModelCreator(Class cls) { + super(); + this.cls = cls; + } + + /** + * {@inheritDoc} + */ + public TYPE createFromParcel(Parcel source) { + TYPE model; + try { + model = cls.newInstance(); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InstantiationException e) { + throw new RuntimeException(e); + } + model.setValues = source.readParcelable(ContentValues.class.getClassLoader()); + model.values = source.readParcelable(ContentValues.class.getClassLoader()); + return model; + } + + /** + * {@inheritDoc} + */ + public TYPE[] newArray(int size) { + return (TYPE[]) Array.newInstance(cls, size); + }; + }; + +} diff --git a/common-src/com/todoroo/andlib/data/GenericDao.java b/common-src/com/todoroo/andlib/data/GenericDao.java new file mode 100644 index 000000000..7fe7b38f5 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/GenericDao.java @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2009, Todoroo Inc + * All Rights Reserved + * http://www.todoroo.com + */ +package com.todoroo.andlib.data; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; + +import android.content.ContentValues; +import android.database.Cursor; + +import com.todoroo.andlib.data.sql.Criterion; +import com.todoroo.andlib.data.sql.Query; + + + +/** + * Abstract data access object + * + * @author Tim Su + * + */ +public class GenericDao { + + private Class modelClass; + + private Table table; + + private AbstractDatabase database; + + public GenericDao(Class modelClass) { + this.modelClass = modelClass; + } + + public GenericDao(Class modelClass, AbstractDatabase database) { + this.modelClass = modelClass; + setDatabase(database); + } + + /** + * Sets up a database + * @param database + */ + protected void setDatabase(AbstractDatabase database) { + this.database = database; + table = database.getTable(modelClass); + } + + // --- dao methods + + /** + * Construct a query with SQL DSL objects + * @param database + * @param properties + * @param builder + * @param where + * @param groupBy + * @param sortOrder + * @return + */ + public TodorooCursor query(Query query) { + query.from(table); + Cursor cursor = database.getDatabase().rawQuery(query.toString(), null); + return new TodorooCursor(cursor, query.getFields()); + } + + /** + * Returns object corresponding to the given identifier + * + * @param database + * @param table + * name of table + * @param properties + * properties to read + * @param id + * id of item + * @return + */ + public TYPE fetch(long id, Property... properties) { + TodorooCursor cursor = fetchItem(id, properties); + try { + if (cursor.getCount() == 0) + return null; + Constructor constructor = modelClass.getConstructor(TodorooCursor.class); + return constructor.newInstance(cursor); + } catch (SecurityException e) { + throw new RuntimeException(e); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } catch (IllegalArgumentException e) { + throw new RuntimeException(e); + } catch (InstantiationException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (InvocationTargetException e) { + throw new RuntimeException(e); + } finally { + cursor.close(); + } + } + + /** + * Delete the given id + * + * @param database + * @param id + * @return true if delete was successful + */ + public boolean delete(long id) { + return database.getDatabase().delete(table.name, + AbstractModel.ID_PROPERTY.eq(id).toString(), null) > 0; + } + + /** + * Delete all matching a clause + * @param database + * @param where + * @return # of deleted items + */ + public int deleteWhere(Criterion where) { + return database.getDatabase().delete(table.name, + where.toString(), null); + } + + /** + * Save the given object to the database. Creates a new object if + * model id property has not been set + * + * @return true on success. + */ + public boolean persist(AbstractModel item) { + if (item.getId() == AbstractModel.NO_ID) { + return createItem(item); + } else { + ContentValues values = item.getSetValues(); + + if (values.size() == 0) // nothing changed + return true; + + return saveItem(item); + } + } + + /** + * Creates the given item. + * + * @param database + * @param table + * table name + * @param item + * item model + * @return returns true on success. + */ + public boolean createItem(AbstractModel item) { + long newRow = database.getDatabase().insert(table.name, + AbstractModel.ID_PROPERTY.name, item.getMergedValues()); + item.setId(newRow); + return newRow >= 0; + } + + /** + * Saves the given item. + * + * @param database + * @param table + * table name + * @param item + * item model + * @return returns true on success. + */ + public boolean saveItem(AbstractModel item) { + ContentValues values = item.getSetValues(); + if(values.size() == 0) // nothing changed + return true; + return database.getDatabase().update(table.name, values, + AbstractModel.ID_PROPERTY.eq(item.getId()).toString(), null) > 0; + } + + // --- helper methods + + + /** + * Returns cursor to object corresponding to the given identifier + * + * @param database + * @param table + * name of table + * @param properties + * properties to read + * @param id + * id of item + * @return + */ + protected TodorooCursor fetchItem(long id, Property... properties) { + TodorooCursor cursor = query( + Query.select(properties).where(AbstractModel.ID_PROPERTY.eq(id))); + cursor.moveToFirst(); + return new TodorooCursor(cursor, properties); + } +} diff --git a/common-src/com/todoroo/andlib/data/Property.java b/common-src/com/todoroo/andlib/data/Property.java new file mode 100644 index 000000000..6c87373ae --- /dev/null +++ b/common-src/com/todoroo/andlib/data/Property.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2009, Todoroo Inc + * All Rights Reserved + * http://www.todoroo.com + */ +package com.todoroo.andlib.data; + +import com.todoroo.andlib.data.sql.Field; + +/** + * Property represents a typed column in a database. + * + * Within a given database row, the parameter may not exist, in which case the + * value is null, it may be of an incorrect type, in which case an exception is + * thrown, or the correct type, in which case the value is returned. + * + * @author Tim Su + * + * @param + * a database supported type, such as String or Integer + */ +@SuppressWarnings("nls") +public abstract class Property extends Field implements Cloneable { + + // --- implementation + + /** The database table name this property */ + public final Table table; + + /** The database column name for this property */ + public final String name; + + /** + * Create a property by table and column name. Uses the default property + * expression which is derived from default table name + */ + protected Property(Table table, String columnName) { + this(table, columnName, (table == null) ? (columnName) : (table.name + "." + columnName)); + } + + /** + * Create a property by table and column name, manually specifying an + * expression to use in SQL + */ + protected Property(Table table, String columnName, String expression) { + super(expression); + this.table = table; + this.name = columnName; + } + + /** + * Accept a visitor + */ + abstract public RETURN accept( + PropertyVisitor visitor, PARAMETER data); + + /** + * Return a clone of this property + */ + @Override + public Property clone() { + try { + return (Property) super.clone(); + } catch (CloneNotSupportedException e) { + throw new RuntimeException(e); + } + } + + // --- helper classes and interfaces + + /** + * Visitor interface for property classes + * + * @author Tim Su + * + */ + public interface PropertyVisitor { + public RETURN visitInteger(Property property, PARAMETER data); + + public RETURN visitLong(Property property, PARAMETER data); + + public RETURN visitDouble(Property property, PARAMETER data); + + public RETURN visitString(Property property, PARAMETER data); + } + + // --- children + + /** + * Integer property type. See {@link Property} + * + * @author Tim Su + * + */ + public static class IntegerProperty extends Property { + + public IntegerProperty(Table table, String name) { + super(table, name); + } + + protected IntegerProperty(Table table, String name, String expression) { + super(table, name, expression); + } + + @Override + public RETURN accept( + PropertyVisitor visitor, PARAMETER data) { + return visitor.visitInteger(this, data); + } + } + + /** + * String property type. See {@link Property} + * + * @author Tim Su + * + */ + public static class StringProperty extends Property { + + public StringProperty(Table table, String name) { + super(table, name); + } + + protected StringProperty(Table table, String name, String expression) { + super(table, name, expression); + } + + @Override + public RETURN accept( + PropertyVisitor visitor, PARAMETER data) { + return visitor.visitString(this, data); + } + } + + /** + * Double property type. See {@link Property} + * + * @author Tim Su + * + */ + public static class DoubleProperty extends Property { + + public DoubleProperty(Table table, String name) { + super(table, name); + } + + protected DoubleProperty(Table table, String name, String expression) { + super(table, name, expression); + } + + + @Override + public RETURN accept( + PropertyVisitor visitor, PARAMETER data) { + return visitor.visitDouble(this, data); + } + } + + /** + * Long property type. See {@link Property} + * + * @author Tim Su + * + */ + public static class LongProperty extends Property { + + public LongProperty(Table table, String name) { + super(table, name); + } + + protected LongProperty(Table table, String name, String expression) { + super(table, name, expression); + } + + @Override + public RETURN accept( + PropertyVisitor visitor, PARAMETER data) { + return visitor.visitLong(this, data); + } + } + + // --- pseudo-properties + + /** Runs a SQL function and returns the result as a string */ + public static class StringFunctionProperty extends StringProperty { + public StringFunctionProperty(String function, String columnName) { + super(null, columnName, function + " AS " + columnName); + } + } + + /** Runs a SQL function and returns the result as a string */ + public static class IntegerFunctionProperty extends IntegerProperty { + public IntegerFunctionProperty(String function, String columnName) { + super(null, columnName, function + " AS " + columnName); + } + } + + /** Counting in aggregated tables. Returns the result of COUNT(1) */ + public static final class CountProperty extends IntegerFunctionProperty { + public CountProperty() { + super("COUNT(1)", "count"); + } + } + +} diff --git a/common-src/com/todoroo/andlib/data/Table.java b/common-src/com/todoroo/andlib/data/Table.java new file mode 100644 index 000000000..ae8d59274 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/Table.java @@ -0,0 +1,67 @@ +package com.todoroo.andlib.data; + +import com.todoroo.andlib.data.sql.Field; + +/** + * Table class. Most fields are final, so methods such as as will + * clone the table when it returns. + * + * @author Tim Su + * + */ +public final class Table extends com.todoroo.andlib.data.sql.Table { + public final String name; + public final Class modelClass; + + public Table(String name, Class modelClass) { + this(name, modelClass, null); + } + + public Table(String name, Class modelClass, String alias) { + super(name); + this.name = name; + this.alias = alias; + this.modelClass = modelClass; + } + + /** + * Reads a list of properties from model class by reflection + * @return property array + */ + @SuppressWarnings("nls") + public Property[] getProperties() { + try { + return (Property[])modelClass.getField("PROPERTIES").get(null); + } catch (IllegalArgumentException e) { + throw new RuntimeException(e); + } catch (SecurityException e) { + throw new RuntimeException(e); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } catch (NoSuchFieldException e) { + throw new RuntimeException(e); + } + } + + // --- for sql-dsl + + /** + * Create a new join table based on this table, but with an alias + */ + @Override + public Table as(String newAlias) { + return new Table(name, modelClass, newAlias); + } + + /** + * Create a field object based on the given property + * @param property + * @return + */ + @SuppressWarnings("nls") + public Field field(Property property) { + if(alias != null) + return Field.field(alias + "." + property.name); + return Field.field(name + "." + property.name); + } +} \ No newline at end of file diff --git a/common-src/com/todoroo/andlib/data/TodorooCursor.java b/common-src/com/todoroo/andlib/data/TodorooCursor.java new file mode 100644 index 000000000..4ef2c8ccc --- /dev/null +++ b/common-src/com/todoroo/andlib/data/TodorooCursor.java @@ -0,0 +1,109 @@ +/** + * See the file "LICENSE" for the full license governing this code. + */ +package com.todoroo.andlib.data; + +import java.util.WeakHashMap; + +import android.database.Cursor; +import android.database.CursorWrapper; + +import com.todoroo.andlib.data.Property.PropertyVisitor; + +/** + * AstridCursor wraps a cursor and allows users to query for individual + * {@link Property} types or read an entire {@link AbstractModel} from + * a database row. + * + * @author Tim Su + * + * @param a model type that is returned by this cursor + */ +public class TodorooCursor extends CursorWrapper { + + /** Properties read by this cursor */ + private final Property[] properties; + + /** Weakly cache field name to column id references for this cursor. + * Because it's a weak hash map, entire keys can be discarded by GC */ + private final WeakHashMap columnIndexCache; + + /** Property reading visitor */ + private static final CursorReadingVisitor reader = new CursorReadingVisitor(); + + /** + * Create an AstridCursor from the supplied {@link Cursor} + * object. + * + * @param cursor + * @param properties properties read from this cursor + */ + public TodorooCursor(Cursor cursor, Property[] properties) { + super(cursor); + + this.properties = properties; + columnIndexCache = new WeakHashMap(); + } + + /** + * Get the value for the given property on the underlying {@link Cursor} + * + * @param type to return + * @param property to retrieve + * @return + */ + public PROPERTY_TYPE get(Property property) { + return (PROPERTY_TYPE)property.accept(reader, this); + } + + /** + * Gets entire property list + * @return + */ + public Property[] getProperties() { + return properties; + } + + /** + * Use cache to get the column index for the given field name + */ + public synchronized int getColumnIndexFromCache(String field) { + Integer index = columnIndexCache.get(field); + if(index == null) { + index = getColumnIndexOrThrow(field); + columnIndexCache.put(field, index); + } + + return index; + } + + /** + * Visitor that reads the given property from a cursor + * + * @author Tim Su + * + */ + public static class CursorReadingVisitor implements PropertyVisitor> { + + public Object visitDouble(Property property, + TodorooCursor cursor) { + return cursor.getDouble(cursor.getColumnIndexFromCache(property.name)); + } + + public Object visitInteger(Property property, + TodorooCursor cursor) { + return cursor.getInt(cursor.getColumnIndexFromCache(property.name)); + } + + public Object visitLong(Property property, TodorooCursor cursor) { + return cursor.getLong(cursor.getColumnIndexFromCache(property.name)); + } + + public Object visitString(Property property, + TodorooCursor cursor) { + return cursor.getString(cursor.getColumnIndexFromCache(property.name)); + } + + } + +} diff --git a/common-src/com/todoroo/andlib/data/sql/Constants.java b/common-src/com/todoroo/andlib/data/sql/Constants.java new file mode 100644 index 000000000..4596aa34b --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/Constants.java @@ -0,0 +1,24 @@ +package com.todoroo.andlib.data.sql; + +@SuppressWarnings("nls") +public class Constants { + static final String SELECT = "SELECT"; + static final String SPACE = " "; + static final String AS = "AS"; + static final String COMMA = ","; + static final String FROM = "FROM"; + static final String ON = "ON"; + static final String JOIN = "JOIN"; + static final String ALL = "*"; + static final String LEFT_PARENTHESIS = "("; + static final String RIGHT_PARENTHESIS = ")"; + static final String AND = "AND"; + static final String BETWEEN = "BETWEEN"; + static final String LIKE = "LIKE"; + static final String OR = "OR"; + static final String ORDER_BY = "ORDER BY"; + static final String GROUP_BY = "GROUP BY"; + static final String WHERE = "WHERE"; + public static final String EXISTS = "EXISTS"; + public static final String NOT = "NOT"; +} diff --git a/common-src/com/todoroo/andlib/data/sql/Criterion.java b/common-src/com/todoroo/andlib/data/sql/Criterion.java new file mode 100644 index 000000000..bae70f44d --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/Criterion.java @@ -0,0 +1,75 @@ +package com.todoroo.andlib.data.sql; + +import static com.todoroo.andlib.data.sql.Constants.AND; +import static com.todoroo.andlib.data.sql.Constants.EXISTS; +import static com.todoroo.andlib.data.sql.Constants.LEFT_PARENTHESIS; +import static com.todoroo.andlib.data.sql.Constants.NOT; +import static com.todoroo.andlib.data.sql.Constants.OR; +import static com.todoroo.andlib.data.sql.Constants.RIGHT_PARENTHESIS; +import static com.todoroo.andlib.data.sql.Constants.SPACE; + +public abstract class Criterion { + protected final Operator operator; + + Criterion(Operator operator) { + this.operator = operator; + } + + public static Criterion and(final Criterion criterion, final Criterion... criterions) { + return new Criterion(Operator.and) { + + @Override + protected void populate(StringBuilder sb) { + sb.append(criterion); + for (Criterion c : criterions) { + sb.append(SPACE).append(AND).append(SPACE).append(c); + } + } + }; + } + + public static Criterion or(final Criterion criterion, final Criterion... criterions) { + return new Criterion(Operator.or) { + + @Override + protected void populate(StringBuilder sb) { + sb.append(criterion); + for (Criterion c : criterions) { + sb.append(SPACE).append(OR).append(SPACE).append(c.toString()); + } + } + }; + } + + public static Criterion exists(final Query query) { + return new Criterion(Operator.exists) { + + @Override + protected void populate(StringBuilder sb) { + sb.append(EXISTS).append(SPACE).append(LEFT_PARENTHESIS).append(query).append(RIGHT_PARENTHESIS); + } + }; + } + + public static Criterion not(final Criterion criterion) { + return new Criterion(Operator.not) { + + @Override + protected void populate(StringBuilder sb) { + sb.append(NOT).append(SPACE); + criterion.populate(sb); + } + }; + } + + protected abstract void populate(StringBuilder sb); + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(LEFT_PARENTHESIS); + populate(builder); + builder.append(RIGHT_PARENTHESIS); + return builder.toString(); + } + +} diff --git a/common-src/com/todoroo/andlib/data/sql/DBObject.java b/common-src/com/todoroo/andlib/data/sql/DBObject.java new file mode 100644 index 000000000..ad8e5ea83 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/DBObject.java @@ -0,0 +1,52 @@ +package com.todoroo.andlib.data.sql; + +import static com.todoroo.andlib.data.sql.Constants.AS; +import static com.todoroo.andlib.data.sql.Constants.SPACE; + +public abstract class DBObject> { + protected String alias; + protected final String expression; + + protected DBObject(String expression){ + this.expression = expression; + } + + public T as(String newAlias) { + this.alias = newAlias; + return (T) this; + } + + public boolean hasAlias() { + return alias != null; + } + + @SuppressWarnings("unchecked") + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + DBObject dbObject = (DBObject) o; + + if (alias != null ? !alias.equals(dbObject.alias) : dbObject.alias != null) return false; + if (expression != null ? !expression.equals(dbObject.expression) : dbObject.expression != null) return false; + + return true; + } + + @Override + public int hashCode() { + int result = alias != null ? alias.hashCode() : 0; + result = 31 * result + (expression != null ? expression.hashCode() : 0); + return result; + } + + @Override + public final String toString() { + StringBuilder sb = new StringBuilder(expression); + if (hasAlias()) { + sb.append(SPACE).append(AS).append(SPACE).append(alias); + } + return sb.toString(); + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/EqCriterion.java b/common-src/com/todoroo/andlib/data/sql/EqCriterion.java new file mode 100644 index 000000000..b2ec34cb8 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/EqCriterion.java @@ -0,0 +1,7 @@ +package com.todoroo.andlib.data.sql; + +public class EqCriterion extends UnaryCriterion { + EqCriterion(Field field, Object value) { + super(field, Operator.eq, value); + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/Field.java b/common-src/com/todoroo/andlib/data/sql/Field.java new file mode 100644 index 000000000..9111d9c25 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/Field.java @@ -0,0 +1,86 @@ +package com.todoroo.andlib.data.sql; + +import static com.todoroo.andlib.data.sql.Constants.AND; +import static com.todoroo.andlib.data.sql.Constants.BETWEEN; +import static com.todoroo.andlib.data.sql.Constants.COMMA; +import static com.todoroo.andlib.data.sql.Constants.LEFT_PARENTHESIS; +import static com.todoroo.andlib.data.sql.Constants.RIGHT_PARENTHESIS; +import static com.todoroo.andlib.data.sql.Constants.SPACE; + +public class Field extends DBObject { + + protected Field(String expression) { + super(expression); + } + + public static Field field(String expression) { + return new Field(expression); + } + + public Criterion eq(Object value) { + return UnaryCriterion.eq(this, value); + } + + public Criterion neq(Object value) { + return UnaryCriterion.neq(this, value); + } + + public Criterion gt(Object value) { + return UnaryCriterion.gt(this, value); + } + + public Criterion lt(final Object value) { + return UnaryCriterion.lt(this, value); + } + + public Criterion isNull() { + return UnaryCriterion.isNull(this); + } + + public Criterion isNotNull() { + return UnaryCriterion.isNotNull(this); + } + + public Criterion between(final Object lower, final Object upper) { + final Field field = this; + return new Criterion(null) { + + @Override + protected void populate(StringBuilder sb) { + sb.append(field).append(SPACE).append(BETWEEN).append(SPACE).append(lower).append(SPACE).append(AND) + .append(SPACE).append(upper); + } + }; + } + + public Criterion like(final String value) { + return UnaryCriterion.like(this, value); + } + + public Criterion in(final T... value) { + final Field field = this; + return new Criterion(Operator.in) { + + @Override + protected void populate(StringBuilder sb) { + sb.append(field).append(SPACE).append(Operator.in).append(SPACE).append(LEFT_PARENTHESIS); + for (T t : value) { + sb.append(t.toString()).append(COMMA); + } + sb.deleteCharAt(sb.length() - 1).append(RIGHT_PARENTHESIS); + } + }; + } + + public Criterion in(final Query query) { + final Field field = this; + return new Criterion(Operator.in) { + + @Override + protected void populate(StringBuilder sb) { + sb.append(field).append(SPACE).append(Operator.in).append(SPACE).append(LEFT_PARENTHESIS).append(query) + .append(RIGHT_PARENTHESIS); + } + }; + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/GroupBy.java b/common-src/com/todoroo/andlib/data/sql/GroupBy.java new file mode 100644 index 000000000..103128411 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/GroupBy.java @@ -0,0 +1,14 @@ +package com.todoroo.andlib.data.sql; + +import java.util.List; +import java.util.ArrayList; + +public class GroupBy { + private List fields = new ArrayList(); + + public static GroupBy groupBy(Field field) { + GroupBy groupBy = new GroupBy(); + groupBy.fields.add(field); + return groupBy; + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/Join.java b/common-src/com/todoroo/andlib/data/sql/Join.java new file mode 100644 index 000000000..f3809dba9 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/Join.java @@ -0,0 +1,43 @@ +package com.todoroo.andlib.data.sql; + +import static com.todoroo.andlib.data.sql.Constants.JOIN; +import static com.todoroo.andlib.data.sql.Constants.ON; +import static com.todoroo.andlib.data.sql.Constants.SPACE; + +public class Join { + private final Table joinTable; + private final JoinType joinType; + private final Criterion[] criterions; + + private Join(Table table, JoinType joinType, Criterion... criterions) { + joinTable = table; + this.joinType = joinType; + this.criterions = criterions; + } + + public static Join inner(Table expression, Criterion... criterions) { + return new Join(expression, JoinType.INNER, criterions); + } + + public static Join left(Table table, Criterion... criterions) { + return new Join(table, JoinType.LEFT, criterions); + } + + public static Join right(Table table, Criterion... criterions) { + return new Join(table, JoinType.RIGHT, criterions); + } + + public static Join out(Table table, Criterion... criterions) { + return new Join(table, JoinType.OUT, criterions); + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + sb.append(joinType).append(SPACE).append(JOIN).append(SPACE).append(joinTable).append(SPACE).append(ON); + for (Criterion criterion : criterions) { + sb.append(SPACE).append(criterion); + } + return sb.toString(); + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/JoinType.java b/common-src/com/todoroo/andlib/data/sql/JoinType.java new file mode 100644 index 000000000..86cd8f2d3 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/JoinType.java @@ -0,0 +1,5 @@ +package com.todoroo.andlib.data.sql; + +public enum JoinType { + INNER, LEFT, RIGHT, OUT +} diff --git a/common-src/com/todoroo/andlib/data/sql/Operator.java b/common-src/com/todoroo/andlib/data/sql/Operator.java new file mode 100644 index 000000000..55981e71a --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/Operator.java @@ -0,0 +1,57 @@ +package com.todoroo.andlib.data.sql; + +import static com.todoroo.andlib.data.sql.Constants.SPACE; + +import java.util.HashMap; +import java.util.Map; + +@SuppressWarnings("nls") +public final class Operator { + + private final String operator; + public static final Operator eq = new Operator("="); + public static final Operator neq = new Operator("<>"); + public static final Operator isNull = new Operator("IS NULL"); + public static final Operator isNotNull = new Operator("IS NOT NULL"); + public static final Operator gt = new Operator(">"); + public static final Operator lt = new Operator("<"); + public static final Operator gte = new Operator(">="); + public static final Operator lte = new Operator("<="); + public static final Operator and = new Operator("AND"); + public static final Operator or = new Operator("OR"); + public static final Operator not = new Operator("NOT"); + public static final Operator exists = new Operator("EXISTS"); + public static final Operator like = new Operator("LIKE"); + public static final Operator in = new Operator("IN"); + + private static final Map contraryRegistry = new HashMap(); + + static { + contraryRegistry.put(eq, neq); + contraryRegistry.put(neq, eq); + contraryRegistry.put(isNull, isNotNull); + contraryRegistry.put(isNotNull, isNull); + contraryRegistry.put(gt, lte); + contraryRegistry.put(lte, gt); + contraryRegistry.put(lt, gte); + contraryRegistry.put(gte, lt); + } + + private Operator(String operator) { + this.operator = operator; + } + + public Operator getContrary() { + if(!contraryRegistry.containsKey(this)){ + Operator opposite = new Operator(not.toString() + SPACE + this.toString()); + contraryRegistry.put(this, opposite); + contraryRegistry.put(opposite, this); + } + return contraryRegistry.get(this); + } + + @Override + public String toString() { + return this.operator.toString(); + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/Order.java b/common-src/com/todoroo/andlib/data/sql/Order.java new file mode 100644 index 000000000..5bff6bf49 --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/Order.java @@ -0,0 +1,30 @@ +package com.todoroo.andlib.data.sql; + +import static com.todoroo.andlib.data.sql.Constants.SPACE; + +public class Order { + private final Field expression; + private final OrderType orderType; + + private Order(Field expression) { + this(expression, OrderType.ASC); + } + + private Order(Field expression, OrderType orderType) { + this.expression = expression; + this.orderType = orderType; + } + + public static Order asc(Field expression) { + return new Order(expression); + } + + public static Order desc(Field expression) { + return new Order(expression, OrderType.DESC); + } + + @Override + public String toString() { + return expression + SPACE + orderType; + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/OrderType.java b/common-src/com/todoroo/andlib/data/sql/OrderType.java new file mode 100644 index 000000000..b3b8fa5af --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/OrderType.java @@ -0,0 +1,5 @@ +package com.todoroo.andlib.data.sql; + +public enum OrderType { + DESC, ASC +} diff --git a/common-src/com/todoroo/andlib/data/sql/Query.java b/common-src/com/todoroo/andlib/data/sql/Query.java new file mode 100644 index 000000000..2d37cbf7e --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/Query.java @@ -0,0 +1,173 @@ +package com.todoroo.andlib.data.sql; + +import static com.todoroo.andlib.data.sql.Constants.ALL; +import static com.todoroo.andlib.data.sql.Constants.COMMA; +import static com.todoroo.andlib.data.sql.Constants.FROM; +import static com.todoroo.andlib.data.sql.Constants.GROUP_BY; +import static com.todoroo.andlib.data.sql.Constants.LEFT_PARENTHESIS; +import static com.todoroo.andlib.data.sql.Constants.ORDER_BY; +import static com.todoroo.andlib.data.sql.Constants.RIGHT_PARENTHESIS; +import static com.todoroo.andlib.data.sql.Constants.SELECT; +import static com.todoroo.andlib.data.sql.Constants.SPACE; +import static com.todoroo.andlib.data.sql.Constants.WHERE; +import static com.todoroo.andlib.data.sql.Table.table; +import static java.util.Arrays.asList; + +import java.util.ArrayList; +import java.util.List; + +import com.todoroo.andlib.data.Property; + +public class Query { + + private Table table; + private List criterions = new ArrayList(); + private List> fields = new ArrayList>(); + private List joins = new ArrayList(); + private List groupBies = new ArrayList(); + private List orders = new ArrayList(); + private List havings = new ArrayList(); + + private Query(Property... fields) { + this.fields.addAll(asList(fields)); + } + + public static Query select(Property... fields) { + return new Query(fields); + } + + public Query from(Table fromTable) { + this.table = fromTable; + return this; + } + + public Query join(Join... join) { + joins.addAll(asList(join)); + return this; + } + + public Query where(Criterion criterion) { + criterions.add(criterion); + return this; + } + + public Query groupBy(Field... groupBy) { + groupBies.addAll(asList(groupBy)); + return this; + } + + public Query orderBy(Order... order) { + orders.addAll(asList(order)); + return this; + } + + public Query appendSelectFields(Property... selectFields) { + this.fields.addAll(asList(selectFields)); + return this; + } + + @Override + public boolean equals(Object o) { + return this == o || !(o == null || getClass() != o.getClass()) && this.toString().equals(o.toString()); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + @Override + public String toString() { + StringBuilder sql = new StringBuilder(); + visitSelectClause(sql); + visitFromClause(sql); + visitJoinClause(sql); + visitWhereClause(sql); + visitGroupByClause(sql); + visitOrderByClause(sql); + return sql.toString(); + } + + private void visitOrderByClause(StringBuilder sql) { + if (orders.isEmpty()) { + return; + } + sql.append(ORDER_BY); + for (Order order : orders) { + sql.append(SPACE).append(order).append(COMMA); + } + sql.deleteCharAt(sql.length() - 1).append(SPACE); + } + + @SuppressWarnings("nls") + private void visitGroupByClause(StringBuilder sql) { + if (groupBies.isEmpty()) { + return; + } + sql.append(GROUP_BY); + for (Field groupBy : groupBies) { + sql.append(SPACE).append(groupBy).append(COMMA); + } + sql.deleteCharAt(sql.length() - 1).append(SPACE); + if (havings.isEmpty()) { + return; + } + sql.append("HAVING"); + for (Criterion havingCriterion : havings) { + sql.append(SPACE).append(havingCriterion).append(COMMA); + } + sql.deleteCharAt(sql.length() - 1).append(SPACE); + } + + private void visitWhereClause(StringBuilder sql) { + if (criterions.isEmpty()) { + return; + } + sql.append(WHERE); + for (Criterion criterion : criterions) { + sql.append(SPACE).append(criterion).append(SPACE); + } + } + + private void visitJoinClause(StringBuilder sql) { + for (Join join : joins) { + sql.append(join).append(SPACE); + } + } + + private void visitFromClause(StringBuilder sql) { + if (table == null) { + return; + } + sql.append(FROM).append(SPACE).append(table).append(SPACE); + } + + private void visitSelectClause(StringBuilder sql) { + sql.append(SELECT).append(SPACE); + if (fields.isEmpty()) { + sql.append(ALL).append(SPACE); + return; + } + for (Field field : fields) { + sql.append(field).append(COMMA); + } + sql.deleteCharAt(sql.length() - 1).append(SPACE); + } + + public Table as(String alias) { + return table(LEFT_PARENTHESIS + this.toString() + RIGHT_PARENTHESIS).as(alias); + } + + public Query having(Criterion criterion) { + this.havings.add(criterion); + return this; + } + + /** + * Gets a list of fields returned by this query + * @return + */ + public Property[] getFields() { + return fields.toArray(new Property[fields.size()]); + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/Table.java b/common-src/com/todoroo/andlib/data/sql/Table.java new file mode 100644 index 000000000..ee04ae0df --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/Table.java @@ -0,0 +1,20 @@ +package com.todoroo.andlib.data.sql; + +public class Table extends DBObject { + + protected Table(String expression) { + super(expression); + } + + public static Table table(String table) { + return new Table(table); + } + + @SuppressWarnings("nls") + protected String fieldExpression(String fieldName) { + if (hasAlias()) { + return alias + "." + fieldName; + } + return expression+"."+fieldName; + } +} diff --git a/common-src/com/todoroo/andlib/data/sql/UnaryCriterion.java b/common-src/com/todoroo/andlib/data/sql/UnaryCriterion.java new file mode 100644 index 000000000..387c8f36d --- /dev/null +++ b/common-src/com/todoroo/andlib/data/sql/UnaryCriterion.java @@ -0,0 +1,92 @@ +package com.todoroo.andlib.data.sql; + +import static com.todoroo.andlib.data.sql.Constants.SPACE; + +public class UnaryCriterion extends Criterion { + protected final Field expression; + protected final Object value; + + UnaryCriterion(Field expression, Operator operator, Object value) { + super(operator); + this.expression = expression; + this.value = value; + } + + @Override + protected void populate(StringBuilder sb) { + beforePopulateOperator(sb); + populateOperator(sb); + afterPopulateOperator(sb); + } + + public static Criterion eq(Field expression, Object value) { + return new UnaryCriterion(expression, Operator.eq, value); + } + + protected void beforePopulateOperator(StringBuilder sb) { + sb.append(expression); + } + + protected void populateOperator(StringBuilder sb) { + sb.append(operator); + } + + @SuppressWarnings("nls") + protected void afterPopulateOperator(StringBuilder sb) { + if(value == null) + return; + else if(value instanceof String) + sb.append("'").append(sanitize((String) value)).append("'"); + else + sb.append(value); + } + + /** + * Sanitize the given input for SQL + * @param input + * @return + */ + @SuppressWarnings("nls") + public static String sanitize(String input) { + return input.replace("\\", "\\\\").replace("'", "\\'"); + } + + public static Criterion neq(Field field, Object value) { + return new UnaryCriterion(field, Operator.neq, value); + } + + public static Criterion gt(Field field, Object value) { + return new UnaryCriterion(field, Operator.gt, value); + } + + public static Criterion lt(Field field, Object value) { + return new UnaryCriterion(field, Operator.lt, value); + } + + public static Criterion isNull(Field field) { + return new UnaryCriterion(field, Operator.isNull, null) { + @Override + protected void populateOperator(StringBuilder sb) { + sb.append(SPACE).append(operator); + } + }; + } + + public static Criterion isNotNull(Field field) { + return new UnaryCriterion(field, Operator.isNotNull, null) { + @Override + protected void populateOperator(StringBuilder sb) { + sb.append(SPACE).append(operator); + } + }; + } + + public static Criterion like(Field field, String value) { + return new UnaryCriterion(field, Operator.like, value) { + @Override + protected void populateOperator(StringBuilder sb) { + sb.append(SPACE).append(operator).append(SPACE); + } + }; + } +} diff --git a/common-src/com/todoroo/andlib/service/AbstractDependencyInjector.java b/common-src/com/todoroo/andlib/service/AbstractDependencyInjector.java new file mode 100644 index 000000000..fbf4fdfb8 --- /dev/null +++ b/common-src/com/todoroo/andlib/service/AbstractDependencyInjector.java @@ -0,0 +1,26 @@ +package com.todoroo.andlib.service; + +import java.lang.reflect.Field; + +/** + * A Dependency Injector knows how to inject certain dependencies based + * on the field that is passed in. + * + * @author Tim Su + * + */ +public interface AbstractDependencyInjector { + + /** + * Gets the injected object for this field. If implementing class does not + * know how to handle this dependency, it should return null + * + * @param object + * object to perform dependency injection on + * @param field + * field tagged with {link Autowired} annotation + * @return object to assign to this field, or null + */ + abstract Object getInjection(Object object, Field field); + +} diff --git a/common-src/com/todoroo/andlib/service/Autowired.java b/common-src/com/todoroo/andlib/service/Autowired.java new file mode 100644 index 000000000..3b0b1c765 --- /dev/null +++ b/common-src/com/todoroo/andlib/service/Autowired.java @@ -0,0 +1,19 @@ +package com.todoroo.andlib.service; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Autowired is an annotation that tells the dependency injector to + * set up the class as appropriate. + * + * @author Tim Su + * + */ +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Autowired { + // +} diff --git a/common-src/com/todoroo/andlib/service/ContextManager.java b/common-src/com/todoroo/andlib/service/ContextManager.java new file mode 100644 index 000000000..de75b7bbc --- /dev/null +++ b/common-src/com/todoroo/andlib/service/ContextManager.java @@ -0,0 +1,46 @@ +/** + * See the file "LICENSE" for the full license governing this code. + */ +package com.todoroo.andlib.service; + +import android.content.Context; + +/** + * Singleton class to manage current application context + * b + * @author Tim Su + * + */ +public final class ContextManager { + + /** + * Global application context + */ + private static Context context = null; + + /** + * Sets the global context + * @param context + */ + public static void setContext(Context context) { + ContextManager.context = context; + } + + /** + * Gets the global context + */ + public static Context getContext() { + return context; + } + + /** + * Convenience method to read a string from the resources + * + * @param resid + * @param parameters + * @return + */ + public static String getString(int resId, Object... formatArgs) { + return context.getString(resId, formatArgs); + } +} diff --git a/common-src/com/todoroo/andlib/service/DependencyInjectionService.java b/common-src/com/todoroo/andlib/service/DependencyInjectionService.java new file mode 100644 index 000000000..27b43e76f --- /dev/null +++ b/common-src/com/todoroo/andlib/service/DependencyInjectionService.java @@ -0,0 +1,137 @@ +package com.todoroo.andlib.service; + +import java.lang.reflect.Field; + +/** + * Simple Dependency Injection Service for Android. + *

+ * Add dependency injectors to the injector chain, then invoke this method + * against classes you wish to perform dependency injection for. + *

+ * All errors encountered are handled as warnings, so if dependency injection + * seems to be failing, check the logs for more information. + * + * @author Tim Su + * + */ +public class DependencyInjectionService { + + private static final String QUALIFIED_PACKAGE = "com.t"; //$NON-NLS-1$ + + /** + * Dependency injectors. Use getters and setters to modify this list + */ + private AbstractDependencyInjector[] injectors = {}; + + /** + * Perform dependency injection in the caller object + * + * @param caller + * object to perform DI on + */ + @SuppressWarnings("nls") + public void inject(Object caller) { + + // Traverse through class and all parent classes, looking for + // fields declared with the @Autowired annotation and using + // dependency injection to set them as appropriate + + Class cls = caller.getClass(); + while(cls != null) { + String packageName = cls.getPackage().getName(); + if(!packageName.startsWith(QUALIFIED_PACKAGE)) + break; + + for(Field field : cls.getDeclaredFields()) { + if(field.getAnnotation(Autowired.class) != null) { + field.setAccessible(true); + try { + handleField(caller, field); + } catch (IllegalStateException e) { + throw new RuntimeException(String.format("Unable to set field '%s' of type '%s'", + field.getName(), field.getType()), e); + } catch (IllegalArgumentException e) { + throw new RuntimeException(String.format("Unable to set field '%s' of type '%s'", + field.getName(), field.getType()), e); + } catch (IllegalAccessException e) { + throw new RuntimeException(String.format("Unable to set field '%s' of type '%s'", + field.getName(), field.getType()), e); + } + } + } + + cls = cls.getSuperclass(); + } + } + + /** + * This method returns the appropriate dependency object based on the type + * that this autowired field accepts + * + * @param caller + * calling object + * @param field + * field to inject + */ + @SuppressWarnings("nls") + private void handleField(Object caller, Field field) + throws IllegalStateException, IllegalArgumentException, + IllegalAccessException { + + if(field.getType().isPrimitive()) + throw new IllegalStateException(String.format( + "Tried to dependency-inject primative field '%s' of type '%s'", + field.getName(), field.getType())); + + // field has already been processed, ignore + if (field.get(caller) != null) { + return; + } + + for (AbstractDependencyInjector injector : injectors) { + Object injection = injector.getInjection(caller, field); + if (injection != null) { + field.set(caller, injection); + return; + } + } + + throw new IllegalStateException( + String.format("No dependency injector found for autowired field '%s' in class '%s'", + field.getName(), caller.getClass().getName())); + } + + // --- static methods + + private static DependencyInjectionService instance = null; + + DependencyInjectionService() { + // prevent instantiation + } + + /** + * Gets the singleton instance of the dependency injection service. + * @return + */ + public synchronized static DependencyInjectionService getInstance() { + if(instance == null) + instance = new DependencyInjectionService(); + return instance; + } + + /** + * Gets the array of installed injectors + * @return + */ + public synchronized AbstractDependencyInjector[] getInjectors() { + return injectors; + } + + /** + * Sets the array of installed injectors + * @param injectors + */ + public synchronized void setInjectors(AbstractDependencyInjector[] injectors) { + this.injectors = injectors; + } +} diff --git a/common-src/com/todoroo/andlib/service/ExceptionService.java b/common-src/com/todoroo/andlib/service/ExceptionService.java new file mode 100644 index 000000000..223655d0a --- /dev/null +++ b/common-src/com/todoroo/andlib/service/ExceptionService.java @@ -0,0 +1,165 @@ +package com.todoroo.andlib.service; + +import java.lang.Thread.UncaughtExceptionHandler; +import java.net.SocketTimeoutException; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.util.Log; + +/** + * Exception handling utility class - reports and logs errors + * + * @author Tim Su + * + */ +public class ExceptionService { + + @Autowired + public ErrorReporter[] errorReporters; + + @Autowired + public Integer errorDialogTitleResource; + + @Autowired + public Integer errorDialogBodyGeneric; + + @Autowired + public Integer errorDialogBodyNullError; + + @Autowired + public Integer errorDialogBodySocketTimeout; + + public ExceptionService() { + DependencyInjectionService.getInstance().inject(this); + } + + /** + * Report the error via registered error handlers + * + * @param name Internal error name. Not displayed to user + * @param error Exception encountered. Message will be displayed to user + */ + public void reportError(String name, Throwable error) { + if(errorReporters == null) + return; + + for(ErrorReporter reporter : errorReporters) + reporter.handleError(name, error); + } + + /** + * Display error dialog if context is activity and report error + * + * @param context Application Context + * @param name Internal error name. Not displayed to user + * @param error Exception encountered. Message will be displayed to user + */ + public void displayAndReportError(final Context context, String name, Throwable error) { + if(context instanceof Activity) { + final String messageToDisplay; + + // pretty up the message when displaying to user + if(error == null) + messageToDisplay = context.getString(errorDialogBodyNullError); + else if(error instanceof SocketTimeoutException) + messageToDisplay = context.getString(errorDialogBodySocketTimeout); + else + messageToDisplay = context.getString(errorDialogBodyGeneric, error.getMessage()); + + ((Activity)context).runOnUiThread(new Runnable() { + public void run() { + try { + new AlertDialog.Builder(context) + .setTitle(errorDialogTitleResource) + .setMessage(messageToDisplay) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton(android.R.string.ok, null) + .show(); + } catch (Exception e) { + // suppress errors during dialog creation + } + } + }); + } + + reportError(name, error); + } + + /** + * Error reporter interface + * + * @author Tim Su + * + */ + public interface ErrorReporter { + public void handleError(String name, Throwable error); + } + + /** + * AndroidLogReporter reports errors to LogCat + * + * @author Tim Su + * + */ + public static class AndroidLogReporter implements ErrorReporter { + + /** + * Report the error to the logs + * + * @param name + * @param error + */ + public void handleError(String name, Throwable error) { + String tag = null; + if(ContextManager.getContext() != null) { + PackageManager pm = ContextManager.getContext().getPackageManager(); + try { + String appName = pm.getApplicationInfo(ContextManager.getContext(). + getPackageName(), 0).name; + tag = appName + "-" + name; //$NON-NLS-1$ + } catch (NameNotFoundException e) { + // give up + } + } + + if(tag == null) + tag = "unknown-" + name; //$NON-NLS-1$ + + if(error == null) + Log.e(tag, "Exception: " + name); //$NON-NLS-1$ + else + Log.e(tag, error.toString(), error); + } + } + + /** + * Uncaught exception handler uses the exception utilities class to + * report errors + * + * @author Tim Su + * + */ + public static class TodorooUncaughtExceptionHandler implements UncaughtExceptionHandler { + private UncaughtExceptionHandler defaultUEH; + + @Autowired + protected ExceptionService exceptionService; + + public TodorooUncaughtExceptionHandler() { + defaultUEH = Thread.getDefaultUncaughtExceptionHandler(); + DependencyInjectionService.getInstance().inject(this); + } + + public void uncaughtException(Thread thread, Throwable ex) { + if(exceptionService != null) + exceptionService.reportError("uncaught", ex); //$NON-NLS-1$ + defaultUEH.uncaughtException(thread, ex); + } + } + +} + diff --git a/common-src/com/todoroo/andlib/service/HttpErrorException.java b/common-src/com/todoroo/andlib/service/HttpErrorException.java new file mode 100644 index 000000000..c2c46fc03 --- /dev/null +++ b/common-src/com/todoroo/andlib/service/HttpErrorException.java @@ -0,0 +1,13 @@ +package com.todoroo.andlib.service; + +import java.io.IOException; + +public class HttpErrorException extends IOException { + + private static final long serialVersionUID = 5373340422464657279L; + + public HttpErrorException(int code, String message) { + super(String.format("%d %s", code, message)); //$NON-NLS-1$ + } + +} diff --git a/common-src/com/todoroo/andlib/service/HttpRestClient.java b/common-src/com/todoroo/andlib/service/HttpRestClient.java new file mode 100644 index 000000000..fa2b470c1 --- /dev/null +++ b/common-src/com/todoroo/andlib/service/HttpRestClient.java @@ -0,0 +1,165 @@ +package com.todoroo.andlib.service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.ref.WeakReference; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.DefaultHttpClient; +import org.apache.http.params.BasicHttpParams; +import org.apache.http.params.HttpConnectionParams; +import org.apache.http.params.HttpParams; + +import android.util.Log; + +/** + * RestClient allows Android to consume web requests. + *

+ * Portions by Praeda: + * http://senior.ceng.metu.edu.tr/2009/praeda/2009/01/11/a-simple + * -restful-client-at-android/ + * + * @author Tim Su + * + */ +public class HttpRestClient implements RestClient { + + private static final int HTTP_UNAVAILABLE_END = 599; + private static final int HTTP_UNAVAILABLE_START = 500; + private static final int HTTP_OK = 200; + + private static final int TIMEOUT_MILLIS = 30000; + + private static WeakReference httpClient = null; + + @Autowired + private Boolean debug; + + public HttpRestClient() { + DependencyInjectionService.getInstance().inject(this); + } + + private static String convertStreamToString(InputStream is) { + /* + * To convert the InputStream to String we use the + * BufferedReader.readLine() method. We iterate until the BufferedReader + * return null which means there's no more data to read. Each line will + * appended to a StringBuilder and returned as String. + */ + BufferedReader reader = new BufferedReader(new InputStreamReader(is), 16384); + StringBuilder sb = new StringBuilder(); + + String line = null; + try { + while ((line = reader.readLine()) != null) { + sb.append(line + "\n"); //$NON-NLS-1$ + } + } catch (IOException e) { + e.printStackTrace(); + } finally { + try { + is.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + return sb.toString(); + } + + private synchronized static void initializeHttpClient() { + if (httpClient == null || httpClient.get() == null) { + HttpParams params = new BasicHttpParams(); + HttpConnectionParams.setConnectionTimeout(params, TIMEOUT_MILLIS); + HttpConnectionParams.setSoTimeout(params, TIMEOUT_MILLIS); + httpClient = new WeakReference(new DefaultHttpClient(params)); + } + } + + private String processHttpResponse(HttpResponse response) throws IOException { + int statusCode = response.getStatusLine().getStatusCode(); + if(statusCode >= HTTP_UNAVAILABLE_START && statusCode <= HTTP_UNAVAILABLE_END) { + throw new HttpUnavailableException(); + } else if(statusCode != HTTP_OK) { + throw new HttpErrorException(response.getStatusLine().getStatusCode(), + response.getStatusLine().getReasonPhrase()); + } + + HttpEntity entity = response.getEntity(); + + if (entity != null) { + InputStream contentStream = entity.getContent(); + try { + return convertStreamToString(contentStream); + } finally { + contentStream.close(); + } + } + + return null; + } + + /** + * Issue an HTTP GET for the given URL, return the response + * + * @param url url with url-encoded params + * @return response, or null if there was no response + * @throws IOException + */ + public synchronized String get(String url) throws IOException { + initializeHttpClient(); + + if(debug) + Log.d("http-rest-client-get", url); //$NON-NLS-1$ + + try { + HttpGet httpGet = new HttpGet(url); + HttpResponse response = httpClient.get().execute(httpGet); + + return processHttpResponse(response); + } catch (IOException e) { + throw e; + } catch (Exception e) { + IOException ioException = new IOException(e.getMessage()); + ioException.initCause(e); + throw ioException; + } + } + + /** + * Issue an HTTP POST for the given URL, return the response + * + * @param url + * @param data + * url-encoded data + * @throws IOException + */ + public synchronized String post(String url, String data) throws IOException { + initializeHttpClient(); + + if(debug) + Log.d("http-rest-client-post", url + " | " + data); //$NON-NLS-1$ //$NON-NLS-2$ + + try { + HttpPost httpPost = new HttpPost(url); + httpPost.setEntity(new StringEntity(data)); + HttpResponse response = httpClient.get().execute(httpPost); + + return processHttpResponse(response); + } catch (IOException e) { + throw e; + } catch (Exception e) { + IOException ioException = new IOException(e.getMessage()); + ioException.initCause(e); + throw ioException; + } + } + +} diff --git a/common-src/com/todoroo/andlib/service/HttpUnavailableException.java b/common-src/com/todoroo/andlib/service/HttpUnavailableException.java new file mode 100644 index 000000000..ac6e8c860 --- /dev/null +++ b/common-src/com/todoroo/andlib/service/HttpUnavailableException.java @@ -0,0 +1,25 @@ +package com.todoroo.andlib.service; + +import java.io.IOException; + +/** + * Exception displayed when a 500 error is received on an HTTP invocation + * + * @author Tim Su + * + */ +public class HttpUnavailableException extends IOException { + + private static final long serialVersionUID = 5373340422464657279L; + + public HttpUnavailableException() { + super(); + DependencyInjectionService.getInstance().inject(this); + } + + @Override + public String getMessage() { + return "Sorry, our servers are experiencing some issues. Please try again later!"; //$NON-NLS-1$ // FIXME + } + +} diff --git a/common-src/com/todoroo/andlib/service/NotificationManager.java b/common-src/com/todoroo/andlib/service/NotificationManager.java new file mode 100644 index 000000000..cb9ff8371 --- /dev/null +++ b/common-src/com/todoroo/andlib/service/NotificationManager.java @@ -0,0 +1,49 @@ +/** + * See the file "LICENSE" for the full license governing this code. + */ +package com.todoroo.andlib.service; + +import android.app.Notification; +import android.content.Context; + +/** + * Notification Manager stub + * + * @author timsu + * + */ +public interface NotificationManager { + + public void cancel(int id); + + public void cancelAll(); + + public void notify(int id, Notification notification); + + /** + * Instantiation of notification manager that passes through to + * Android's notification manager + * + * @author timsu + * + */ + public static class AndroidNotificationManager implements NotificationManager { + final android.app.NotificationManager nm; + public AndroidNotificationManager(Context context) { + nm = (android.app.NotificationManager) + context.getSystemService(Context.NOTIFICATION_SERVICE); + } + + public void cancel(int id) { + nm.cancel(id); + } + + public void cancelAll() { + nm.cancelAll(); + } + + public void notify(int id, Notification notification) { + nm.notify(id, notification); + } + } +} diff --git a/common-src/com/todoroo/andlib/service/RestClient.java b/common-src/com/todoroo/andlib/service/RestClient.java new file mode 100644 index 000000000..0bf35e461 --- /dev/null +++ b/common-src/com/todoroo/andlib/service/RestClient.java @@ -0,0 +1,14 @@ +package com.todoroo.andlib.service; + +import java.io.IOException; + +/** + * RestClient stub invokes the HTML requests as desired + * + * @author Tim Su + * + */ +public interface RestClient { + public String get(String url) throws IOException; + public String post(String url, String data) throws IOException; +} \ No newline at end of file diff --git a/common-src/com/todoroo/andlib/utility/AndroidUtilities.java b/common-src/com/todoroo/andlib/utility/AndroidUtilities.java new file mode 100644 index 000000000..c19380b5c --- /dev/null +++ b/common-src/com/todoroo/andlib/utility/AndroidUtilities.java @@ -0,0 +1,113 @@ +package com.todoroo.andlib.utility; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; + +import android.app.Activity; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.NetworkInfo.State; + +import com.todoroo.andlib.service.Autowired; +import com.todoroo.andlib.service.DependencyInjectionService; +import com.todoroo.andlib.service.ExceptionService; + +/** + * Android Utility Classes + * + * @author Tim Su + * + */ +public class AndroidUtilities { + + private static class ExceptionHelper { + @Autowired + public ExceptionService exceptionService; + + public ExceptionHelper() { + DependencyInjectionService.getInstance().inject(this); + } + } + + /** + * @return true if we're connected to the internet + */ + public static boolean isConnected(Context context) { + ConnectivityManager manager = (ConnectivityManager) + context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo info = manager.getActiveNetworkInfo(); + if (info == null) + return false; + if (info.getState() != State.CONNECTED) + return false; + return true; + } + + /** Fetch the image specified by the given url */ + public static Bitmap fetchImage(URL url) throws IOException { + InputStream is = null; + try { + URLConnection conn = url.openConnection(); + conn.connect(); + is = conn.getInputStream(); + BufferedInputStream bis = new BufferedInputStream(is, 16384); + try { + Bitmap bitmap = BitmapFactory.decodeStream(bis); + return bitmap; + } finally { + bis.close(); + } + } finally { + if(is != null) + is.close(); + } + } + + /** + * Start the given intent, handling security exceptions if they arise + * + * @param context + * @param intent + */ + public static void startExternalIntent(Context context, Intent intent) { + try { + context.startActivity(intent); + } catch (SecurityException e) { + ExceptionHelper helper = new ExceptionHelper(); + helper.exceptionService.displayAndReportError(context, + "start-external-intent-" + intent.toString(), //$NON-NLS-1$ + e); + } + } + + /** + * Start the given intent, handling security exceptions if they arise + * + * @param activity + * @param intent + * @param requestCode + */ + public static void startExternalIntentForResult( + Activity activity, Intent intent, int requestCode) { + try { + activity.startActivityForResult(intent, requestCode); + } catch (SecurityException e) { + ExceptionHelper helper = new ExceptionHelper(); + helper.exceptionService.displayAndReportError(activity, + "start-external-intent-" + intent.toString(), //$NON-NLS-1$ + e); + } + } + +} diff --git a/common-src/com/todoroo/andlib/utility/Base64.java b/common-src/com/todoroo/andlib/utility/Base64.java new file mode 100644 index 000000000..60dd008a2 --- /dev/null +++ b/common-src/com/todoroo/andlib/utility/Base64.java @@ -0,0 +1,2052 @@ +package com.todoroo.andlib.utility; + +/** + *

Encodes and decodes to and from Base64 notation.

+ *

Homepage: http://iharder.net/base64.

+ * + *

Example:

+ * + * String encoded = Base64.encode( myByteArray ); + *
+ * byte[] myByteArray = Base64.decode( encoded ); + * + *

The options parameter, which appears in a few places, is used to pass + * several pieces of information to the encoder. In the "higher level" methods such as + * encodeBytes( bytes, options ) the options parameter can be used to indicate such + * things as first gzipping the bytes before encoding them, not inserting linefeeds, + * and encoding using the URL-safe and Ordered dialects.

+ * + *

Note, according to RFC3548, + * Section 2.1, implementations should not add line feeds unless explicitly told + * to do so. I've got Base64 set to this behavior now, although earlier versions + * broke lines by default.

+ * + *

The constants defined in Base64 can be OR-ed together to combine options, so you + * might make a call like this:

+ * + * String encoded = Base64.encodeBytes( mybytes, Base64.GZIP | Base64.DO_BREAK_LINES ); + *

to compress the data before encoding it and then making the output have newline characters.

+ *

Also...

+ * String encoded = Base64.encodeBytes( crazyString.getBytes() ); + * + * + * + *

+ * Change Log: + *

+ *
    + *
  • v2.3.4 - Fixed bug when working with gzipped streams whereby flushing + * the Base64.OutputStream closed the Base64 encoding (by padding with equals + * signs) too soon. Also added an option to suppress the automatic decoding + * of gzipped streams. Also added experimental support for specifying a + * class loader when using the + * {@link #decodeToObject(java.lang.String, int, java.lang.ClassLoader)} + * method.
  • + *
  • v2.3.3 - Changed default char encoding to US-ASCII which reduces the internal Java + * footprint with its CharEncoders and so forth. Fixed some javadocs that were + * inconsistent. Removed imports and specified things like java.io.IOException + * explicitly inline.
  • + *
  • v2.3.2 - Reduced memory footprint! Finally refined the "guessing" of how big the + * final encoded data will be so that the code doesn't have to create two output + * arrays: an oversized initial one and then a final, exact-sized one. Big win + * when using the {@link #encodeBytesToBytes(byte[])} family of methods (and not + * using the gzip options which uses a different mechanism with streams and stuff).
  • + *
  • v2.3.1 - Added {@link #encodeBytesToBytes(byte[], int, int, int)} and some + * similar helper methods to be more efficient with memory by not returning a + * String but just a byte array.
  • + *
  • v2.3 - This is not a drop-in replacement! This is two years of comments + * and bug fixes queued up and finally executed. Thanks to everyone who sent + * me stuff, and I'm sorry I wasn't able to distribute your fixes to everyone else. + * Much bad coding was cleaned up including throwing exceptions where necessary + * instead of returning null values or something similar. Here are some changes + * that may affect you: + *
      + *
    • Does not break lines, by default. This is to keep in compliance with + * RFC3548.
    • + *
    • Throws exceptions instead of returning null values. Because some operations + * (especially those that may permit the GZIP option) use IO streams, there + * is a possiblity of an java.io.IOException being thrown. After some discussion and + * thought, I've changed the behavior of the methods to throw java.io.IOExceptions + * rather than return null if ever there's an error. I think this is more + * appropriate, though it will require some changes to your code. Sorry, + * it should have been done this way to begin with.
    • + *
    • Removed all references to System.out, System.err, and the like. + * Shame on me. All I can say is sorry they were ever there.
    • + *
    • Throws NullPointerExceptions and IllegalArgumentExceptions as needed + * such as when passed arrays are null or offsets are invalid.
    • + *
    • Cleaned up as much javadoc as I could to avoid any javadoc warnings. + * This was especially annoying before for people who were thorough in their + * own projects and then had gobs of javadoc warnings on this file.
    • + *
    + *
  • v2.2.1 - Fixed bug using URL_SAFE and ORDERED encodings. Fixed bug + * when using very small files (~< 40 bytes).
  • + *
  • v2.2 - Added some helper methods for encoding/decoding directly from + * one file to the next. Also added a main() method to support command line + * encoding/decoding from one file to the next. Also added these Base64 dialects: + *
      + *
    1. The default is RFC3548 format.
    2. + *
    3. Calling Base64.setFormat(Base64.BASE64_FORMAT.URLSAFE_FORMAT) generates + * URL and file name friendly format as described in Section 4 of RFC3548. + * http://www.faqs.org/rfcs/rfc3548.html
    4. + *
    5. Calling Base64.setFormat(Base64.BASE64_FORMAT.ORDERED_FORMAT) generates + * URL and file name friendly format that preserves lexical ordering as described + * in http://www.faqs.org/qa/rfcc-1940.html
    6. + *
    + * Special thanks to Jim Kellerman at http://www.powerset.com/ + * for contributing the new Base64 dialects. + *
  • + * + *
  • v2.1 - Cleaned up javadoc comments and unused variables and methods. Added + * some convenience methods for reading and writing to and from files.
  • + *
  • v2.0.2 - Now specifies UTF-8 encoding in places where the code fails on systems + * with other encodings (like EBCDIC).
  • + *
  • v2.0.1 - Fixed an error when decoding a single byte, that is, when the + * encoded data was a single byte.
  • + *
  • v2.0 - I got rid of methods that used booleans to set options. + * Now everything is more consolidated and cleaner. The code now detects + * when data that's being decoded is gzip-compressed and will decompress it + * automatically. Generally things are cleaner. You'll probably have to + * change some method calls that you were making to support the new + * options format (ints that you "OR" together).
  • + *
  • v1.5.1 - Fixed bug when decompressing and decoding to a + * byte[] using decode( String s, boolean gzipCompressed ). + * Added the ability to "suspend" encoding in the Output Stream so + * you can turn on and off the encoding if you need to embed base64 + * data in an otherwise "normal" stream (like an XML file).
  • + *
  • v1.5 - Output stream pases on flush() command but doesn't do anything itself. + * This helps when using GZIP streams. + * Added the ability to GZip-compress objects before encoding them.
  • + *
  • v1.4 - Added helper methods to read/write files.
  • + *
  • v1.3.6 - Fixed OutputStream.flush() so that 'position' is reset.
  • + *
  • v1.3.5 - Added flag to turn on and off line breaks. Fixed bug in input stream + * where last buffer being read, if not completely full, was not returned.
  • + *
  • v1.3.4 - Fixed when "improperly padded stream" error was thrown at the wrong time.
  • + *
  • v1.3.3 - Fixed I/O streams which were totally messed up.
  • + *
+ * + *

+ * I am placing this code in the Public Domain. Do with it as you will. + * This software comes with no guarantees or warranties but with + * plenty of well-wishing instead! + * Please visit http://iharder.net/base64 + * periodically to check for updates or to contribute improvements. + *

+ * + * @author Robert Harder + * @author rob@iharder.net + * @version 2.3.3 + */ +@SuppressWarnings({"nls", "null"}) +public class Base64 +{ + +/* ******** P U B L I C F I E L D S ******** */ + + + /** No options specified. Value is zero. */ + public final static int NO_OPTIONS = 0; + + /** Specify encoding in first bit. Value is one. */ + public final static int ENCODE = 1; + + + /** Specify decoding in first bit. Value is zero. */ + public final static int DECODE = 0; + + + /** Specify that data should be gzip-compressed in second bit. Value is two. */ + public final static int GZIP = 2; + + /** Specify that gzipped data should not be automatically gunzipped. */ + public final static int DONT_GUNZIP = 4; + + + /** Do break lines when encoding. Value is 8. */ + public final static int DO_BREAK_LINES = 8; + + /** + * Encode using Base64-like encoding that is URL- and Filename-safe as described + * in Section 4 of RFC3548: + * http://www.faqs.org/rfcs/rfc3548.html. + * It is important to note that data encoded this way is not officially valid Base64, + * or at the very least should not be called Base64 without also specifying that is + * was encoded using the URL- and Filename-safe dialect. + */ + public final static int URL_SAFE = 16; + + + /** + * Encode using the special "ordered" dialect of Base64 described here: + * http://www.faqs.org/qa/rfcc-1940.html. + */ + public final static int ORDERED = 32; + + +/* ******** P R I V A T E F I E L D S ******** */ + + + /** Maximum line length (76) of Base64 output. */ + private final static int MAX_LINE_LENGTH = 76; + + + /** The equals sign (=) as a byte. */ + private final static byte EQUALS_SIGN = (byte)'='; + + + /** The new line character (\n) as a byte. */ + private final static byte NEW_LINE = (byte)'\n'; + + + /** Preferred encoding. */ + private final static String PREFERRED_ENCODING = "US-ASCII"; + + + private final static byte WHITE_SPACE_ENC = -5; // Indicates white space in encoding + private final static byte EQUALS_SIGN_ENC = -1; // Indicates equals sign in encoding + + +/* ******** S T A N D A R D B A S E 6 4 A L P H A B E T ******** */ + + /** The 64 valid Base64 values. */ + /* Host platform me be something funny like EBCDIC, so we hardcode these values. */ + private final static byte[] _STANDARD_ALPHABET = { + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', + (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'+', (byte)'/' + }; + + + /** + * Translates a Base64 value to either its 6-bit reconstruction value + * or a negative number indicating some other meaning. + **/ + private final static byte[] _STANDARD_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + 62, // Plus sign at decimal 43 + -9,-9,-9, // Decimal 44 - 46 + 63, // Slash at decimal 47 + 52,53,54,55,56,57,58,59,60,61, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 0,1,2,3,4,5,6,7,8,9,10,11,12,13, // Letters 'A' through 'N' + 14,15,16,17,18,19,20,21,22,23,24,25, // Letters 'O' through 'Z' + -9,-9,-9,-9,-9,-9, // Decimal 91 - 96 + 26,27,28,29,30,31,32,33,34,35,36,37,38, // Letters 'a' through 'm' + 39,40,41,42,43,44,45,46,47,48,49,50,51, // Letters 'n' through 'z' + -9,-9,-9,-9 // Decimal 123 - 126 + /*,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 127 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */ + }; + + +/* ******** U R L S A F E B A S E 6 4 A L P H A B E T ******** */ + + /** + * Used in the URL- and Filename-safe dialect described in Section 4 of RFC3548: + * http://www.faqs.org/rfcs/rfc3548.html. + * Notice that the last two bytes become "hyphen" and "underscore" instead of "plus" and "slash." + */ + private final static byte[] _URL_SAFE_ALPHABET = { + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', + (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'-', (byte)'_' + }; + + /** + * Used in decoding URL- and Filename-safe dialects of Base64. + */ + private final static byte[] _URL_SAFE_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + -9, // Plus sign at decimal 43 + -9, // Decimal 44 + 62, // Minus sign at decimal 45 + -9, // Decimal 46 + -9, // Slash at decimal 47 + 52,53,54,55,56,57,58,59,60,61, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 0,1,2,3,4,5,6,7,8,9,10,11,12,13, // Letters 'A' through 'N' + 14,15,16,17,18,19,20,21,22,23,24,25, // Letters 'O' through 'Z' + -9,-9,-9,-9, // Decimal 91 - 94 + 63, // Underscore at decimal 95 + -9, // Decimal 96 + 26,27,28,29,30,31,32,33,34,35,36,37,38, // Letters 'a' through 'm' + 39,40,41,42,43,44,45,46,47,48,49,50,51, // Letters 'n' through 'z' + -9,-9,-9,-9 // Decimal 123 - 126 + /*,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 127 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */ + }; + + + +/* ******** O R D E R E D B A S E 6 4 A L P H A B E T ******** */ + + /** + * I don't get the point of this technique, but someone requested it, + * and it is described here: + * http://www.faqs.org/qa/rfcc-1940.html. + */ + private final static byte[] _ORDERED_ALPHABET = { + (byte)'-', + (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', + (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', + (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F', (byte)'G', + (byte)'H', (byte)'I', (byte)'J', (byte)'K', (byte)'L', (byte)'M', (byte)'N', + (byte)'O', (byte)'P', (byte)'Q', (byte)'R', (byte)'S', (byte)'T', (byte)'U', + (byte)'V', (byte)'W', (byte)'X', (byte)'Y', (byte)'Z', + (byte)'_', + (byte)'a', (byte)'b', (byte)'c', (byte)'d', (byte)'e', (byte)'f', (byte)'g', + (byte)'h', (byte)'i', (byte)'j', (byte)'k', (byte)'l', (byte)'m', (byte)'n', + (byte)'o', (byte)'p', (byte)'q', (byte)'r', (byte)'s', (byte)'t', (byte)'u', + (byte)'v', (byte)'w', (byte)'x', (byte)'y', (byte)'z' + }; + + /** + * Used in decoding the "ordered" dialect of Base64. + */ + private final static byte[] _ORDERED_DECODABET = { + -9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 0 - 8 + -5,-5, // Whitespace: Tab and Linefeed + -9,-9, // Decimal 11 - 12 + -5, // Whitespace: Carriage Return + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 14 - 26 + -9,-9,-9,-9,-9, // Decimal 27 - 31 + -5, // Whitespace: Space + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 33 - 42 + -9, // Plus sign at decimal 43 + -9, // Decimal 44 + 0, // Minus sign at decimal 45 + -9, // Decimal 46 + -9, // Slash at decimal 47 + 1,2,3,4,5,6,7,8,9,10, // Numbers zero through nine + -9,-9,-9, // Decimal 58 - 60 + -1, // Equals sign at decimal 61 + -9,-9,-9, // Decimal 62 - 64 + 11,12,13,14,15,16,17,18,19,20,21,22,23, // Letters 'A' through 'M' + 24,25,26,27,28,29,30,31,32,33,34,35,36, // Letters 'N' through 'Z' + -9,-9,-9,-9, // Decimal 91 - 94 + 37, // Underscore at decimal 95 + -9, // Decimal 96 + 38,39,40,41,42,43,44,45,46,47,48,49,50, // Letters 'a' through 'm' + 51,52,53,54,55,56,57,58,59,60,61,62,63, // Letters 'n' through 'z' + -9,-9,-9,-9 // Decimal 123 - 126 + /*,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 127 - 139 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243 + -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */ + }; + + +/* ******** D E T E R M I N E W H I C H A L H A B E T ******** */ + + + /** + * Returns one of the _SOMETHING_ALPHABET byte arrays depending on + * the options specified. + * It's possible, though silly, to specify ORDERED and URLSAFE + * in which case one of them will be picked, though there is + * no guarantee as to which one will be picked. + */ + final static byte[] getAlphabet( int options ) { + if ((options & URL_SAFE) == URL_SAFE) { + return _URL_SAFE_ALPHABET; + } else if ((options & ORDERED) == ORDERED) { + return _ORDERED_ALPHABET; + } else { + return _STANDARD_ALPHABET; + } + } // end getAlphabet + + + /** + * Returns one of the _SOMETHING_DECODABET byte arrays depending on + * the options specified. + * It's possible, though silly, to specify ORDERED and URL_SAFE + * in which case one of them will be picked, though there is + * no guarantee as to which one will be picked. + */ + final static byte[] getDecodabet( int options ) { + if( (options & URL_SAFE) == URL_SAFE) { + return _URL_SAFE_DECODABET; + } else if ((options & ORDERED) == ORDERED) { + return _ORDERED_DECODABET; + } else { + return _STANDARD_DECODABET; + } + } // end getAlphabet + + + + /** Defeats instantiation. */ + private Base64(){/**/} + + + + +/* ******** E N C O D I N G M E T H O D S ******** */ + + + /** + * Encodes up to the first three bytes of array threeBytes + * and returns a four-byte array in Base64 notation. + * The actual number of significant bytes in your array is + * given by numSigBytes. + * The array threeBytes needs only be as big as + * numSigBytes. + * Code can reuse a byte array by passing a four-byte array as b4. + * + * @param b4 A reusable byte array to reduce array instantiation + * @param threeBytes the array to convert + * @param numSigBytes the number of significant bytes in your array + * @return four byte array in Base64 notation. + * @since 1.5.1 + */ + static byte[] encode3to4( byte[] b4, byte[] threeBytes, int numSigBytes, int options ) { + encode3to4( threeBytes, 0, numSigBytes, b4, 0, options ); + return b4; + } // end encode3to4 + + + /** + *

Encodes up to three bytes of the array source + * and writes the resulting four Base64 bytes to destination. + * The source and destination arrays can be manipulated + * anywhere along their length by specifying + * srcOffset and destOffset. + * This method does not check to make sure your arrays + * are large enough to accomodate srcOffset + 3 for + * the source array or destOffset + 4 for + * the destination array. + * The actual number of significant bytes in your array is + * given by numSigBytes.

+ *

This is the lowest level of the encoding methods with + * all possible parameters.

+ * + * @param source the array to convert + * @param srcOffset the index where conversion begins + * @param numSigBytes the number of significant bytes in your array + * @param destination the array to hold the conversion + * @param destOffset the index where output will be put + * @return the destination array + * @since 1.3 + */ + static byte[] encode3to4( + byte[] source, int srcOffset, int numSigBytes, + byte[] destination, int destOffset, int options ) { + + byte[] ALPHABET = getAlphabet( options ); + + // 1 2 3 + // 01234567890123456789012345678901 Bit position + // --------000000001111111122222222 Array position from threeBytes + // --------| || || || | Six bit groups to index ALPHABET + // >>18 >>12 >> 6 >> 0 Right shift necessary + // 0x3f 0x3f 0x3f Additional AND + + // Create buffer with zero-padding if there are only one or two + // significant bytes passed in the array. + // We have to shift left 24 in order to flush out the 1's that appear + // when Java treats a value as negative that is cast from a byte to an int. + int inBuff = ( numSigBytes > 0 ? ((source[ srcOffset ] << 24) >>> 8) : 0 ) + | ( numSigBytes > 1 ? ((source[ srcOffset + 1 ] << 24) >>> 16) : 0 ) + | ( numSigBytes > 2 ? ((source[ srcOffset + 2 ] << 24) >>> 24) : 0 ); + + switch( numSigBytes ) + { + case 3: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = ALPHABET[ (inBuff >>> 6) & 0x3f ]; + destination[ destOffset + 3 ] = ALPHABET[ (inBuff ) & 0x3f ]; + return destination; + + case 2: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = ALPHABET[ (inBuff >>> 6) & 0x3f ]; + destination[ destOffset + 3 ] = EQUALS_SIGN; + return destination; + + case 1: + destination[ destOffset ] = ALPHABET[ (inBuff >>> 18) ]; + destination[ destOffset + 1 ] = ALPHABET[ (inBuff >>> 12) & 0x3f ]; + destination[ destOffset + 2 ] = EQUALS_SIGN; + destination[ destOffset + 3 ] = EQUALS_SIGN; + return destination; + + default: + return destination; + } // end switch + } // end encode3to4 + + + + /** + * Performs Base64 encoding on the raw ByteBuffer, + * writing it to the encoded ByteBuffer. + * This is an experimental feature. Currently it does not + * pass along any options (such as {@link #DO_BREAK_LINES} + * or {@link #GZIP}. + * + * @param raw input buffer + * @param encoded output buffer + * @since 2.3 + */ + public static void encode( java.nio.ByteBuffer raw, java.nio.ByteBuffer encoded ){ + byte[] raw3 = new byte[3]; + byte[] enc4 = new byte[4]; + + while( raw.hasRemaining() ){ + int rem = Math.min(3,raw.remaining()); + raw.get(raw3,0,rem); + Base64.encode3to4(enc4, raw3, rem, Base64.NO_OPTIONS ); + encoded.put(enc4); + } // end input remaining + } + + + /** + * Performs Base64 encoding on the raw ByteBuffer, + * writing it to the encoded CharBuffer. + * This is an experimental feature. Currently it does not + * pass along any options (such as {@link #DO_BREAK_LINES} + * or {@link #GZIP}. + * + * @param raw input buffer + * @param encoded output buffer + * @since 2.3 + */ + public static void encode( java.nio.ByteBuffer raw, java.nio.CharBuffer encoded ){ + byte[] raw3 = new byte[3]; + byte[] enc4 = new byte[4]; + + while( raw.hasRemaining() ){ + int rem = Math.min(3,raw.remaining()); + raw.get(raw3,0,rem); + Base64.encode3to4(enc4, raw3, rem, Base64.NO_OPTIONS ); + for( int i = 0; i < 4; i++ ){ + encoded.put( (char)(enc4[i] & 0xFF) ); + } + } // end input remaining + } + + + + + /** + * Serializes an object and returns the Base64-encoded + * version of that serialized object. + * + *

As of v 2.3, if the object + * cannot be serialized or there is another error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * The object is not GZip-compressed before being encoded. + * + * @param serializableObject The object to encode + * @return The Base64-encoded object + * @throws java.io.IOException if there is an error + * @throws NullPointerException if serializedObject is null + * @since 1.4 + */ + public static String encodeObject( java.io.Serializable serializableObject ) + throws java.io.IOException { + return encodeObject( serializableObject, NO_OPTIONS ); + } // end encodeObject + + + + /** + * Serializes an object and returns the Base64-encoded + * version of that serialized object. + * + *

As of v 2.3, if the object + * cannot be serialized or there is another error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * The object is not GZip-compressed before being encoded. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     * 
+ *

+ * Example: encodeObject( myObj, Base64.GZIP ) or + *

+ * Example: encodeObject( myObj, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * @param serializableObject The object to encode + * @param options Specified options + * @return The Base64-encoded object + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @since 2.0 + */ + public static String encodeObject( java.io.Serializable serializableObject, int options ) + throws java.io.IOException { + + if( serializableObject == null ){ + throw new NullPointerException( "Cannot serialize a null object." ); + } // end if: null + + // Streams + java.io.ByteArrayOutputStream baos = null; + java.io.OutputStream b64os = null; + java.util.zip.GZIPOutputStream gzos = null; + java.io.ObjectOutputStream oos = null; + + + try { + // ObjectOutputStream -> (GZIP) -> Base64 -> ByteArrayOutputStream + baos = new java.io.ByteArrayOutputStream(); + b64os = new Base64.OutputStream( baos, ENCODE | options ); + if( (options & GZIP) != 0 ){ + // Gzip + gzos = new java.util.zip.GZIPOutputStream(b64os); + oos = new java.io.ObjectOutputStream( gzos ); + } else { + // Not gzipped + oos = new java.io.ObjectOutputStream( b64os ); + } + oos.writeObject( serializableObject ); + } // end try + catch( java.io.IOException e ) { + // Catch it and then throw it immediately so that + // the finally{/**/} block is called for cleanup. + throw e; + } // end catch + finally { + try{ oos.close(); } catch( Exception e ){/**/} + try{ gzos.close(); } catch( Exception e ){/**/} + try{ b64os.close(); } catch( Exception e ){/**/} + try{ baos.close(); } catch( Exception e ){/**/} + } // end finally + + // Return value according to relevant encoding. + try { + return new String( baos.toByteArray(), PREFERRED_ENCODING ); + } // end try + catch (java.io.UnsupportedEncodingException uue){ + // Fall back to some Java default + return new String( baos.toByteArray() ); + } // end catch + + } // end encode + + + + /** + * Encodes a byte array into Base64 notation. + * Does not GZip-compress data. + * + * @param source The data to convert + * @return The data in Base64-encoded form + * @throws NullPointerException if source array is null + * @since 1.4 + */ + public static String encodeBytes( byte[] source ) { + // Since we're not going to have the GZIP encoding turned on, + // we're not going to have an java.io.IOException thrown, so + // we should not force the user to have to catch it. + String encoded = null; + try { + encoded = encodeBytes(source, 0, source.length, NO_OPTIONS); + } catch (java.io.IOException ex) { + assert false : ex.getMessage(); + } // end catch + assert encoded != null; + return encoded; + } // end encodeBytes + + + + /** + * Encodes a byte array into Base64 notation. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     *     Note: Technically, this makes your encoding non-compliant.
+     * 
+ *

+ * Example: encodeBytes( myData, Base64.GZIP ) or + *

+ * Example: encodeBytes( myData, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * + *

As of v 2.3, if there is an error with the GZIP stream, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @since 2.0 + */ + public static String encodeBytes( byte[] source, int options ) throws java.io.IOException { + return encodeBytes( source, 0, source.length, options ); + } // end encodeBytes + + + /** + * Encodes a byte array into Base64 notation. + * Does not GZip-compress data. + * + *

As of v 2.3, if there is an error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @return The Base64-encoded data as a String + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 1.4 + */ + public static String encodeBytes( byte[] source, int off, int len ) { + // Since we're not going to have the GZIP encoding turned on, + // we're not going to have an java.io.IOException thrown, so + // we should not force the user to have to catch it. + String encoded = null; + try { + encoded = encodeBytes( source, off, len, NO_OPTIONS ); + } catch (java.io.IOException ex) { + assert false : ex.getMessage(); + } // end catch + assert encoded != null; + return encoded; + } // end encodeBytes + + + + /** + * Encodes a byte array into Base64 notation. + *

+ * Example options:

+     *   GZIP: gzip-compresses object before encoding it.
+     *   DO_BREAK_LINES: break lines at 76 characters
+     *     Note: Technically, this makes your encoding non-compliant.
+     * 
+ *

+ * Example: encodeBytes( myData, Base64.GZIP ) or + *

+ * Example: encodeBytes( myData, Base64.GZIP | Base64.DO_BREAK_LINES ) + * + * + *

As of v 2.3, if there is an error with the GZIP stream, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned a null value, but + * in retrospect that's a pretty poor way to handle it.

+ * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 2.0 + */ + public static String encodeBytes( byte[] source, int off, int len, int options ) throws java.io.IOException { + byte[] encoded = encodeBytesToBytes( source, off, len, options ); + + // Return value according to relevant encoding. + try { + return new String( encoded, PREFERRED_ENCODING ); + } // end try + catch (java.io.UnsupportedEncodingException uue) { + return new String( encoded ); + } // end catch + + } // end encodeBytes + + + + + /** + * Similar to {@link #encodeBytes(byte[])} but returns + * a byte array instead of instantiating a String. This is more efficient + * if you're working with I/O streams and have large data sets to encode. + * + * + * @param source The data to convert + * @return The Base64-encoded data as a byte[] (of ASCII characters) + * @throws NullPointerException if source array is null + * @since 2.3.1 + */ + public static byte[] encodeBytesToBytes( byte[] source ) { + byte[] encoded = null; + try { + encoded = encodeBytesToBytes( source, 0, source.length, Base64.NO_OPTIONS ); + } catch( java.io.IOException ex ) { + assert false : "IOExceptions only come from GZipping, which is turned off: " + ex.getMessage(); + } + return encoded; + } + + + /** + * Similar to {@link #encodeBytes(byte[], int, int, int)} but returns + * a byte array instead of instantiating a String. This is more efficient + * if you're working with I/O streams and have large data sets to encode. + * + * + * @param source The data to convert + * @param off Offset in array where conversion should begin + * @param len Length of data to convert + * @param options Specified options + * @return The Base64-encoded data as a String + * @see Base64#GZIP + * @see Base64#DO_BREAK_LINES + * @throws java.io.IOException if there is an error + * @throws NullPointerException if source array is null + * @throws IllegalArgumentException if source array, offset, or length are invalid + * @since 2.3.1 + */ + public static byte[] encodeBytesToBytes( byte[] source, int off, int len, int options ) throws java.io.IOException { + + if( source == null ){ + throw new NullPointerException( "Cannot serialize a null array." ); + } // end if: null + + if( off < 0 ){ + throw new IllegalArgumentException( "Cannot have negative offset: " + off ); + } // end if: off < 0 + + if( len < 0 ){ + throw new IllegalArgumentException( "Cannot have length offset: " + len ); + } // end if: len < 0 + + if( off + len > source.length ){ + throw new IllegalArgumentException( + String.format( "Cannot have offset of %d and length of %d with array of length %d", off,len,source.length)); + } // end if: off < 0 + + + + // Compress? + if( (options & GZIP) != 0 ) { + java.io.ByteArrayOutputStream baos = null; + java.util.zip.GZIPOutputStream gzos = null; + Base64.OutputStream b64os = null; + + try { + // GZip -> Base64 -> ByteArray + baos = new java.io.ByteArrayOutputStream(); + b64os = new Base64.OutputStream( baos, ENCODE | options ); + gzos = new java.util.zip.GZIPOutputStream( b64os ); + + gzos.write( source, off, len ); + gzos.close(); + } // end try + catch( java.io.IOException e ) { + // Catch it and then throw it immediately so that + // the finally{/**/} block is called for cleanup. + throw e; + } // end catch + finally { + try{ gzos.close(); } catch( Exception e ){/**/} + try{ b64os.close(); } catch( Exception e ){/**/} + try{ baos.close(); } catch( Exception e ){/**/} + } // end finally + + return baos.toByteArray(); + } // end if: compress + + // Else, don't compress. Better not to use streams at all then. + else { + boolean breakLines = (options & DO_BREAK_LINES) > 0; + + //int len43 = len * 4 / 3; + //byte[] outBuff = new byte[ ( len43 ) // Main 4:3 + // + ( (len % 3) > 0 ? 4 : 0 ) // Account for padding + // + (breakLines ? ( len43 / MAX_LINE_LENGTH ) : 0) ]; // New lines + // Try to determine more precisely how big the array needs to be. + // If we get it right, we don't have to do an array copy, and + // we save a bunch of memory. + int encLen = ( len / 3 ) * 4 + ( len % 3 > 0 ? 4 : 0 ); // Bytes needed for actual encoding + if( breakLines ){ + encLen += encLen / MAX_LINE_LENGTH; // Plus extra newline characters + } + byte[] outBuff = new byte[ encLen ]; + + + int d = 0; + int e = 0; + int len2 = len - 2; + int lineLength = 0; + for( ; d < len2; d+=3, e+=4 ) { + encode3to4( source, d+off, 3, outBuff, e, options ); + + lineLength += 4; + if( breakLines && lineLength >= MAX_LINE_LENGTH ) + { + outBuff[e+4] = NEW_LINE; + e++; + lineLength = 0; + } // end if: end of line + } // en dfor: each piece of array + + if( d < len ) { + encode3to4( source, d+off, len - d, outBuff, e, options ); + e += 4; + } // end if: some padding needed + + + // Only resize array if we didn't guess it right. + if( e < outBuff.length - 1 ){ + byte[] finalOut = new byte[e]; + System.arraycopy(outBuff,0, finalOut,0,e); + //System.err.println("Having to resize array from " + outBuff.length + " to " + e ); + return finalOut; + } else { + //System.err.println("No need to resize array."); + return outBuff; + } + + } // end else: don't compress + + } // end encodeBytesToBytes + + + + + +/* ******** D E C O D I N G M E T H O D S ******** */ + + + /** + * Decodes four bytes from array source + * and writes the resulting bytes (up to three of them) + * to destination. + * The source and destination arrays can be manipulated + * anywhere along their length by specifying + * srcOffset and destOffset. + * This method does not check to make sure your arrays + * are large enough to accomodate srcOffset + 4 for + * the source array or destOffset + 3 for + * the destination array. + * This method returns the actual number of bytes that + * were converted from the Base64 encoding. + *

This is the lowest level of the decoding methods with + * all possible parameters.

+ * + * + * @param source the array to convert + * @param srcOffset the index where conversion begins + * @param destination the array to hold the conversion + * @param destOffset the index where output will be put + * @param options alphabet type is pulled from this (standard, url-safe, ordered) + * @return the number of decoded bytes converted + * @throws NullPointerException if source or destination arrays are null + * @throws IllegalArgumentException if srcOffset or destOffset are invalid + * or there is not enough room in the array. + * @since 1.3 + */ + static int decode4to3( + byte[] source, int srcOffset, + byte[] destination, int destOffset, int options ) { + + // Lots of error checking and exception throwing + if( source == null ){ + throw new NullPointerException( "Source array was null." ); + } // end if + if( destination == null ){ + throw new NullPointerException( "Destination array was null." ); + } // end if + if( srcOffset < 0 || srcOffset + 3 >= source.length ){ + throw new IllegalArgumentException( String.format( + "Source array with length %d cannot have offset of %d and still process four bytes.", source.length, srcOffset ) ); + } // end if + if( destOffset < 0 || destOffset +2 >= destination.length ){ + throw new IllegalArgumentException( String.format( + "Destination array with length %d cannot have offset of %d and still store three bytes.", destination.length, destOffset ) ); + } // end if + + + byte[] DECODABET = getDecodabet( options ); + + // Example: Dk== + if( source[ srcOffset + 2] == EQUALS_SIGN ) { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1] ] << 24 ) >>> 12 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1] ] & 0xFF ) << 12 ); + + destination[ destOffset ] = (byte)( outBuff >>> 16 ); + return 1; + } + + // Example: DkL= + else if( source[ srcOffset + 3 ] == EQUALS_SIGN ) { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1 ] ] << 24 ) >>> 12 ) + // | ( ( DECODABET[ source[ srcOffset + 2 ] ] << 24 ) >>> 18 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1 ] ] & 0xFF ) << 12 ) + | ( ( DECODABET[ source[ srcOffset + 2 ] ] & 0xFF ) << 6 ); + + destination[ destOffset ] = (byte)( outBuff >>> 16 ); + destination[ destOffset + 1 ] = (byte)( outBuff >>> 8 ); + return 2; + } + + // Example: DkLE + else { + // Two ways to do the same thing. Don't know which way I like best. + //int outBuff = ( ( DECODABET[ source[ srcOffset ] ] << 24 ) >>> 6 ) + // | ( ( DECODABET[ source[ srcOffset + 1 ] ] << 24 ) >>> 12 ) + // | ( ( DECODABET[ source[ srcOffset + 2 ] ] << 24 ) >>> 18 ) + // | ( ( DECODABET[ source[ srcOffset + 3 ] ] << 24 ) >>> 24 ); + int outBuff = ( ( DECODABET[ source[ srcOffset ] ] & 0xFF ) << 18 ) + | ( ( DECODABET[ source[ srcOffset + 1 ] ] & 0xFF ) << 12 ) + | ( ( DECODABET[ source[ srcOffset + 2 ] ] & 0xFF ) << 6) + | ( ( DECODABET[ source[ srcOffset + 3 ] ] & 0xFF ) ); + + + destination[ destOffset ] = (byte)( outBuff >> 16 ); + destination[ destOffset + 1 ] = (byte)( outBuff >> 8 ); + destination[ destOffset + 2 ] = (byte)( outBuff ); + + return 3; + } + } // end decodeToBytes + + + + + + /** + * Low-level access to decoding ASCII characters in + * the form of a byte array. Ignores GUNZIP option, if + * it's set. This is not generally a recommended method, + * although it is used internally as part of the decoding process. + * Special case: if len = 0, an empty array is returned. Still, + * if you need more speed and reduced memory footprint (and aren't + * gzipping), consider this method. + * + * @param source The Base64 encoded data + * @return decoded data + * @since 2.3.1 + */ + public static byte[] decode( byte[] source ){ + byte[] decoded = null; + try { + decoded = decode( source, 0, source.length, Base64.NO_OPTIONS ); + } catch( java.io.IOException ex ) { + assert false : "IOExceptions only come from GZipping, which is turned off: " + ex.getMessage(); + } + return decoded; + } + + + + /** + * Low-level access to decoding ASCII characters in + * the form of a byte array. Ignores GUNZIP option, if + * it's set. This is not generally a recommended method, + * although it is used internally as part of the decoding process. + * Special case: if len = 0, an empty array is returned. Still, + * if you need more speed and reduced memory footprint (and aren't + * gzipping), consider this method. + * + * @param source The Base64 encoded data + * @param off The offset of where to begin decoding + * @param len The length of characters to decode + * @param options Can specify options such as alphabet type to use + * @return decoded data + * @throws java.io.IOException If bogus characters exist in source data + * @since 1.3 + */ + public static byte[] decode( byte[] source, int off, int len, int options ) + throws java.io.IOException { + + // Lots of error checking and exception throwing + if( source == null ){ + throw new NullPointerException( "Cannot decode null source array." ); + } // end if + if( off < 0 || off + len > source.length ){ + throw new IllegalArgumentException( String.format( + "Source array with length %d cannot have offset of %d and process %d bytes.", source.length, off, len ) ); + } // end if + + if( len == 0 ){ + return new byte[0]; + }else if( len < 4 ){ + throw new IllegalArgumentException( + "Base64-encoded string must have at least four characters, but length specified was " + len ); + } // end if + + byte[] DECODABET = getDecodabet( options ); + + int len34 = len * 3 / 4; // Estimate on array size + byte[] outBuff = new byte[ len34 ]; // Upper limit on size of output + int outBuffPosn = 0; // Keep track of where we're writing + + byte[] b4 = new byte[4]; // Four byte buffer from source, eliminating white space + int b4Posn = 0; // Keep track of four byte input buffer + int i = 0; // Source array counter + byte sbiCrop = 0; // Low seven bits (ASCII) of input + byte sbiDecode = 0; // Special value from DECODABET + + for( i = off; i < off+len; i++ ) { // Loop through source + + sbiCrop = (byte)(source[i] & 0x7f); // Only the low seven bits + sbiDecode = DECODABET[ sbiCrop ]; // Special value + + // White space, Equals sign, or legit Base64 character + // Note the values such as -5 and -9 in the + // DECODABETs at the top of the file. + if( sbiDecode >= WHITE_SPACE_ENC ) { + if( sbiDecode >= EQUALS_SIGN_ENC ) { + b4[ b4Posn++ ] = sbiCrop; // Save non-whitespace + if( b4Posn > 3 ) { // Time to decode? + outBuffPosn += decode4to3( b4, 0, outBuff, outBuffPosn, options ); + b4Posn = 0; + + // If that was the equals sign, break out of 'for' loop + if( sbiCrop == EQUALS_SIGN ) { + break; + } // end if: equals sign + } // end if: quartet built + } // end if: equals sign or better + } // end if: white space, equals sign or better + else { + // There's a bad input character in the Base64 stream. + throw new java.io.IOException( String.format( + "Bad Base64 input character '%c' in array position %d", source[i], i ) ); + } // end else: + } // each input character + + byte[] out = new byte[ outBuffPosn ]; + System.arraycopy( outBuff, 0, out, 0, outBuffPosn ); + return out; + } // end decode + + + + + /** + * Decodes data from Base64 notation, automatically + * detecting gzip-compressed data and decompressing it. + * + * @param s the string to decode + * @return the decoded data + * @throws java.io.IOException If there is a problem + * @since 1.4 + */ + public static byte[] decode( String s ) throws java.io.IOException { + return decode( s, NO_OPTIONS ); + } + + + + /** + * Decodes data from Base64 notation, automatically + * detecting gzip-compressed data and decompressing it. + * + * @param s the string to decode + * @param options encode options such as URL_SAFE + * @return the decoded data + * @throws java.io.IOException if there is an error + * @throws NullPointerException if s is null + * @since 1.4 + */ + public static byte[] decode( String s, int options ) throws java.io.IOException { + + if( s == null ){ + throw new NullPointerException( "Input string was null." ); + } // end if + + byte[] bytes; + try { + bytes = s.getBytes( PREFERRED_ENCODING ); + } // end try + catch( java.io.UnsupportedEncodingException uee ) { + bytes = s.getBytes(); + } // end catch + // + + // Decode + bytes = decode( bytes, 0, bytes.length, options ); + + // Check to see if it's gzip-compressed + // GZIP Magic Two-Byte Number: 0x8b1f (35615) + boolean dontGunzip = (options & DONT_GUNZIP) != 0; + if( (bytes != null) && (bytes.length >= 4) && (!dontGunzip) ) { + + int head = ((int)bytes[0] & 0xff) | ((bytes[1] << 8) & 0xff00); + if( java.util.zip.GZIPInputStream.GZIP_MAGIC == head ) { + java.io.ByteArrayInputStream bais = null; + java.util.zip.GZIPInputStream gzis = null; + java.io.ByteArrayOutputStream baos = null; + byte[] buffer = new byte[2048]; + int length = 0; + + try { + baos = new java.io.ByteArrayOutputStream(); + bais = new java.io.ByteArrayInputStream( bytes ); + gzis = new java.util.zip.GZIPInputStream( bais ); + + while( ( length = gzis.read( buffer ) ) >= 0 ) { + baos.write(buffer,0,length); + } // end while: reading input + + // No error? Get new bytes. + bytes = baos.toByteArray(); + + } // end try + catch( java.io.IOException e ) { + e.printStackTrace(); + // Just return originally-decoded bytes + } // end catch + finally { + try{ baos.close(); } catch( Exception e ){/**/} + try{ gzis.close(); } catch( Exception e ){/**/} + try{ bais.close(); } catch( Exception e ){/**/} + } // end finally + + } // end if: gzipped + } // end if: bytes.length >= 2 + + return bytes; + } // end decode + + + + /** + * Attempts to decode Base64 data and deserialize a Java + * Object within. Returns null if there was an error. + * + * @param encodedObject The Base64 data to decode + * @return The decoded and deserialized object + * @throws NullPointerException if encodedObject is null + * @throws java.io.IOException if there is a general error + * @throws ClassNotFoundException if the decoded object is of a + * class that cannot be found by the JVM + * @since 1.5 + */ + public static Object decodeToObject( String encodedObject ) + throws java.io.IOException, java.lang.ClassNotFoundException { + return decodeToObject(encodedObject,NO_OPTIONS,null); + } + + + /** + * Attempts to decode Base64 data and deserialize a Java + * Object within. Returns null if there was an error. + * If loader is not null, it will be the class loader + * used when deserializing. + * + * @param encodedObject The Base64 data to decode + * @param options Various parameters related to decoding + * @param loader Optional class loader to use in deserializing classes. + * @return The decoded and deserialized object + * @throws NullPointerException if encodedObject is null + * @throws java.io.IOException if there is a general error + * @throws ClassNotFoundException if the decoded object is of a + * class that cannot be found by the JVM + * @since 2.3.4 + */ + public static Object decodeToObject( + String encodedObject, int options, final ClassLoader loader ) + throws java.io.IOException, java.lang.ClassNotFoundException { + + // Decode and gunzip if necessary + byte[] objBytes = decode( encodedObject, options ); + + java.io.ByteArrayInputStream bais = null; + java.io.ObjectInputStream ois = null; + Object obj = null; + + try { + bais = new java.io.ByteArrayInputStream( objBytes ); + + // If no custom class loader is provided, use Java's builtin OIS. + if( loader == null ){ + ois = new java.io.ObjectInputStream( bais ); + } // end if: no loader provided + + // Else make a customized object input stream that uses + // the provided class loader. + else { + ois = new java.io.ObjectInputStream(bais){ + @Override + public Class resolveClass(java.io.ObjectStreamClass streamClass) + throws java.io.IOException, ClassNotFoundException { + Class c = Class.forName(streamClass.getName(), false, loader); + if( c == null ){ + return super.resolveClass(streamClass); + } else { + return c; // Class loader knows of this class. + } // end else: not null + } // end resolveClass + }; // end ois + } // end else: no custom class loader + + obj = ois.readObject(); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw in order to execute finally{/**/} + } // end catch + catch( java.lang.ClassNotFoundException e ) { + throw e; // Catch and throw in order to execute finally{/**/} + } // end catch + finally { + try{ bais.close(); } catch( Exception e ){/**/} + try{ ois.close(); } catch( Exception e ){/**/} + } // end finally + + return obj; + } // end decodeObject + + + + /** + * Convenience method for encoding data to a file. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param dataToEncode byte array of data to encode in base64 form + * @param filename Filename for saving encoded data + * @throws java.io.IOException if there is an error + * @throws NullPointerException if dataToEncode is null + * @since 2.1 + */ + public static void encodeToFile( byte[] dataToEncode, String filename ) + throws java.io.IOException { + + if( dataToEncode == null ){ + throw new NullPointerException( "Data to encode was null." ); + } // end iff + + Base64.OutputStream bos = null; + try { + bos = new Base64.OutputStream( + new java.io.FileOutputStream( filename ), Base64.ENCODE ); + bos.write( dataToEncode ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw to execute finally{/**/} block + } // end catch: java.io.IOException + finally { + try{ bos.close(); } catch( Exception e ){/**/} + } // end finally + + } // end encodeToFile + + + /** + * Convenience method for decoding data to a file. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param dataToDecode Base64-encoded data as a string + * @param filename Filename for saving decoded data + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static void decodeToFile( String dataToDecode, String filename ) + throws java.io.IOException { + + Base64.OutputStream bos = null; + try{ + bos = new Base64.OutputStream( + new java.io.FileOutputStream( filename ), Base64.DECODE ); + bos.write( dataToDecode.getBytes( PREFERRED_ENCODING ) ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and throw to execute finally{/**/} block + } // end catch: java.io.IOException + finally { + try{ bos.close(); } catch( Exception e ){/**/} + } // end finally + + } // end decodeToFile + + + + + /** + * Convenience method for reading a base64-encoded + * file and decoding it. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param filename Filename for reading encoded data + * @return decoded byte array + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static byte[] decodeFromFile( String filename ) + throws java.io.IOException { + + byte[] decodedData = null; + Base64.InputStream bis = null; + try + { + // Set up some useful variables + java.io.File file = new java.io.File( filename ); + byte[] buffer = null; + int length = 0; + int numBytes = 0; + + // Check for size of file + if( file.length() > Integer.MAX_VALUE ) + { + throw new java.io.IOException( "File is too big for this convenience method (" + file.length() + " bytes)." ); + } // end if: file too big for int index + buffer = new byte[ (int)file.length() ]; + + // Open a stream + bis = new Base64.InputStream( + new java.io.BufferedInputStream( + new java.io.FileInputStream( file ) ), Base64.DECODE ); + + // Read until done + while( ( numBytes = bis.read( buffer, length, 4096 ) ) >= 0 ) { + length += numBytes; + } // end while + + // Save in a variable to return + decodedData = new byte[ length ]; + System.arraycopy( buffer, 0, decodedData, 0, length ); + + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{/**/} + } // end catch: java.io.IOException + finally { + try{ bis.close(); } catch( Exception e) {/**/} + } // end finally + + return decodedData; + } // end decodeFromFile + + + + /** + * Convenience method for reading a binary file + * and base64-encoding it. + * + *

As of v 2.3, if there is a error, + * the method will throw an java.io.IOException. This is new to v2.3! + * In earlier versions, it just returned false, but + * in retrospect that's a pretty poor way to handle it.

+ * + * @param filename Filename for reading binary data + * @return base64-encoded string + * @throws java.io.IOException if there is an error + * @since 2.1 + */ + public static String encodeFromFile( String filename ) + throws java.io.IOException { + + String encodedData = null; + Base64.InputStream bis = null; + try + { + // Set up some useful variables + java.io.File file = new java.io.File( filename ); + byte[] buffer = new byte[ Math.max((int)(file.length() * 1.4),40) ]; // Need max() for math on small files (v2.2.1) + int length = 0; + int numBytes = 0; + + // Open a stream + bis = new Base64.InputStream( + new java.io.BufferedInputStream( + new java.io.FileInputStream( file ) ), Base64.ENCODE ); + + // Read until done + while( ( numBytes = bis.read( buffer, length, 4096 ) ) >= 0 ) { + length += numBytes; + } // end while + + // Save in a variable to return + encodedData = new String( buffer, 0, length, Base64.PREFERRED_ENCODING ); + + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{/**/} + } // end catch: java.io.IOException + finally { + try{ bis.close(); } catch( Exception e) {/**/} + } // end finally + + return encodedData; + } // end encodeFromFile + + /** + * Reads infile and encodes it to outfile. + * + * @param infile Input file + * @param outfile Output file + * @throws java.io.IOException if there is an error + * @since 2.2 + */ + public static void encodeFileToFile( String infile, String outfile ) + throws java.io.IOException { + + String encoded = Base64.encodeFromFile( infile ); + java.io.OutputStream out = null; + try{ + out = new java.io.BufferedOutputStream( + new java.io.FileOutputStream( outfile ) ); + out.write( encoded.getBytes("US-ASCII") ); // Strict, 7-bit output. + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{/**/} + } // end catch + finally { + try { out.close(); } + catch( Exception ex ){/**/} + } // end finally + } // end encodeFileToFile + + + /** + * Reads infile and decodes it to outfile. + * + * @param infile Input file + * @param outfile Output file + * @throws java.io.IOException if there is an error + * @since 2.2 + */ + public static void decodeFileToFile( String infile, String outfile ) + throws java.io.IOException { + + byte[] decoded = Base64.decodeFromFile( infile ); + java.io.OutputStream out = null; + try{ + out = new java.io.BufferedOutputStream( + new java.io.FileOutputStream( outfile ) ); + out.write( decoded ); + } // end try + catch( java.io.IOException e ) { + throw e; // Catch and release to execute finally{/**/} + } // end catch + finally { + try { out.close(); } + catch( Exception ex ){/**/} + } // end finally + } // end decodeFileToFile + + + /* ******** I N N E R C L A S S I N P U T S T R E A M ******** */ + + + + /** + * A {@link Base64.InputStream} will read data from another + * java.io.InputStream, given in the constructor, + * and encode/decode to/from Base64 notation on the fly. + * + * @see Base64 + * @since 1.3 + */ + public static class InputStream extends java.io.FilterInputStream { + + private boolean encode; // Encoding or decoding + private int position; // Current position in the buffer + private byte[] buffer; // Small buffer holding converted data + private int bufferLength; // Length of buffer (3 or 4) + private int numSigBytes; // Number of meaningful bytes in the buffer + private int lineLength; + private boolean breakLines; // Break lines at less than 80 characters + private int options; // Record options used to create the stream. + private byte[] decodabet; // Local copies to avoid extra method calls + + + /** + * Constructs a {@link Base64.InputStream} in DECODE mode. + * + * @param in the java.io.InputStream from which to read data. + * @since 1.3 + */ + public InputStream( java.io.InputStream in ) { + this( in, DECODE ); + } // end constructor + + + /** + * Constructs a {@link Base64.InputStream} in + * either ENCODE or DECODE mode. + *

+ * Valid options:

+         *   ENCODE or DECODE: Encode or Decode as data is read.
+         *   DO_BREAK_LINES: break lines at 76 characters
+         *     (only meaningful when encoding)
+         * 
+ *

+ * Example: new Base64.InputStream( in, Base64.DECODE ) + * + * + * @param in the java.io.InputStream from which to read data. + * @param options Specified options + * @see Base64#ENCODE + * @see Base64#DECODE + * @see Base64#DO_BREAK_LINES + * @since 2.0 + */ + public InputStream( java.io.InputStream in, int options ) { + + super( in ); + this.options = options; // Record for later + this.breakLines = (options & DO_BREAK_LINES) > 0; + this.encode = (options & ENCODE) > 0; + this.bufferLength = encode ? 4 : 3; + this.buffer = new byte[ bufferLength ]; + this.position = -1; + this.lineLength = 0; + this.decodabet = getDecodabet(options); + } // end constructor + + /** + * Reads enough of the input stream to convert + * to/from Base64 and returns the next byte. + * + * @return next byte + * @since 1.3 + */ + @Override + public int read() throws java.io.IOException { + + // Do we need to get data? + if( position < 0 ) { + if( encode ) { + byte[] b3 = new byte[3]; + int numBinaryBytes = 0; + for( int i = 0; i < 3; i++ ) { + int b = in.read(); + + // If end of stream, b is -1. + if( b >= 0 ) { + b3[i] = (byte)b; + numBinaryBytes++; + } else { + break; // out of for loop + } // end else: end of stream + + } // end for: each needed input byte + + if( numBinaryBytes > 0 ) { + encode3to4( b3, 0, numBinaryBytes, buffer, 0, options ); + position = 0; + numSigBytes = 4; + } // end if: got data + else { + return -1; // Must be end of stream + } // end else + } // end if: encoding + + // Else decoding + else { + byte[] b4 = new byte[4]; + int i = 0; + for( i = 0; i < 4; i++ ) { + // Read four "meaningful" bytes: + int b = 0; + do{ b = in.read(); } + while( b >= 0 && decodabet[ b & 0x7f ] <= WHITE_SPACE_ENC ); + + if( b < 0 ) { + break; // Reads a -1 if end of stream + } // end if: end of stream + + b4[i] = (byte)b; + } // end for: each needed input byte + + if( i == 4 ) { + numSigBytes = decode4to3( b4, 0, buffer, 0, options ); + position = 0; + } // end if: got four characters + else if( i == 0 ){ + return -1; + } // end else if: also padded correctly + else { + // Must have broken out from above. + throw new java.io.IOException( "Improperly padded Base64 input." ); + } // end + + } // end else: decode + } // end else: get data + + // Got data? + if( position >= 0 ) { + // End of relevant data? + if( /*!encode &&*/ position >= numSigBytes ){ + return -1; + } // end if: got data + + if( encode && breakLines && lineLength >= MAX_LINE_LENGTH ) { + lineLength = 0; + return '\n'; + } // end if + else { + lineLength++; // This isn't important when decoding + // but throwing an extra "if" seems + // just as wasteful. + + int b = buffer[ position++ ]; + + if( position >= bufferLength ) { + position = -1; + } // end if: end + + return b & 0xFF; // This is how you "cast" a byte that's + // intended to be unsigned. + } // end else + } // end if: position >= 0 + + // Else error + else { + throw new java.io.IOException( "Error in Base64 code reading stream." ); + } // end else + } // end read + + + /** + * Calls {@link #read()} repeatedly until the end of stream + * is reached or len bytes are read. + * Returns number of bytes read into array or -1 if + * end of stream is encountered. + * + * @param dest array to hold values + * @param off offset for array + * @param len max number of bytes to read into array + * @return bytes read into array or -1 if end of stream is encountered. + * @since 1.3 + */ + @Override + public int read( byte[] dest, int off, int len ) + throws java.io.IOException { + int i; + int b; + for( i = 0; i < len; i++ ) { + b = read(); + + if( b >= 0 ) { + dest[off + i] = (byte) b; + } + else if( i == 0 ) { + return -1; + } + else { + break; // Out of 'for' loop + } // Out of 'for' loop + } // end for: each byte read + return i; + } // end read + + } // end inner class InputStream + + + + + + + /* ******** I N N E R C L A S S O U T P U T S T R E A M ******** */ + + + + /** + * A {@link Base64.OutputStream} will write data to another + * java.io.OutputStream, given in the constructor, + * and encode/decode to/from Base64 notation on the fly. + * + * @see Base64 + * @since 1.3 + */ + public static class OutputStream extends java.io.FilterOutputStream { + + private boolean encode; + private int position; + private byte[] buffer; + private int bufferLength; + private int lineLength; + private boolean breakLines; + private byte[] b4; // Scratch used in a few places + private boolean suspendEncoding; + private int options; // Record for later + private byte[] decodabet; // Local copies to avoid extra method calls + + /** + * Constructs a {@link Base64.OutputStream} in ENCODE mode. + * + * @param out the java.io.OutputStream to which data will be written. + * @since 1.3 + */ + public OutputStream( java.io.OutputStream out ) { + this( out, ENCODE ); + } // end constructor + + + /** + * Constructs a {@link Base64.OutputStream} in + * either ENCODE or DECODE mode. + *

+ * Valid options:

+         *   ENCODE or DECODE: Encode or Decode as data is read.
+         *   DO_BREAK_LINES: don't break lines at 76 characters
+         *     (only meaningful when encoding)
+         * 
+ *

+ * Example: new Base64.OutputStream( out, Base64.ENCODE ) + * + * @param out the java.io.OutputStream to which data will be written. + * @param options Specified options. + * @see Base64#ENCODE + * @see Base64#DECODE + * @see Base64#DO_BREAK_LINES + * @since 1.3 + */ + public OutputStream( java.io.OutputStream out, int options ) { + super( out ); + this.breakLines = (options & DO_BREAK_LINES) != 0; + this.encode = (options & ENCODE) != 0; + this.bufferLength = encode ? 3 : 4; + this.buffer = new byte[ bufferLength ]; + this.position = 0; + this.lineLength = 0; + this.suspendEncoding = false; + this.b4 = new byte[4]; + this.options = options; + this.decodabet = getDecodabet(options); + } // end constructor + + + /** + * Writes the byte to the output stream after + * converting to/from Base64 notation. + * When encoding, bytes are buffered three + * at a time before the output stream actually + * gets a write() call. + * When decoding, bytes are buffered four + * at a time. + * + * @param theByte the byte to write + * @since 1.3 + */ + @Override + public void write(int theByte) + throws java.io.IOException { + // Encoding suspended? + if( suspendEncoding ) { + this.out.write( theByte ); + return; + } // end if: supsended + + // Encode? + if( encode ) { + buffer[ position++ ] = (byte)theByte; + if( position >= bufferLength ) { // Enough to encode. + + this.out.write( encode3to4( b4, buffer, bufferLength, options ) ); + + lineLength += 4; + if( breakLines && lineLength >= MAX_LINE_LENGTH ) { + this.out.write( NEW_LINE ); + lineLength = 0; + } // end if: end of line + + position = 0; + } // end if: enough to output + } // end if: encoding + + // Else, Decoding + else { + // Meaningful Base64 character? + if( decodabet[ theByte & 0x7f ] > WHITE_SPACE_ENC ) { + buffer[ position++ ] = (byte)theByte; + if( position >= bufferLength ) { // Enough to output. + + int len = Base64.decode4to3( buffer, 0, b4, 0, options ); + out.write( b4, 0, len ); + position = 0; + } // end if: enough to output + } // end if: meaningful base64 character + else if( decodabet[ theByte & 0x7f ] != WHITE_SPACE_ENC ) { + throw new java.io.IOException( "Invalid character in Base64 data." ); + } // end else: not white space either + } // end else: decoding + } // end write + + + + /** + * Calls {@link #write(int)} repeatedly until len + * bytes are written. + * + * @param theBytes array from which to read bytes + * @param off offset for array + * @param len max number of bytes to read into array + * @since 1.3 + */ + @Override + public void write( byte[] theBytes, int off, int len ) + throws java.io.IOException { + // Encoding suspended? + if( suspendEncoding ) { + this.out.write( theBytes, off, len ); + return; + } // end if: supsended + + for( int i = 0; i < len; i++ ) { + write( theBytes[ off + i ] ); + } // end for: each byte written + + } // end write + + + + /** + * Method added by PHIL. [Thanks, PHIL. -Rob] + * This pads the buffer without closing the stream. + * @throws java.io.IOException if there's an error. + */ + public void flushBase64() throws java.io.IOException { + if( position > 0 ) { + if( encode ) { + out.write( encode3to4( b4, buffer, position, options ) ); + position = 0; + } // end if: encoding + else { + throw new java.io.IOException( "Base64 input not properly padded." ); + } // end else: decoding + } // end if: buffer partially full + + } // end flush + + + /** + * Flushes and closes (I think, in the superclass) the stream. + * + * @since 1.3 + */ + @Override + public void close() throws java.io.IOException { + // 1. Ensure that pending characters are written + flushBase64(); + + // 2. Actually close the stream + // Base class both flushes and closes. + super.close(); + + buffer = null; + out = null; + } // end close + + + + /** + * Suspends encoding of the stream. + * May be helpful if you need to embed a piece of + * base64-encoded data in a stream. + * + * @throws java.io.IOException if there's an error flushing + * @since 1.5.1 + */ + public void suspendEncoding() throws java.io.IOException { + flushBase64(); + this.suspendEncoding = true; + } // end suspendEncoding + + + /** + * Resumes encoding of the stream. + * May be helpful if you need to embed a piece of + * base64-encoded data in a stream. + * + * @since 1.5.1 + */ + public void resumeEncoding() { + this.suspendEncoding = false; + } // end resumeEncoding + + + + } // end inner class OutputStream + + +} // end class Base64 diff --git a/common-src/com/todoroo/andlib/utility/DateUtilities.java b/common-src/com/todoroo/andlib/utility/DateUtilities.java new file mode 100644 index 000000000..39685bf13 --- /dev/null +++ b/common-src/com/todoroo/andlib/utility/DateUtilities.java @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2009, Todoroo Inc + * All Rights Reserved + * http://www.todoroo.com + */ +package com.todoroo.andlib.utility; + +import java.util.Date; + +import android.content.res.Resources; + +import com.todoroo.andlib.service.Autowired; +import com.todoroo.andlib.service.DependencyInjectionService; + + +public class DateUtilities { + + @Autowired + public Integer yearsResource; + + @Autowired + public Integer monthsResource; + + @Autowired + public Integer daysResource; + + @Autowired + public Integer hoursResource; + + @Autowired + public Integer minutesResource; + + @Autowired + public Integer secondsResource; + + @Autowired + public Integer daysAbbrevResource; + + @Autowired + public Integer hoursAbbrevResource; + + @Autowired + public Integer minutesAbbrevResource; + + @Autowired + public Integer secondsAbbrevResource; + + public DateUtilities() { + DependencyInjectionService.getInstance().inject(this); + } + + /* ====================================================================== + * ============================================================ unix time + * ====================================================================== */ + + /** Convert unixtime into date */ + public static final Date unixtimeToDate(int seconds) { + if(seconds == 0) + return null; + return new Date(seconds * 1000L); + } + + /** Convert date into unixtime */ + public static final int dateToUnixtime(Date date) { + if(date == null) + return 0; + return (int)(date.getTime() / 1000); + } + + /** Returns unixtime for current time */ + public static final int now() { + return (int) (System.currentTimeMillis() / 1000L); + } + + /* ====================================================================== + * =========================================================== formatters + * ====================================================================== */ + + /** + * Convenience method for dropping the preposition argument. + */ + public String getDurationString(Resources r, int timeInSeconds, + int unitsToShow) { + return getDurationString(r, timeInSeconds, unitsToShow, false); + } + + /** + * Format a time into the format: 5 days, 3 hours, 2 minutes + * + * @param r Resources to get strings from + * @param timeInSeconds + * @param unitsToShow number of units to show (i.e. if 2, then 5 hours + * 3 minutes 2 seconds is truncated to 5 hours 3 minutes) + * @param withPreposition whether there is a preceding preposition + * @return + */ + public String getDurationString(Resources r, int timeInSeconds, + int unitsToShow, boolean withPreposition) { + int years, months, days, hours, minutes, seconds; + short unitsDisplayed = 0; + timeInSeconds = Math.abs(timeInSeconds); + + if(timeInSeconds == 0) + return r.getQuantityString(secondsResource, 0, 0); + + Date now = new Date(80, 0, 1); + Date then = unixtimeToDate((int)(now.getTime() / 1000L) + timeInSeconds); + + years = then.getYear() - now.getYear(); + months = then.getMonth() - now.getMonth(); + days = then.getDate() - now.getDate(); + hours = then.getHours() - now.getHours(); + minutes = then.getMinutes() - now.getMinutes(); + seconds = then.getSeconds() - now.getSeconds(); + + StringBuilder result = new StringBuilder(); + unitsDisplayed = displayUnits(r, yearsResource, unitsToShow, years, months >= 6, + unitsDisplayed, result); + unitsDisplayed = displayUnits(r, monthsResource, unitsToShow, months, days >= 15, + unitsDisplayed, result); + unitsDisplayed = displayUnits(r, daysResource, unitsToShow, days, hours >= 12, + unitsDisplayed, result); + unitsDisplayed = displayUnits(r, hoursResource, unitsToShow, hours, minutes >= 30, + unitsDisplayed, result); + unitsDisplayed = displayUnits(r, minutesResource, unitsToShow, minutes, seconds >= 30, + unitsDisplayed, result); + unitsDisplayed = displayUnits(r, secondsResource, unitsToShow, seconds, false, + unitsDisplayed, result); + + return result.toString().trim(); + } + + /** Display units, rounding up if necessary. Returns units to show */ + private short displayUnits(Resources r, int resource, int unitsToShow, int value, + boolean shouldRound, short unitsDisplayed, StringBuilder result) { + if(unitsDisplayed < unitsToShow && value > 0) { + // round up if needed + if(unitsDisplayed + 1 == unitsToShow && shouldRound) + value++; + result.append(r.getQuantityString(resource, value, value)). + append(' '); + unitsDisplayed++; + } + return unitsDisplayed; + } + + /** + * Format a time into the format: 5 days, 3 hrs, 2 min + * + * @param r Resources to get strings from + * @param timeInSeconds + * @param unitsToShow number of units to show (i.e. if 2, then 5 hours + * 3 minutes 2 seconds is truncated to 5 hours 3 minutes) + * @return + */ + public String getAbbreviatedDurationString(Resources r, int timeInSeconds, + int unitsToShow) { + short days, hours, minutes, seconds; + short unitsDisplayed = 0; + timeInSeconds = Math.abs(timeInSeconds); + + if(timeInSeconds == 0) + return r.getQuantityString(secondsAbbrevResource, 0, 0); + + days = (short)(timeInSeconds / 24 / 3600); + timeInSeconds -= days*24*3600; + hours = (short)(timeInSeconds / 3600); + timeInSeconds -= hours * 3600; + minutes = (short)(timeInSeconds / 60); + timeInSeconds -= minutes * 60; + seconds = (short)timeInSeconds; + + StringBuilder result = new StringBuilder(); + if(days > 0) { + // round up if needed + if(unitsDisplayed == unitsToShow && hours >= 12) + days++; + result.append(r.getQuantityString(daysAbbrevResource, days, days)). + append(' '); + unitsDisplayed++; + } + if(unitsDisplayed < unitsToShow && hours > 0) { + // round up if needed + if(unitsDisplayed == unitsToShow && minutes >= 30) + days++; + result.append(r.getQuantityString(hoursAbbrevResource, hours, + hours)). + append(' '); + unitsDisplayed++; + } + if(unitsDisplayed < unitsToShow && minutes > 0) { + // round up if needed + if(unitsDisplayed == unitsToShow && seconds >= 30) + days++; + result.append(r.getQuantityString(minutesAbbrevResource, minutes, + minutes)).append(' '); + unitsDisplayed++; + } + if(unitsDisplayed < unitsToShow && seconds > 0) { + result.append(r.getQuantityString(secondsAbbrevResource, seconds, + seconds)).append(' '); + } + + return result.toString().trim(); + } + +} diff --git a/common-src/com/todoroo/andlib/utility/DialogUtilities.java b/common-src/com/todoroo/andlib/utility/DialogUtilities.java new file mode 100644 index 000000000..895cb37ea --- /dev/null +++ b/common-src/com/todoroo/andlib/utility/DialogUtilities.java @@ -0,0 +1,169 @@ +package com.todoroo.andlib.utility; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.view.View; + +import com.todoroo.andlib.service.Autowired; +import com.todoroo.andlib.service.DependencyInjectionService; + +public class DialogUtilities { + + @Autowired + public Integer informationDialogTitleResource; + + public DialogUtilities() { + DependencyInjectionService.getInstance().inject(this); + } + + /** + * Displays a dialog box with a EditText and an ok / cancel + * + * @param activity + * @param text + * @param okListener + */ + public void viewDialog(final Activity activity, final String text, + final View view, final DialogInterface.OnClickListener okListener, + final DialogInterface.OnClickListener cancelListener) { + activity.runOnUiThread(new Runnable() { + public void run() { + new AlertDialog.Builder(activity) + .setTitle(informationDialogTitleResource) + .setMessage(text) + .setView(view) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, cancelListener) + .show(); + } + }); + } + + /** + * Displays a dialog box with an OK button + * + * @param activity + * @param text + * @param okListener + */ + public void okDialog(final Activity activity, final String text, + final DialogInterface.OnClickListener okListener) { + activity.runOnUiThread(new Runnable() { + public void run() { + new AlertDialog.Builder(activity) + .setTitle(informationDialogTitleResource) + .setMessage(text) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton(android.R.string.ok, okListener) + .show(); + } + }); + } + + /** + * Displays a dialog box with an OK button + * + * @param activity + * @param text + * @param okListener + */ + public void okDialog(final Activity activity, final int icon, final String text, + final DialogInterface.OnClickListener okListener) { + activity.runOnUiThread(new Runnable() { + public void run() { + new AlertDialog.Builder(activity) + .setTitle(informationDialogTitleResource) + .setMessage(text) + .setIcon(icon) + .setPositiveButton(android.R.string.ok, okListener) + .show(); + } + }); + } + + /** + * Displays a dialog box with OK and Cancel buttons and custom title + * + * @param activity + * @param title + * @param text + * @param okListener + * @param cancelListener + */ + public void okCancelDialog(final Activity activity, final String title, + final String text, final DialogInterface.OnClickListener okListener, + final DialogInterface.OnClickListener cancelListener) { + activity.runOnUiThread(new Runnable() { + public void run() { + new AlertDialog.Builder(activity) + .setTitle(title) + .setMessage(text) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, cancelListener) + .show(); + } + }); + } + + /** + * Displays a dialog box with OK and Cancel buttons + * + * @param activity + * @param text + * @param okListener + * @param cancelListener + */ + public void okCancelDialog(final Activity activity, final String text, + final DialogInterface.OnClickListener okListener, + final DialogInterface.OnClickListener cancelListener) { + activity.runOnUiThread(new Runnable() { + public void run() { + new AlertDialog.Builder(activity) + .setTitle(informationDialogTitleResource) + .setMessage(text) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton(android.R.string.ok, okListener) + .setNegativeButton(android.R.string.cancel, cancelListener) + .show(); + } + }); + } + + /** + * Displays a progress dialog. Must be run on the UI thread + * @param context + * @param text + * @return + */ + public ProgressDialog progressDialog(Context context, String text) { + ProgressDialog dialog = new ProgressDialog(context); + dialog.setIndeterminate(true); + dialog.setProgressStyle(ProgressDialog.STYLE_SPINNER); + dialog.setMessage(text); + dialog.show(); + return dialog; + } + + /** + * Dismiss a dialog off the UI thread + * + * @param activity + * @param dialog + */ + public void dismissDialog(Activity activity, final ProgressDialog dialog) { + activity.runOnUiThread(new Runnable() { + public void run() { + try { + dialog.dismiss(); + } catch (Exception e) { + // could have killed activity + } + } + }); + } +} diff --git a/common-src/com/todoroo/andlib/utility/EmailValidator.java b/common-src/com/todoroo/andlib/utility/EmailValidator.java new file mode 100644 index 000000000..2029596d6 --- /dev/null +++ b/common-src/com/todoroo/andlib/utility/EmailValidator.java @@ -0,0 +1,66 @@ +package com.todoroo.andlib.utility; + +import java.util.regex.Pattern; + +/** + * E-mail Validator Copyright 2008 Les Hazlewood + * Licensed under the Apache License, Version 2.0 (the "License"); + */ +@SuppressWarnings("nls") +public final class EmailValidator { + + // RFC 2822 2.2.2 Structured Header Field Bodies + private static final String wsp = "[ \\t]"; // space or tab + private static final String fwsp = wsp + "*"; + + // RFC 2822 3.2.1 Primitive tokens + private static final String dquote = "\\\""; + // ASCII Control characters excluding white space: + private static final String noWsCtl = "\\x01-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F"; + // all ASCII characters except CR and LF: + private static final String asciiText = "[\\x01-\\x09\\x0B\\x0C\\x0E-\\x7F]"; + + // RFC 2822 3.2.2 Quoted characters: + // single backslash followed by a text char + private static final String quotedPair = "(\\\\" + asciiText + ")"; + + // RFC 2822 3.2.4 Atom: + private static final String atext = "[a-zA-Z0-9\\!\\#\\$\\%\\&\\'\\*\\+\\-\\/\\=\\?\\^\\_\\`\\{\\|\\}\\~]"; + private static final String dotAtomText = atext + "+" + "(" + "\\." + atext + + "+)*"; + private static final String dotAtom = fwsp + "(" + dotAtomText + ")" + fwsp; + + // RFC 2822 3.2.5 Quoted strings: + // noWsCtl and the rest of ASCII except the doublequote and backslash + // characters: + private static final String qtext = "[" + noWsCtl + + "\\x21\\x23-\\x5B\\x5D-\\x7E]"; + private static final String qcontent = "(" + qtext + "|" + quotedPair + ")"; + private static final String quotedString = dquote + "(" + fwsp + qcontent + + ")*" + fwsp + dquote; + + // RFC 1035 tokens for domain names: + private static final String letter = "[a-zA-Z]"; + private static final String letDig = "[a-zA-Z0-9]"; + private static final String letDigHyp = "[a-zA-Z0-9-]"; + private static final String rfcLabel = letDig + "(" + letDigHyp + "{0,61}" + + letDig + ")?"; + private static final String rfc1035DomainName = rfcLabel + "(\\." + + rfcLabel + ")*\\." + letter + "{2,6}"; + + private static final String domain = rfc1035DomainName; + + private static final String localPart = "((" + dotAtom + ")|(" + + quotedString + "))"; + private static final String addrSpec = localPart + "@" + domain; + + // now compile a pattern for efficient re-use: + // if we're allowing quoted identifiers or not: + private static final String patternString = addrSpec; + public static final Pattern VALID_PATTERN = Pattern.compile(patternString); + + + public static boolean validateEmail(String value) { + return VALID_PATTERN.matcher(value).matches(); + } +} \ No newline at end of file diff --git a/common-src/com/todoroo/andlib/utility/Pair.java b/common-src/com/todoroo/andlib/utility/Pair.java new file mode 100644 index 000000000..d3001a568 --- /dev/null +++ b/common-src/com/todoroo/andlib/utility/Pair.java @@ -0,0 +1,56 @@ +package com.todoroo.andlib.utility; + +/** + * Pair utility class + * + * @author Tim Su + * + * @param + * @param + */ +public class Pair { + + private final L left; + private final R right; + + public R getRight() { + return right; + } + + public L getLeft() { + return left; + } + + public Pair(final L left, final R right) { + this.left = left; + this.right = right; + } + + public static Pair create(A left, B right) { + return new Pair(left, right); + } + + @Override + public final boolean equals(Object o) { + if (!(o instanceof Pair)) + return false; + + final Pair other = (Pair) o; + return equal(getLeft(), other.getLeft()) && equal(getRight(), other.getRight()); + } + + public static final boolean equal(Object o1, Object o2) { + if (o1 == null) { + return o2 == null; + } + return o1.equals(o2); + } + + @Override + public int hashCode() { + int hLeft = getLeft() == null ? 0 : getLeft().hashCode(); + int hRight = getRight() == null ? 0 : getRight().hashCode(); + + return hLeft + (57 * hRight); + } +} diff --git a/common-src/com/todoroo/andlib/utility/SoftHashMap.java b/common-src/com/todoroo/andlib/utility/SoftHashMap.java new file mode 100644 index 000000000..188b31980 --- /dev/null +++ b/common-src/com/todoroo/andlib/utility/SoftHashMap.java @@ -0,0 +1,121 @@ +package com.todoroo.andlib.utility; + +import java.io.Serializable; +import java.lang.ref.Reference; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.SoftReference; +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +/** + * SoftHashMap from javaspecialists.eu Issue 98 + * + * + * @param + * @param + */ +public class SoftHashMap extends AbstractMap implements + Serializable { + private static final long serialVersionUID = -3796460667941300642L; + + /** The internal HashMap that will hold the SoftReference. */ + private final Map> hash = new HashMap>(); + + private final Map, K> reverseLookup = new HashMap, K>(); + + /** Reference queue for cleared SoftReference objects. */ + protected final ReferenceQueue queue = new ReferenceQueue(); + + @Override + public V get(Object key) { + expungeStaleEntries(); + V result = null; + // We get the SoftReference represented by that key + SoftReference soft_ref = hash.get(key); + if (soft_ref != null) { + // From the SoftReference we get the value, which can be + // null if it has been garbage collected + result = soft_ref.get(); + if (result == null) { + // If the value has been garbage collected, remove the + // entry from the HashMap. + hash.remove(key); + reverseLookup.remove(soft_ref); + } + } + return result; + } + + private void expungeStaleEntries() { + Reference sv; + while ((sv = queue.poll()) != null) { + hash.remove(reverseLookup.remove(sv)); + } + } + + @Override + public V put(K key, V value) { + expungeStaleEntries(); + SoftReference soft_ref = new SoftReference(value, queue); + reverseLookup.put(soft_ref, key); + SoftReference result = hash.put(key, soft_ref); + if (result == null) + return null; + reverseLookup.remove(result); + return result.get(); + } + + @Override + public V remove(Object key) { + expungeStaleEntries(); + SoftReference result = hash.remove(key); + if (result == null) + return null; + return result.get(); + } + + @Override + public void clear() { + hash.clear(); + reverseLookup.clear(); + } + + @Override + public int size() { + expungeStaleEntries(); + return hash.size(); + } + + /** + * Returns a copy of the key/values in the map at the point of calling. + * However, setValue still sets the value in the actual SoftHashMap. + */ + @Override + public Set> entrySet() { + expungeStaleEntries(); + Set> result = new LinkedHashSet>(); + for (final Entry> entry : hash.entrySet()) { + final V value = entry.getValue().get(); + if (value != null) { + result.add(new Entry() { + public K getKey() { + return entry.getKey(); + } + + public V getValue() { + return value; + } + + public V setValue(V v) { + entry.setValue(new SoftReference(v, queue)); + return value; + } + }); + } + } + return result; + } +} diff --git a/common-src/com/todoroo/andlib/utility/UserTask.java b/common-src/com/todoroo/andlib/utility/UserTask.java new file mode 100644 index 000000000..9440a49ae --- /dev/null +++ b/common-src/com/todoroo/andlib/utility/UserTask.java @@ -0,0 +1,447 @@ +package com.todoroo.andlib.utility; + +/* + * Copyright (C) 2008 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import android.os.Handler; +import android.os.Message; +import android.os.Process; + +/** + *

UserTask enables proper and easy use of the UI thread. This class allows to + * perform background operations and publish results on the UI thread without + * having to manipulate threads and/or handlers.

+ * + *

A user task is defined by a computation that runs on a background thread and + * whose result is published on the UI thread. A user task is defined by 3 generic + * types, called Params, Progress and Result, + * and 4 steps, called begin, doInBackground, + * processProgress and end.

+ * + *

Usage

+ *

UserTask must be subclassed to be used. The subclass will override at least + * one method ({@link #doInBackground(Object[])}), and most often will override a + * second one ({@link #end(Object)}.)

+ * + *

Here is an example of subclassing:

+ *
+ * private class DownloadFilesTask extends UserTask<URL, Integer, Long> {
+ *     public File doInBackground(URL... urls) {
+ *         int count = urls.length;
+ *         long totalSize = 0;
+ *         for (int i = 0; i < count; i++) {
+ *             totalSize += Downloader.downloadFile(urls[i]);
+ *             publishProgress((int) ((i / (float) count) * 100));
+ *         }
+ *     }
+ *
+ *     public void processProgress(Integer... progress) {
+ *         setProgressPercent(progress[0]);
+ *     }
+ *
+ *     public void end(Long result) {
+ *         showDialog("Downloaded " + result + " bytes");
+ *     }
+ * }
+ * 
+ * + *

Once created, a task is executed very simply:

+ *
+ * new DownloadFilesTask().execute(new URL[] { ... });
+ * 
+ * + *

User task's generic types

+ *

The three types used by a user task are the following:

+ *
    + *
  1. Params, the type of the parameters sent to the task upon + * execution.
  2. + *
  3. Progress, the type of the progress units published during + * the background computation.
  4. + *
  5. Result, the type of the result of the background + * computation.
  6. + *
+ *

Not all types are always used by a user task. To mark a type as unused, + * simply use the type {@link Void}:

+ *
+ * private class MyTask extends UserTask
+ *
+ * 

The 4 steps

+ *

When a user task is executed, the task goes through 4 steps:

+ *
    + *
  1. {@link #begin()}, invoked on the UI thread immediately after the task + * is executed. This step is normally used to setup the task, for instance by + * showing a progress bar in the user interface.
  2. + *
  3. {@link #doInBackground(Object[])}, invoked on the background thread + * immediately after {@link #begin()} finishes executing. This step is used + * to perform background computation that can take a long time. The parameters + * of the user task are passed to this step. The result of the computation must + * be returned by this step and will be passed back to the last step. This step + * can also use {@link #publishProgress(Object[])} to publish one or more units + * of progress. These values are published on the UI thread, in the + * {@link #processProgress(Object[])} step.
  4. + *
  5. {@link #processProgress(Object[])}, invoked on the UI thread after a + * call to {@link #publishProgress(Object[])}. The timing of the execution is + * undefined. This method is used to display any form of progress in the user + * interface while the background computation is still executing. For instance, + * it can be used to animate a progress bar or show logs in a text field.
  6. + *
  7. {@link #end(Object)}, invoked on the UI thread after the background + * computation finishes. The result of the background computation is passed to + * this step as a parameter.
  8. + *
+ * + *

Threading rules

+ *

There are a few threading rules that must be followed for this class to + * work properly:

+ *
    + *
  • The task instance must be created on the UI thread.
  • + *
  • {@link #execute(Object[])} must be invoked on the UI thread.
  • + *
  • Do not call {@link #begin()}, {@link #end(Object)}, + * {@link #doInBackground(Object[])}, {@link #processProgress(Object[])} + * manually.
  • + *
  • The task can be executed only once (an exception will be thrown if + * a second execution is attempted.)
  • + *
+ */ +@SuppressWarnings("nls") +public abstract class UserTask { + private static final String LOG_TAG = "UserTask"; + + private static final int CORE_POOL_SIZE = 4; + private static final int MAXIMUM_POOL_SIZE = 10; + private static final int KEEP_ALIVE = 10; + + private static final BlockingQueue sWorkQueue = + new LinkedBlockingQueue(MAXIMUM_POOL_SIZE); + + private static final ThreadFactory sThreadFactory = new ThreadFactory() { + private final AtomicInteger mCount = new AtomicInteger(1); + + public Thread newThread(Runnable r) { + final Thread thread = new Thread(r, "UserTask #" + mCount.getAndIncrement()); + Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND); + return thread; + } + }; + + private static final ThreadPoolExecutor sExecutor = new ThreadPoolExecutor(CORE_POOL_SIZE, + MAXIMUM_POOL_SIZE, KEEP_ALIVE, TimeUnit.SECONDS, sWorkQueue, sThreadFactory); + + private static final int MESSAGE_POST_RESULT = 0x1; + private static final int MESSAGE_POST_PROGRESS = 0x2; + + protected static InternalHandler sHandler; + + private final WorkerRunnable mWorker; + private final FutureTask mFuture; + + private volatile Status mStatus = Status.PENDING; + + /** + * Indicates the current status of the task. Each status will be set only once + * during the lifetime of a task. + */ + public enum Status { + /** + * Indicates that the task has not been executed yet. + */ + PENDING, + /** + * Indicates that the task is running. + */ + RUNNING, + /** + * Indicates that {@link UserTask#end(Object)} has finished. + */ + FINISHED, + } + + /** + * Creates a new user task. This constructor must be invoked on the UI thread. + */ + public UserTask() { + if (sHandler == null) { + sHandler = new InternalHandler(); + } + + mWorker = new WorkerRunnable() { + public Result call() throws Exception { + return doInBackground(mParams); + } + }; + + mFuture = new FutureTask(mWorker) { + @Override + protected void done() { + Result result = null; + try { + result = get(); + } catch (InterruptedException e) { + android.util.Log.w(LOG_TAG, e); + } catch (ExecutionException e) { + throw new RuntimeException("An error occured while executing doInBackground()", + e.getCause()); + } catch (CancellationException e) { + return; + } catch (Throwable t) { + throw new RuntimeException("An error occured while executing " + + "doInBackground()", t); + } + + final Message message = sHandler.obtainMessage(MESSAGE_POST_RESULT, + new UserTaskResult(UserTask.this, result)); + message.sendToTarget(); + } + }; + } + + /** + * Returns the current status of this task. + * + * @return The current status. + */ + public final Status getStatus() { + return mStatus; + } + + /** + * Override this method to perform a computation on a background thread. The + * specified parameters are the parameters passed to {@link #execute(Object[])} + * by the caller of this task. + * + * This method can call {@link #publishProgress(Object[])} to publish updates + * on the UI thread. + * + * @params params The parameters of the task. + * + * @return A result, defined by the subclass of this task. + * + * @see #begin() + * @see #end(Object) + * @see #publishProgress(Object[]) + */ + public Result doInBackground(@SuppressWarnings("unused") Params... params) { + return null; + } + + /** + * Runs on the UI thread before {@link #doInBackground(Object[])}. + * + * @see #end(Object) + * @see #doInBackground(Object[]) + */ + public void begin() { + // ... + } + + /** + * Runs on the UI thread after {@link #doInBackground(Object[])}. The + * specified result is the value returned by {@link #doInBackground(Object[])} + * or null if the task was cancelled or an exception occured. + * + * @see #begin() + * @see #doInBackground(Object[]) + */ + public void end(@SuppressWarnings("unused") Result result) { + // ... + } + + /** + * Runs on the UI thread after {@link #publishProgress(Object[])} is invoked. + * The specified values are the values passed to {@link #publishProgress(Object[])}. + * + * @see #publishProgress(Object[]) + * @see #doInBackground(Object[]) + */ + public void processProgress(@SuppressWarnings("unused") Progress... values) { + // ... + } + + /** + * Returns true if this task was cancelled before it completed + * normally. + * + * @return true if task was cancelled before it completed + * + * @see #cancel(boolean) + */ + public final boolean isCancelled() { + return mFuture.isCancelled(); + } + + /** + * Attempts to cancel execution of this task. This attempt will + * fail if the task has already completed, already been cancelled, + * or could not be cancelled for some other reason. If successful, + * and this task has not started when cancel is called, + * this task should never run. If the task has already started, + * then the mayInterruptIfRunning parameter determines + * whether the thread executing this task should be interrupted in + * an attempt to stop the task. + * + * @param mayInterruptIfRunning true if the thread executing this + * task should be interrupted; otherwise, in-progress tasks are allowed + * to complete. + * + * @return false if the task could not be cancelled, + * typically because it has already completed normally; + * true otherwise + * + * @see #isCancelled() + */ + public final boolean cancel(boolean mayInterruptIfRunning) { + return mFuture.cancel(mayInterruptIfRunning); + } + + /** + * Waits if necessary for the computation to complete, and then + * retrieves its result. + * + * @return The computed result. + * + * @throws CancellationException If the computation was cancelled. + * @throws ExecutionException If the computation threw an exception. + * @throws InterruptedException If the current thread was interrupted + * while waiting. + */ + public final Result get() throws InterruptedException, ExecutionException { + return mFuture.get(); + } + + /** + * Waits if necessary for at most the given time for the computation + * to complete, and then retrieves its result. + * + * @return The computed result. + * + * @throws CancellationException If the computation was cancelled. + * @throws ExecutionException If the computation threw an exception. + * @throws InterruptedException If the current thread was interrupted + * while waiting. + * @throws TimeoutException If the wait timed out. + */ + public final Result get(long timeout, TimeUnit unit) throws InterruptedException, + ExecutionException, TimeoutException { + return mFuture.get(timeout, unit); + } + + /** + * Executes the task with the specified parameters. The task returns + * itself (this) so that the caller can keep a reference to it. + * + * This method must be invoked on the UI thread. + * + * @params params The parameters of the task. + * + * @return This instance of UserTask. + * + * @throws IllegalStateException If {@link #getStatus()} returns either + * {@link com.google.android.photostream.UserTask.Status#RUNNING} or + * {@link com.google.android.photostream.UserTask.Status#FINISHED}. + */ + public final UserTask execute(Params... params) { + if (mStatus != Status.PENDING) { + switch (mStatus) { + case RUNNING: + throw new IllegalStateException("Cannot execute task:" + + " the task is already running."); + case FINISHED: + throw new IllegalStateException("Cannot execute task:" + + " the task has already been executed " + + "(a task can be executed only once)"); + } + } + + mStatus = Status.RUNNING; + + begin(); + + mWorker.mParams = params; + + try { + sExecutor.execute(mFuture); + } catch (RejectedExecutionException e) { + // cannot schedule because of some other error. just die quietly + } + + return this; + } + + /** + * This method can be invoked from {@link #doInBackground(Object[])} to + * publish updates on the UI thread while the background computation is + * still running. Each call to this method will trigger the execution of + * {@link #processProgress(Object[])} on the UI thread. + * + * @params values The progress values to update the UI with. + * + * @see #processProgress(Object[]) + * @see #doInBackground(Object[]) + */ + protected final void publishProgress(Progress... values) { + sHandler.obtainMessage(MESSAGE_POST_PROGRESS, + new UserTaskResult(this, values)).sendToTarget(); + } + + protected void finish(Result result) { + end(result); + mStatus = Status.FINISHED; + } + + protected static class InternalHandler extends Handler { + @SuppressWarnings({"unchecked"}) + @Override + public void handleMessage(Message msg) { + UserTaskResult result = (UserTaskResult) msg.obj; + switch (msg.what) { + case MESSAGE_POST_RESULT: + // There is only one result + result.mTask.finish(result.mData[0]); + break; + case MESSAGE_POST_PROGRESS: + result.mTask.processProgress(result.mData); + break; + } + } + } + + protected static abstract class WorkerRunnable implements Callable { + Params[] mParams; + } + + protected static class UserTaskResult { + final UserTask mTask; + final Data[] mData; + + UserTaskResult(UserTask task, Data... data) { + mTask = task; + mData = data; + } + } +} diff --git a/common-src/com/todoroo/andlib/widget/DateControlSet.java b/common-src/com/todoroo/andlib/widget/DateControlSet.java new file mode 100644 index 000000000..e0b715a50 --- /dev/null +++ b/common-src/com/todoroo/andlib/widget/DateControlSet.java @@ -0,0 +1,117 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.todoroo.andlib.widget; + +import java.text.Format; +import java.util.Date; + +import android.app.DatePickerDialog; +import android.app.TimePickerDialog; +import android.app.DatePickerDialog.OnDateSetListener; +import android.app.TimePickerDialog.OnTimeSetListener; +import android.content.Context; +import android.text.format.DateFormat; +import android.view.View; +import android.widget.Button; +import android.widget.DatePicker; +import android.widget.TimePicker; + +public class DateControlSet implements OnTimeSetListener, + OnDateSetListener, View.OnClickListener { + + private final Format dateFormatter; + private final Format timeFormatter; + + protected final Context context; + protected Button dateButton; + protected Button timeButton; + protected Date date; + + protected DateControlSet(Context context) { + this.context = context; + + dateFormatter = DateFormat.getDateFormat(context); + timeFormatter = DateFormat.getTimeFormat(context); + } + + public DateControlSet(Context context, Button dateButton, Button timeButton) { + this(context); + + this.dateButton = dateButton; + this.timeButton = timeButton; + + if(dateButton != null) + dateButton.setOnClickListener(this); + + if(timeButton != null) + timeButton.setOnClickListener(this); + + setDate(null); + } + + public Date getDate() { + return date; + } + + /** Initialize the components for the given date field */ + public void setDate(Date newDate) { + if(newDate == null) { + date = new Date(); + } else { + this.date = new Date(newDate.getTime()); + } + + updateDate(); + updateTime(); + } + + public void onDateSet(DatePicker view, int year, int month, int monthDay) { + date.setYear(year - 1900); + date.setMonth(month); + date.setDate(monthDay); + updateDate(); + } + + public void onTimeSet(TimePicker view, int hourOfDay, int minute) { + date.setHours(hourOfDay); + date.setMinutes(minute); + updateTime(); + } + + public void updateDate() { + if(dateButton != null) + dateButton.setText(dateFormatter.format(date)); + + } + + public void updateTime() { + if(timeButton != null) + timeButton.setText(timeFormatter.format(date)); + } + + public void onClick(View v) { + if(v == timeButton) + new TimePickerDialog(context, this, date.getHours(), + date.getMinutes(), false).show(); + else + new DatePickerDialog(context, this, 1900 + + date.getYear(), date.getMonth(), date.getDate()).show(); + } +} \ No newline at end of file diff --git a/common-src/com/todoroo/andlib/widget/DateWithNullControlSet.java b/common-src/com/todoroo/andlib/widget/DateWithNullControlSet.java new file mode 100644 index 000000000..3cd1a1d34 --- /dev/null +++ b/common-src/com/todoroo/andlib/widget/DateWithNullControlSet.java @@ -0,0 +1,69 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.todoroo.andlib.widget; + +import java.util.Date; + +import android.app.Activity; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.CompoundButton.OnCheckedChangeListener; + +/** Date Control Set with an "enabled" checkbox" to toggle date / null */ +public class DateWithNullControlSet extends DateControlSet { + + private CheckBox activatedCheckBox; + + public DateWithNullControlSet(Activity activity, int checkBoxId, int dateButtonId, int timeButtonId) { + super(activity); + activatedCheckBox = (CheckBox)activity.findViewById(checkBoxId); + dateButton = (Button)activity.findViewById(dateButtonId); + timeButton = (Button)activity.findViewById(timeButtonId); + + activatedCheckBox.setOnCheckedChangeListener( + new OnCheckedChangeListener() { + public void onCheckedChanged(CompoundButton buttonView, + boolean isChecked) { + dateButton.setEnabled(isChecked); + timeButton.setEnabled(isChecked); + } + }); + dateButton.setOnClickListener(this); + timeButton.setOnClickListener(this); + } + + @Override + public Date getDate() { + if(!activatedCheckBox.isChecked()) + return null; + return super.getDate(); + } + + /** Initialize the components for the given date field */ + @Override + public void setDate(Date newDate) { + activatedCheckBox.setChecked(newDate != null); + dateButton.setEnabled(newDate != null); + timeButton.setEnabled(newDate != null); + + super.setDate(newDate); + } +} \ No newline at end of file diff --git a/common-src/com/todoroo/andlib/widget/NNumberPickerDialog.java b/common-src/com/todoroo/andlib/widget/NNumberPickerDialog.java new file mode 100644 index 000000000..fa2f9e5c6 --- /dev/null +++ b/common-src/com/todoroo/andlib/widget/NNumberPickerDialog.java @@ -0,0 +1,126 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.todoroo.andlib.widget; + +import java.util.LinkedList; +import java.util.List; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.FrameLayout.LayoutParams; + +import com.todoroo.andlib.service.Autowired; +import com.todoroo.andlib.service.DependencyInjectionService; + +/** Dialog box with an arbitrary number of number pickers */ +public class NNumberPickerDialog extends AlertDialog implements OnClickListener { + + @Autowired + private Integer nNumberPickerLayout; + + public interface OnNNumberPickedListener { + void onNumbersPicked(int[] number); + } + + private final List pickers = new LinkedList(); + private final OnNNumberPickedListener mCallback; + + /** Instantiate the dialog box. + * + * @param context + * @param callBack callback function to get the numbers you requested + * @param title title of the dialog box + * @param initialValue initial picker values array + * @param incrementBy picker increment by array + * @param start picker range start array + * @param end picker range end array + * @param separators text separating the spinners. whole array, or individual + * elements can be null + */ + public NNumberPickerDialog(Context context, OnNNumberPickedListener callBack, + String title, int[] initialValue, int[] incrementBy, int[] start, + int[] end, String[] separators) { + super(context); + mCallback = callBack; + + DependencyInjectionService.getInstance().inject(this); + + setButton(context.getText(android.R.string.ok), this); + setButton2(context.getText(android.R.string.cancel), (OnClickListener) null); + + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(nNumberPickerLayout, null); + setView(view); + LinearLayout container = (LinearLayout)view; + + setTitle(title); + LayoutParams npLayout = new LayoutParams(LayoutParams.WRAP_CONTENT, + LayoutParams.FILL_PARENT); + npLayout.gravity = 1; + LayoutParams sepLayout = new LayoutParams(LayoutParams.WRAP_CONTENT, + LayoutParams.FILL_PARENT); + for(int i = 0; i < incrementBy.length; i++) { + NumberPickerWidget np = new NumberPickerWidget(context, null); + np.setIncrementBy(incrementBy[i]); + np.setLayoutParams(npLayout); + np.setRange(start[i], end[i]); + np.setCurrent(initialValue[i]); + + container.addView(np); + pickers.add(np); + + if(separators != null && separators[i] != null) { + TextView text = new TextView(context); + text.setText(separators[i]); + if(separators[i].length() < 3) + text.setTextSize(48); + else + text.setTextSize(20); + text.setGravity(Gravity.CENTER); + text.setLayoutParams(sepLayout); + container.addView(text); + } + } + } + + public void setInitialValues(int[] values) { + for(int i = 0; i < pickers.size(); i++) + pickers.get(i).setCurrent(values[i]); + } + + public void onClick(DialogInterface dialog, int which) { + if (mCallback != null) { + int[] values = new int[pickers.size()]; + for(int i = 0; i < pickers.size(); i++) { + pickers.get(i).clearFocus(); + values[i] = pickers.get(i).getCurrent(); + } + mCallback.onNumbersPicked(values); + } + } +} diff --git a/common-src/com/todoroo/andlib/widget/NumberPickerDialog.java b/common-src/com/todoroo/andlib/widget/NumberPickerDialog.java new file mode 100644 index 000000000..0dc1ea803 --- /dev/null +++ b/common-src/com/todoroo/andlib/widget/NumberPickerDialog.java @@ -0,0 +1,79 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.todoroo.andlib.widget; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.view.LayoutInflater; +import android.view.View; + +import com.todoroo.andlib.service.Autowired; +import com.todoroo.andlib.service.DependencyInjectionService; + +public class NumberPickerDialog extends AlertDialog implements OnClickListener { + + @Autowired + private Integer numberPickerDialogLayout; + + @Autowired + private Integer numberPickerId; + + public interface OnNumberPickedListener { + void onNumberPicked(NumberPickerWidget view, int number); + } + + private final NumberPickerWidget mPicker; + private final OnNumberPickedListener mCallback; + + public NumberPickerDialog(Context context, OnNumberPickedListener callBack, + String title, int initialValue, int incrementBy, int start, int end) { + super(context); + DependencyInjectionService.getInstance().inject(this); + + mCallback = callBack; + + setButton(context.getText(android.R.string.ok), this); + setButton2(context.getText(android.R.string.cancel), (OnClickListener) null); + + LayoutInflater inflater = (LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + View view = inflater.inflate(numberPickerDialogLayout, null); + setView(view); + + setTitle(title); + mPicker = (NumberPickerWidget) view.findViewById(numberPickerId); + mPicker.setIncrementBy(incrementBy); + mPicker.setRange(start, end); + mPicker.setCurrent(initialValue); + } + + public void setInitialValue(int initialValue) { + mPicker.setCurrent(initialValue); + } + + public void onClick(DialogInterface dialog, int which) { + if (mCallback != null) { + mPicker.clearFocus(); + mCallback.onNumberPicked(mPicker, mPicker.getCurrent()); + } + } +} diff --git a/common-src/com/todoroo/andlib/widget/NumberPickerWidget.java b/common-src/com/todoroo/andlib/widget/NumberPickerWidget.java new file mode 100644 index 000000000..25039a0c9 --- /dev/null +++ b/common-src/com/todoroo/andlib/widget/NumberPickerWidget.java @@ -0,0 +1,460 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.todoroo.andlib.widget; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.text.InputFilter; +import android.text.Spanned; +import android.text.method.NumberKeyListener; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnFocusChangeListener; +import android.view.View.OnLongClickListener; +import android.view.animation.Animation; +import android.view.animation.TranslateAnimation; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.todoroo.andlib.service.Autowired; +import com.todoroo.andlib.service.DependencyInjectionService; + +public class NumberPickerWidget extends LinearLayout implements OnClickListener, + OnFocusChangeListener, OnLongClickListener { + + @Autowired + private Integer numberPickerLayout; + + @Autowired + private Integer numberPickerIncrementId; + + @Autowired + private Integer numberPickerDecrementId; + + @Autowired + private Integer numberPickerInputId; + + public interface OnChangedListener { + void onChanged(NumberPickerWidget picker, int oldVal, int newVal); + } + + public interface Formatter { + String toString(int value); + } + + /* + * Use a custom NumberPicker formatting callback to use two-digit minutes + * strings like "01". Keeping a static formatter etc. is the most efficient + * way to do this; it avoids creating temporary objects on every call to + * format(). + */ + public static final NumberPickerWidget.Formatter TWO_DIGIT_FORMATTER = new NumberPickerWidget.Formatter() { + final StringBuilder mBuilder = new StringBuilder(); + final java.util.Formatter mFmt = new java.util.Formatter(mBuilder); + final Object[] mArgs = new Object[1]; + + public String toString(int value) { + mArgs[0] = value; + mBuilder.delete(0, mBuilder.length()); + mFmt.format("%02d", mArgs); //$NON-NLS-1$ + return mFmt.toString(); + } + }; + + protected int incrementBy = 1; + public void setIncrementBy(int incrementBy) { + this.incrementBy = incrementBy; + } + + protected final Handler mHandler; + private final Runnable mRunnable = new Runnable() { + public void run() { + if (mIncrement) { + changeCurrent(mCurrent + incrementBy, mSlideUpInAnimation, mSlideUpOutAnimation); + mHandler.postDelayed(this, mSpeed); + } else if (mDecrement) { + changeCurrent(mCurrent - incrementBy, mSlideDownInAnimation, mSlideDownOutAnimation); + mHandler.postDelayed(this, mSpeed); + } + } + }; + + private final LayoutInflater mInflater; + private final TextView mText; + protected final InputFilter mInputFilter; + protected final InputFilter mNumberInputFilter; + + protected final Animation mSlideUpOutAnimation; + protected final Animation mSlideUpInAnimation; + protected final Animation mSlideDownOutAnimation; + protected final Animation mSlideDownInAnimation; + + protected String[] mDisplayedValues; + protected int mStart; + protected int mEnd; + protected int mCurrent; + protected int mPrevious; + private OnChangedListener mListener; + private Formatter mFormatter; + protected long mSpeed = 500; + + protected boolean mIncrement; + protected boolean mDecrement; + + public NumberPickerWidget(Context context) { + this(context, null); + } + + public NumberPickerWidget(Context context, AttributeSet attrs) { + super(context, attrs); + DependencyInjectionService.getInstance().inject(this); + + setOrientation(VERTICAL); + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mInflater.inflate(numberPickerLayout, this, true); + + mHandler = new Handler(Looper.getMainLooper()); + mInputFilter = new NumberPickerInputFilter(); + mNumberInputFilter = new NumberRangeKeyListener(); + mIncrementButton = (NumberPickerWidgetButton) findViewById(numberPickerIncrementId); + mIncrementButton.setOnClickListener(this); + mIncrementButton.setOnLongClickListener(this); + mIncrementButton.setNumberPicker(this); + mDecrementButton = (NumberPickerWidgetButton) findViewById(numberPickerDecrementId); + mDecrementButton.setOnClickListener(this); + mDecrementButton.setOnLongClickListener(this); + mDecrementButton.setNumberPicker(this); + + mText = (TextView) findViewById(numberPickerInputId); + mText.setOnFocusChangeListener(this); + mText.setFilters(new InputFilter[] { mInputFilter }); + + mSlideUpOutAnimation = new TranslateAnimation( + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, -100); + mSlideUpOutAnimation.setDuration(200); + mSlideUpInAnimation = new TranslateAnimation( + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 100, Animation.RELATIVE_TO_SELF, 0); + mSlideUpInAnimation.setDuration(200); + mSlideDownOutAnimation = new TranslateAnimation( + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 100); + mSlideDownOutAnimation.setDuration(200); + mSlideDownInAnimation = new TranslateAnimation( + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, -100, Animation.RELATIVE_TO_SELF, 0); + mSlideDownInAnimation.setDuration(200); + + if (!isEnabled()) { + setEnabled(false); + } + } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + mIncrementButton.setEnabled(enabled); + mDecrementButton.setEnabled(enabled); + mText.setEnabled(enabled); + } + + public void setOnChangeListener(OnChangedListener listener) { + mListener = listener; + } + + public void setFormatter(Formatter formatter) { + mFormatter = formatter; + } + + /** + * Set the range of numbers allowed for the number picker. The current value + * will be automatically set to the start. + * + * @param start + * the start of the range (inclusive) + * @param end + * the end of the range (inclusive) + */ + public void setRange(int start, int end) { + mStart = start; + mEnd = end; + mCurrent = start; + updateView(); + } + + /** + * Set the range of numbers allowed for the number picker. The current value + * will be automatically set to the start. Also provide a mapping for values + * used to display to the user. + * + * @param start + * the start of the range (inclusive) + * @param end + * the end of the range (inclusive) + * @param displayedValues + * the values displayed to the user. + */ + public void setRange(int start, int end, String[] displayedValues) { + mDisplayedValues = displayedValues; + mStart = start; + mEnd = end; + mCurrent = start; + updateView(); + } + + public void setCurrent(int current) { + mCurrent = current; + updateView(); + } + + /** + * The speed (in milliseconds) at which the numbers will scroll when the the + * +/- buttons are longpressed. Default is 300ms. + */ + public void setSpeed(long speed) { + mSpeed = speed; + } + + public void onClick(View v) { + + /* + * The text view may still have focus so clear it's focus which will + * trigger the on focus changed and any typed values to be pulled. + */ + mText.clearFocus(); + + // now perform the increment/decrement + if (numberPickerIncrementId == v.getId()) { + changeCurrent(mCurrent + incrementBy, mSlideUpInAnimation, + mSlideUpOutAnimation); + } else if (numberPickerDecrementId == v.getId()) { + changeCurrent(mCurrent - incrementBy, mSlideDownInAnimation, + mSlideDownOutAnimation); + } + } + + private String formatNumber(int value) { + return (mFormatter != null) ? mFormatter.toString(value) + : String.valueOf(value); + } + + protected void changeCurrent(int current, + @SuppressWarnings("unused") Animation in, + @SuppressWarnings("unused") Animation out) { + + // Wrap around the values if we go past the start or end + if (current > mEnd) { + current = mStart; + } else if (current < mStart) { + current = mEnd; + } + mPrevious = mCurrent; + mCurrent = current; + notifyChange(); + updateView(); + } + + private void notifyChange() { + if (mListener != null) { + mListener.onChanged(this, mPrevious, mCurrent); + } + } + + private void updateView() { + + /* + * If we don't have displayed values then use the current number else + * find the correct value in the displayed values for the current + * number. + */ + if (mDisplayedValues == null) { + mText.setText(formatNumber(mCurrent)); + } else { + mText.setText(mDisplayedValues[mCurrent - mStart]); + } + } + + private void validateCurrentView(CharSequence str) { + int val = getSelectedPos(str.toString()); + if ((val >= mStart) && (val <= mEnd)) { + mPrevious = mCurrent; + mCurrent = val; + notifyChange(); + } + updateView(); + } + + public void onFocusChange(View v, boolean hasFocus) { + + /* + * When focus is lost check that the text field has valid values. + */ + if (!hasFocus && v instanceof TextView) { + String str = String.valueOf(((TextView) v).getText()); + if ("".equals(str)) { //$NON-NLS-1$ + + // Restore to the old value as we don't allow empty values + updateView(); + } else { + + // Check the new value and ensure it's in range + validateCurrentView(str); + } + } + } + + /** + * We start the long click here but rely on the {@link NumberPickerWidgetButton} + * to inform us when the long click has ended. + */ + public boolean onLongClick(View v) { + + /* + * The text view may still have focus so clear it's focus which will + * trigger the on focus changed and any typed values to be pulled. + */ + mText.clearFocus(); + + if (numberPickerIncrementId == v.getId()) { + mIncrement = true; + mHandler.post(mRunnable); + } else if (numberPickerDecrementId == v.getId()) { + mDecrement = true; + mHandler.post(mRunnable); + } + return true; + } + + public void cancelIncrement() { + mIncrement = false; + } + + public void cancelDecrement() { + mDecrement = false; + } + + protected static final char[] DIGIT_CHARACTERS = new char[] { '0', '1', '2', + '3', '4', '5', '6', '7', '8', '9' }; + + private NumberPickerWidgetButton mIncrementButton; + private NumberPickerWidgetButton mDecrementButton; + + class NumberPickerInputFilter implements InputFilter { + public CharSequence filter(CharSequence source, int start, int end, + Spanned dest, int dstart, int dend) { + if (mDisplayedValues == null) { + return mNumberInputFilter.filter(source, start, end, dest, + dstart, dend); + } + CharSequence filtered = String.valueOf(source.subSequence(start, + end)); + String result = String.valueOf(dest.subSequence(0, dstart)) + + filtered + dest.subSequence(dend, dest.length()); + String str = String.valueOf(result).toLowerCase(); + for (String val : mDisplayedValues) { + val = val.toLowerCase(); + if (val.startsWith(str)) { + return filtered; + } + } + return ""; //$NON-NLS-1$ + } + } + + class NumberRangeKeyListener extends NumberKeyListener { + + @Override + protected char[] getAcceptedChars() { + return DIGIT_CHARACTERS; + } + + @Override + public CharSequence filter(CharSequence source, int start, int end, + Spanned dest, int dstart, int dend) { + + CharSequence filtered = super.filter(source, start, end, dest, + dstart, dend); + if (filtered == null) { + filtered = source.subSequence(start, end); + } + + String result = String.valueOf(dest.subSequence(0, dstart)) + + filtered + dest.subSequence(dend, dest.length()); + + if ("".equals(result)) { //$NON-NLS-1$ + return result; + } + int val = getSelectedPos(result); + + /* + * Ensure the user can't type in a value greater than the max + * allowed. We have to allow less than min as the user might want to + * delete some numbers and then type a new number. + */ + if (val > mEnd) { + return ""; //$NON-NLS-1$ + } else { + return filtered; + } + } + + public int getInputType() { + return 0; + } + } + + protected int getSelectedPos(String str) { + if (mDisplayedValues == null) { + return Integer.parseInt(str); + } else { + for (int i = 0; i < mDisplayedValues.length; i++) { + + /* Don't force the user to type in jan when ja will do */ + str = str.toLowerCase(); + if (mDisplayedValues[i].toLowerCase().startsWith(str)) { + return mStart + i; + } + } + + /* + * The user might have typed in a number into the month field i.e. + * 10 instead of OCT so support that too. + */ + try { + return Integer.parseInt(str); + } catch (NumberFormatException e) { + + /* Ignore as if it's not a number we don't care */ + } + } + return mStart; + } + + /** + * @return the current value. + */ + public int getCurrent() { + return mCurrent; + } +} \ No newline at end of file diff --git a/common-src/com/todoroo/andlib/widget/NumberPickerWidgetButton.java b/common-src/com/todoroo/andlib/widget/NumberPickerWidgetButton.java new file mode 100644 index 000000000..dea3c9e47 --- /dev/null +++ b/common-src/com/todoroo/andlib/widget/NumberPickerWidgetButton.java @@ -0,0 +1,99 @@ +/* + * ASTRID: Android's Simple Task Recording Dashboard + * + * Copyright (c) 2009 Tim Su + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, write to the Free Software Foundation, Inc., + * 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + */ +package com.todoroo.andlib.widget; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.widget.ImageButton; + +import com.todoroo.andlib.service.Autowired; +import com.todoroo.andlib.service.DependencyInjectionService; + +/** + * This class exists purely to cancel long click events. + */ +public class NumberPickerWidgetButton extends ImageButton { + + private NumberPickerWidget mNumberPicker; + + @Autowired + private Integer numberPickerIncrementId; + + @Autowired + private Integer numberPickerDecrementId; + + public NumberPickerWidgetButton(Context context, AttributeSet attrs, + int defStyle) { + super(context, attrs, defStyle); + DependencyInjectionService.getInstance().inject(this); + } + + public NumberPickerWidgetButton(Context context, AttributeSet attrs) { + super(context, attrs); + DependencyInjectionService.getInstance().inject(this); + } + + public NumberPickerWidgetButton(Context context) { + super(context); + DependencyInjectionService.getInstance().inject(this); + } + + public void setNumberPicker(NumberPickerWidget picker) { + mNumberPicker = picker; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + cancelLongpressIfRequired(event); + return super.onTouchEvent(event); + } + + @Override + public boolean onTrackballEvent(MotionEvent event) { + cancelLongpressIfRequired(event); + return super.onTrackballEvent(event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if ((keyCode == KeyEvent.KEYCODE_DPAD_CENTER) + || (keyCode == KeyEvent.KEYCODE_ENTER)) { + cancelLongpress(); + } + return super.onKeyUp(keyCode, event); + } + + private void cancelLongpressIfRequired(MotionEvent event) { + if ((event.getAction() == MotionEvent.ACTION_CANCEL) + || (event.getAction() == MotionEvent.ACTION_UP)) { + cancelLongpress(); + } + } + + private void cancelLongpress() { + if (numberPickerIncrementId == getId()) { + mNumberPicker.cancelIncrement(); + } else if (numberPickerDecrementId == getId()) { + mNumberPicker.cancelDecrement(); + } + } +} \ No newline at end of file