Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Data model on Android

Data model on Android

After 2 years of development, 61 releases and adding 100k lines of code, the maintenance of data model in Android app became a major problem at Base CRM. None of the existing solutions fit our requirements, so we have created few libraries that make our everyday work much easier.

Jerzy Chalupski

October 26, 2013
Tweet

More Decks by Jerzy Chalupski

Other Decks in Programming

Transcript

  1.  Jerzy Chalupski chalup, futuresimple  [email protected] Data model on

    Android Base CRM http://porcupineprogrammer.blogspot.com/ 
  2. Data model on Android 61 releases 200+ migrations 50 models

    ~90 relations ~2 years on Google Play ~100k LOC
  3. public class Address { public Long id; public String region;

    public String zip; public String country; public String city; public String street; } @Override public ContentValues getContentValues() { ContentValues values = new ContentValues(); values.put(Addresses.ID, id); values.put(Addresses.REGION, region); values.put(Addresses.ZIP, zip); values.put(Addresses.COUNTRY, country); values.put(Addresses.CITY, city); values.put(Addresses.STREET, street); return values; } POJOs with android.content.*
  4. public class Address { public Long id; public String region;

    public String zip; public String country; public String city; public String street; } public static Address getObjectFromCursor(Cursor c) { Address object = new Address(); int cId = c.getColumnIndex(Addresses.ID); object.id = c.isNull(cId) ? null : c.getLong(cId); object.region = c.getString(c.getColumnIndex(Addresses.REGION)); object.zip = c.getString(c.getColumnIndex(Addresses.ZIP)); object.country = c.getString(c.getColumnIndex(Addresses.COUNTRY)); object.city = c.getString(c.getColumnIndex(Addresses.CITY)); object.street = c.getString(c.getColumnIndex(Addresses.STREET)); return object; } POJOs with android.content.*
  5. MINI QUIZ TIME! What’s wrong with this code? Cursor c

    = /* some valid cursor */; boolean isDirty = c.getBoolean(c.getColumnIndex("is_dirty"));
  6. MINI QUIZ TIME! What’s wrong with this code? “Cannot resolve

    method Cursor.getBoolean(int)” Cursor c = /* some valid cursor */; boolean isDirty = c.getBoolean(c.getColumnIndex("is_dirty"));
  7. MINI QUIZ TIME! ContentValues values = new ContentValues(); values.put("is_dirty", true);

    What’s wrong with this code? “Cannot resolve method Cursor.getBoolean(int)” Cursor c = /* some valid cursor */; boolean isDirty = c.getBoolean(c.getColumnIndex("is_dirty"));
  8. MINI QUIZ TIME! ContentValues values = new ContentValues(); values.put("is_dirty", true);

    boolean isDirty = c.getInt(c.getColumnIndex("is_dirty")) == 1; What’s wrong with this code? “Cannot resolve method Cursor.getBoolean(int)” Cursor c = /* some valid cursor */; boolean isDirty = c.getBoolean(c.getColumnIndex("is_dirty"));
  9. public class Address { @Column(Addresses.ID) public Long id; @Column(Addresses.REGION) public

    String region; @Column(Addresses.ZIP) public String zip; @Column(Addresses.COUNTRY) public String country; @Column(Addresses.CITY) public String city; @Column(Addresses.STREET) public String street; } MicroOrm microOrm = new MicroOrm(); Address address = microOrm.fromCursor(cursor, Address.class); ContentValues values = microOrm.toContentValues(address); POJOs with MicroOrm
  10.  chalup/microorm POJOs with MicroOrm List<Address> addresses = Lists.newArrayList(); if

    (c != null && c.moveToFirst()) { do { addresses.add(Address.getObjectFromCursor(c)); } while (c.moveToNext()); } BEFORE: AFTER: List<Address> addresses = microOrm.listFromCursor(c, Address.class);
  11. MINI QUIZ TIME! How many parameters there is in ContentResolver’s

    query method? resolver.query(uri, null, null, null, null);
  12. MINI QUIZ TIME! resolver.query(uri, null, null, null, null); resolver.query(uri, null,

    null, null, null, null); // API 16 How many parameters there is in ContentResolver’s query method?
  13. MINI QUIZ TIME! resolver.query(uri, null, null, null, null); resolver.query(uri, null,

    null, null, null, null); // API 16 How many parameters there is in ContentResolver’s query method? What about SQLiteDatabase? db.query(table, ?);
  14. MINI QUIZ TIME! resolver.query(uri, null, null, null, null); resolver.query(uri, null,

    null, null, null, null); // API 16 How many parameters there is in ContentResolver’s query method? What about SQLiteDatabase? db.query(table, null, null, null, null, null, null); db.query(table, null, null, null, null, null, null, null); db.query(true, table, null, null, null, null, null, null, null); db.query(true, table, null, null, null, null, null, null, null, null);
  15. MINI QUIZ TIME! resolver.query(uri, null, null, null, null); resolver.query(uri, null,

    null, null, null, null); // API 16 How many parameters there is in ContentResolver’s query method? What about SQLiteDatabase? db.query(table, null, null, null, null, null, null); db.query(table, null, null, null, null, null, null, null); db.query(true, table, null, null, null, null, null, null, null); db.query(true, table, null, null, null, null, null, null, null, null);
  16. MINI QUIZ TIME! resolver.query(uri, null, null, null, null); resolver.query(uri, null,

    null, null, null, null); // API 16 How many parameters there is in ContentResolver’s query method? What about SQLiteDatabase? db.query(table, null, null, null, null, null, null); db.query(table, null, null, null, null, null, null, null); db.query(true, table, null, null, null, null, null, null, null); db.query(true, table, null, null, null, null, null, null, null, null);
  17. MINI QUIZ TIME! resolver.query(uri, null, null, null, null); resolver.query(uri, null,

    null, null, null, null); // API 16 How many parameters there is in ContentResolver’s query method? What about SQLiteDatabase? db.query(table, null, null, null, null, null, null); db.query(table, null, null, null, null, null, null, null); db.query(true, table, null, null, null, null, null, null, null); db.query(true, table, null, null, null, null, null, null, null, null);
  18. ContentResolver API resolver.query(uri, null, null, null, null); resolver.query(uri, null, "is_dirty

    = ?", new String[] { String.valueOf(1) }, null ); ContentValues values = new ContentValues(); values.put("is_dirty", true); resolver.update(uri, values, null, null);
  19.  futuresimple/android-db-commons ProviderAction.query(uri).perform(resolver); FluentCursor fluentCursor = ProviderAction .query(uri) .where("is_dirty =

    ?", 1) .perform(resolver); fluentCursor.toFluentIterable(new Function<Cursor, T>() { /* ... */ }); ProviderAction .update(uri) .value("is_dirty", true) .perform(resolver); ProviderAction API
  20. @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { CursorLoader loader

    = new CursorLoader(getActivity()); loader.setUri(Contacts.CONTENT_URI); loader.setSelection("is_dirty = ?"); loader.setSelectionArgs(new String[] { String.valueOf(1) }); return loader; } @Override public void onLoadFinished(Loader<Cursor> loader, Cursor c) { if (c != null && c.moveToFirst()) { do { // interesting code } while (c.moveToNext()); } } CursorLoader API
  21. @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) { CursorLoader loader

    = new CursorLoader(getActivity()); loader.setUri(Contacts.CONTENT_URI); loader.setSelection("is_dirty = ?"); loader.setSelectionArgs(new String[] { String.valueOf(1) }); return loader; } @Override public void onLoadFinished(Loader<Cursor> loader, Cursor c) { if (c != null && c.moveToFirst()) { do { // interesting code } while (c.moveToNext()); } } CursorLoader API
  22. CursorLoaderBuilder API @Override public Loader<Cursor> onCreateLoader(int id, Bundle args) {

    return CursorLoaderBuilder .forUri(Contacts.CONTENT_URI) .where("is_dirty = ?", 1) .build(getActivity()); }  futuresimple/android-db-commons
  23.  futuresimple/android-db-commons @Override public Loader<List<Long>> onCreateLoader(int id, Bundle args) {

    return CursorLoaderBuilder .forUri(Contacts.CONTENT_URI) .transform(new Function<Cursor, Long>() { /* ... */ }) .build(getActivity()); } @Override public Loader<Long> onCreateLoader(int id, Bundle args) { return CursorLoaderBuilder .forUri(Contacts.CONTENT_URI) .wrap(new Function<Cursor, Long>() { /* ... */ }) .build(getActivity()); } CursorLoaderBuilder API
  24. SQLite’s ALTER TABLE no ALTER COLUMN no DROP COLUMN no

    touching of PRIMARY KEY no ADD/DROP/ALTER CONSTRAINT
  25. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT,

    first_name TEXT, last_name TEXT ); CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, - first_name TEXT, - last_name TEXT + name TEXT ); SQLite’s ALTER TABLE ?
  26. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT,

    first_name TEXT, last_name TEXT ); CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, - first_name TEXT, - last_name TEXT + name TEXT ); SQLite’s ALTER TABLE ALTER TABLE users RENAME TO tmp;
  27. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT,

    first_name TEXT, last_name TEXT ); CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, - first_name TEXT, - last_name TEXT + name TEXT ); SQLite’s ALTER TABLE ALTER TABLE users RENAME TO tmp; CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, name TEXT );
  28. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT,

    first_name TEXT, last_name TEXT ); CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, - first_name TEXT, - last_name TEXT + name TEXT ); SQLite’s ALTER TABLE ALTER TABLE users RENAME TO tmp; CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, name TEXT ); INSERT INTO users (_id, email) SELECT _id, email FROM tmp;
  29. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT,

    first_name TEXT, last_name TEXT ); CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, - first_name TEXT, - last_name TEXT + name TEXT ); SQLite’s ALTER TABLE ALTER TABLE users RENAME TO tmp; CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, name TEXT ); INSERT INTO users (_id, email) SELECT _id, email FROM tmp; DROP TABLE tmp;
  30.  futuresimple/android-schema-utils Sane migrations API CREATE TABLE users ( _id

    INTEGER PRIMARY KEY, email TEXT, first_name TEXT, last_name TEXT ); CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT, - first_name TEXT, - last_name TEXT + name TEXT ); TableMigration migration = TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .withMapping(/* ... */) .build(); mMigrationsHelper.performMigrations(db, migration);
  31. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); + first_name TEXT, + last_name TEXT Migrations gotcha v1 v5
  32. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); + first_name TEXT, + last_name TEXT TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); Migrations gotcha v1 v5
  33. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); - email TEXT + email TEXT NOT NULL + first_name TEXT, + last_name TEXT TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); Migrations gotcha v1 v5 v9
  34. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); - email TEXT + email TEXT NOT NULL + first_name TEXT, + last_name TEXT TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); db.execSQL( ”DELETE FROM users” + ”WHERE email IS NULL” ); TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); Migrations gotcha v1 v5 v9
  35. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); - email TEXT + email TEXT NOT NULL + first_name TEXT, + last_name TEXT TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); db.execSQL( ”DELETE FROM users” + ”WHERE email IS NULL” ); TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); Migrations gotcha v1 v5 v9
  36. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); - email TEXT + email TEXT NOT NULL + first_name TEXT, + last_name TEXT TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); db.execSQL( ”DELETE FROM users” + ”WHERE email IS NULL” ); TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); Migrations gotcha v1 v5 v9
  37. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); - email TEXT + email TEXT NOT NULL + first_name TEXT, + last_name TEXT TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); db.execSQL( ”DELETE FROM users” + ”WHERE email IS NULL” ); TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); Migrations gotcha v1 v5 v9
  38. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); - email TEXT + email TEXT NOT NULL + first_name TEXT, + last_name TEXT TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); db.execSQL( ”DELETE FROM users” + ”WHERE email IS NULL” ); TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); Migrations gotcha v1 v5 v9
  39. CREATE TABLE users ( _id INTEGER PRIMARY KEY, email TEXT

    ); - email TEXT + email TEXT NOT NULL + first_name TEXT, + last_name TEXT TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); db.execSQL( ”DELETE FROM users” + ”WHERE email IS NULL” ); TableMigration .of(Tables.USERS) .to(CREATE_TABLE_USERS) .build(); Migrations gotcha v1 v5 v9 HAVE TO USE v5 SCHEMA
  40. MINI QUIZ TIME! How would you keep old db schemas?

    CREATE_TABLE_X_r1500 List of upgrades NO DIFFS
  41. MINI QUIZ TIME! How would you keep old db schemas?

    CREATE_TABLE_X_r1500 List of upgrades NO CURRENT SCHEMA NO DIFFS
  42. MINI QUIZ TIME! How would you keep old db schemas?

    CREATE_TABLE_X_r1500 List of upgrades Current schema with list of downgrades NO CURRENT SCHEMA NO DIFFS
  43. MINI QUIZ TIME! How would you keep old db schemas?

    CREATE_TABLE_X_r1500 List of upgrades Current schema with list of downgrades NO DIFFS NO CURRENT SCHEMA UNINTUITIVE
  44. Sane(ish?) schema API private static final Schemas SCHEMA = Schemas.Builder

    .currentSchema(10, new TableDefinition(Tables.ADDRESSES, new AddColumn(Addresses.REGION, "TEXT"), new AddColumn(Addresses.ZIP, "TEXT"), new AddColumn(Addresses.COUNTRY, "TEXT"), new AddColumn(Addresses.CITY, "TEXT"), new AddColumn(Addresses.STREET, "TEXT ") ) ) .upgradeTo(7, clear(Tables.ADDRESSES)) .downgradeTo(4, new TableDowngrade(Tables.ADDRESSES, new DropColumn(Addresses.REGION) ) ) .downgradeTo(2, dropTable(Tables.ADDRESSES)) .build();  futuresimple/android-schema-utils
  45. Look ma, no boilerplate! @Override public void onCreate(SQLiteDatabase db) {

    Schema currentSchema = SCHEMAS.getCurrentSchema(); for (String table : currentSchema.getTables()) { db.execSQL(currentSchema.getCreateTableStatement(table)); } } public void onUpgrade(final SQLiteDatabase db, int oldVersion, int newVersion) { SCHEMAS.upgrade(oldVersion, mContext, db); }  futuresimple/android-schema-utils
  46. Obvious index is obvious db.execSQL("CREATE TABLE " + Tables.RAW_CONTACTS +

    " (" + // ... RawContacts.CONTACT_ID + " INTEGER REFERENCES contacts(_id)," + // ... ");"); db.execSQL("CREATE INDEX raw_contacts_contact_id_index ON " + Tables.RAW_CONTACTS + " (" + RawContacts.CONTACT_ID + ");");
  47. Thneed public static final ModelGraph<ModelInterface> MODEL_GRAPH = ModelGraph .of(ModelInterface.class) .identifiedByDefault().by(ModelColumns.ID)

    .where() .the(CONTACT).references(USER).by(Contacts.USER_ID) .the(CONTACT).groupsOther().by(Contacts.CONTACT_ID) .the(DEAL).references(CONTACT).by(Deals.CONTACT_ID) .the(DEAL).references(USER).by(Deals.USER_ID)  chalup/thneed
  48. Thneed public static final ModelGraph<ModelInterface> MODEL_GRAPH = ModelGraph .of(ModelInterface.class) .identifiedByDefault().by(ModelColumns.ID)

    .where() .the(CONTACT).references(USER).by(Contacts.USER_ID) .the(CONTACT).groupsOther().by(Contacts.CONTACT_ID) .the(DEAL).references(CONTACT).by(Deals.CONTACT_ID) .the(DEAL).references(USER).by(Deals.USER_ID) public class ModelGraph<TModel> { public void accept(ModelVisitor<? super TModel> visitor); public void accept(RelationshipVisitor<? super TModel> visitor); }  chalup/thneed
  49.  futuresimple/android-autoindexer public class AutoIndexer { public static Collection<SqliteIndex> generateIndexes(ModelGraph<~>

    graph); public static String getCreateStatement(SqliteIndex index); } private static void createIndexes(SQLiteDatabase db) { FluentIterable<SqliteIndex> indexes = FluentIterable .from(AutoIndexer.generateIndexes(MODEL_GRAPH)) .filter(Predicates.not(AutoIndexer.isIndexOnColumn(ModelColumns.ID))) .filter(Predicates.not(AutoIndexer.isIndexOnColumn(BaseColumns._ID))); for (SqliteIndex index : indexes) { db.execSQL(AutoIndexer.getCreateStatement(index)); } } AutoIndexer
  50. Autogenerate? Autodestruct. sqlite> select * from sqlite_master limit 10; type

    name tbl_name rootpage sql ---------- ---------------- ---------------- ---------- ------------------------------------------- table android_metadata android_metadata 3 CREATE TABLE android_metadata (locale TEXT) table stages stages 4 CREATE TABLE stages (_id INTEGER PRIMARY KE index sqlite_autoindex stages 5 table sqlite_sequence sqlite_sequence 6 CREATE TABLE sqlite_sequence(name,seq) table sources sources 7 CREATE TABLE sources (id INTEGER PRIMARY KE table contacts contacts 8 CREATE TABLE contacts (_id INTEGER PRIMARY index sqlite_autoindex contacts 9 table deals deals 10 CREATE TABLE deals (_id INTEGER PRIMARY KEY index sqlite_autoindex deals 11 table notes notes 12 CREATE TABLE notes (_id INTEGER PRIMARY KEY
  51. Autogenerate? Autodestruct. SQLiteMaster.dropIndexes(db); SQLiteMaster.dropTriggers(db); SQLiteMaster.dropViews(db); List<SQLiteSchemaPart> parts = SQLiteMaster.getSQLiteSchemaParts(db); sqlite>

    select * from sqlite_master limit 10; type name tbl_name rootpage sql ---------- ---------------- ---------------- ---------- ------------------------------------------- table android_metadata android_metadata 3 CREATE TABLE android_metadata (locale TEXT) table stages stages 4 CREATE TABLE stages (_id INTEGER PRIMARY KE index sqlite_autoindex stages 5 table sqlite_sequence sqlite_sequence 6 CREATE TABLE sqlite_sequence(name,seq) table sources sources 7 CREATE TABLE sources (id INTEGER PRIMARY KE table contacts contacts 8 CREATE TABLE contacts (_id INTEGER PRIMARY index sqlite_autoindex contacts 9 table deals deals 10 CREATE TABLE deals (_id INTEGER PRIMARY KEY index sqlite_autoindex deals 11 table notes notes 12 CREATE TABLE notes (_id INTEGER PRIMARY KEY  futuresimple/sqlitemaster
  52. Autogenerate? Autodestruct. SQLiteMaster.dropIndexes(db); SQLiteMaster.dropTriggers(db); SQLiteMaster.dropViews(db); List<SQLiteSchemaPart> parts = SQLiteMaster.getSQLiteSchemaParts(db); sqlite>

    select * from sqlite_master limit 10; type name tbl_name rootpage sql ---------- ---------------- ---------------- ---------- ------------------------------------------- table android_metadata android_metadata 3 CREATE TABLE android_metadata (locale TEXT) table stages stages 4 CREATE TABLE stages (_id INTEGER PRIMARY KE index sqlite_autoindex stages 5 table sqlite_sequence sqlite_sequence 6 CREATE TABLE sqlite_sequence(name,seq) table sources sources 7 CREATE TABLE sources (id INTEGER PRIMARY KE table contacts contacts 8 CREATE TABLE contacts (_id INTEGER PRIMARY index sqlite_autoindex contacts 9 table deals deals 10 CREATE TABLE deals (_id INTEGER PRIMARY KEY index sqlite_autoindex deals 11 table notes notes 12 CREATE TABLE notes (_id INTEGER PRIMARY KEY  futuresimple/sqlitemaster
  53.  futuresimple/sqlitemaster Autogenerate? Autodestruct. SQLiteMaster.dropIndexes(db); SQLiteMaster.dropTriggers(db); SQLiteMaster.dropViews(db); List<SQLiteSchemaPart> parts =

    SQLiteMaster.getSQLiteSchemaParts(db); sqlite> select * from sqlite_master limit 10; type name tbl_name rootpage sql ---------- ---------------- ---------------- ---------- ------------------------------------------- table android_metadata android_metadata 3 CREATE TABLE android_metadata (locale TEXT) table stages stages 4 CREATE TABLE stages (_id INTEGER PRIMARY KE index sqlite_autoindex stages 5 table sqlite_sequence sqlite_sequence 6 CREATE TABLE sqlite_sequence(name,seq) table sources sources 7 CREATE TABLE sources (id INTEGER PRIMARY KE table contacts contacts 8 CREATE TABLE contacts (_id INTEGER PRIMARY index sqlite_autoindex contacts 9 table deals deals 10 CREATE TABLE deals (_id INTEGER PRIMARY KEY index sqlite_autoindex deals 11 table notes notes 12 CREATE TABLE notes (_id INTEGER PRIMARY KEY
  54. Forger<ModelInterface> forger; Upload upload = forger .iNeed(Upload.class) .with(Uploads.IS_CACHED, true) .in(mContentResolver);

    Attachment attachment = forger .iNeed(Attachment.class) .relatedTo(upload) .in(mContentResolver); Byproduct: Forger  futuresimple/forger
  55. Forger<ModelInterface> forger; Upload upload = forger .iNeed(Upload.class) .with(Uploads.IS_CACHED, true) .in(mContentResolver);

    Attachment attachment = forger .iNeed(Attachment.class) .relatedTo(upload) .in(mContentResolver); Byproduct: Forger  futuresimple/forger
  56. Forger<ModelInterface> forger = FORGER .inContextOf(User.class).in(mContentResolver); Upload upload = forger .iNeed(Upload.class)

    .with(Uploads.IS_CACHED, true) .in(mContentResolver); Attachment attachment = forger .iNeed(Attachment.class) .relatedTo(upload) .in(mContentResolver); Byproduct: Forger  futuresimple/forger
  57.  futuresimple/forger @Test public void shouldNotifyAboutReadyShareRequest() throws Exception { FORGER

    .inContextOf(User.class).in(mContentResolver) .inContextOf(Contact.class).in(mContentResolver) .inContextOf(Upload.class).in(mContentResolver) .inContextOf(Attachment.class).in(mContentResolver) .inContextOf(ShareDocumentRequest.class).in(mContentResolver) .iNeed(ShareDocumentList.class).in(mContentResolver); testSubject.notifyReadyShareRequests(); verify(mNotificationManager).notify(anyInt(), any(Notification.class)); } Byproduct: Forger
  58. Work in progress! AutoProvider TableJoiner Uri matching/building, CRUD basic implementation...

    ...or something completely replacing ContentProvider The SQLite helper to end all SQLite helpers!
  59. Work in progress! AutoProvider TableJoiner Cerberus Uri matching/building, CRUD basic

    implementation... ...or something completely replacing ContentProvider The SQLite helper to end all SQLite helpers! Attaches itself to database and executes EXPLAIN QUERY PLAN for each query.
  60. ?