dimanche 3 avril 2016

ContentProvider returns faulty queries with a real ContentResolver, but not with a mocked one

I'm getting into Android programming and also into testing. So any additional tipps are welcomed! :)

The issue

The queries I perform behave very strange. They return cursors that contain only freshly added entries. This isn't always true, though. Sometimes I can add one extra entry to those five new ones and it will still only return five instead of six.

What really irritates me is that my tests for my ContentProvider run just fine on a MockContentResolver. They only behave strangely with a real one.

Additional info

I have not yet figured out a pattern for when exactly freshly added entries really get returned. I have created a DummyUtils class which provides an (ugly) method to add a few sample entries into the ContentProvider and the underlying SQLite database. I've set it to insert five new entries. Only those are shown in the RecyclerView and I've checked that they are the only entries in the returned Cursor.

What's really frustrating is that I have to assume that the database contains many more entries: The _ID field of the Cursor keeps on incrementing, even with it not set to AUTOINCREMENT.

Sample code

I have published the code on GitHub, you can check it out there.

My ContentProvider (Provider)

package com.y0hy0h.moneytracker.data;

import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;

public class Provider extends ContentProvider{

    private static final String LOG_TAG = Provider.class.getSimpleName();

    private static final UriMatcher sUriMatcher = buildUriMatcher();
    private DbHelper mOpenHelper;

    static final int SINGLE_EXPENSE = 100;
    static final int EXPENSES = 101;
    static final int SINGLE_CATEGORY = 200;
    static final int CATEGORIES = 201;

    private static final String JOINED_EXPENSES_CATEGORIES_TABLE =
            Contract.ExpenseEntry.TABLE_NAME + " JOIN " +
                    Contract.CategoryEntry.TABLE_NAME +
            " ON " + Contract.ExpenseEntry.TABLE_NAME + "." +
                    Contract.ExpenseEntry.COLUMN_NAME_CATEGORY_ID +
            " = " + Contract.CategoryEntry.TABLE_NAME + "." +
                    Contract.CategoryEntry._ID;

    static UriMatcher buildUriMatcher() {
        final UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH);
        final String authority = Contract.CONTENT_AUTHORITY;

        matcher.addURI(authority, Contract.PATH_EXPENSE + "/#", SINGLE_EXPENSE);
        matcher.addURI(authority, Contract.PATH_CATEGORY + "/#", SINGLE_CATEGORY);
        matcher.addURI(authority, Contract.PATH_EXPENSE, EXPENSES);
        matcher.addURI(authority, Contract.PATH_CATEGORY, CATEGORIES);

        return matcher;
    }

    @Override
    public boolean onCreate() {
        mOpenHelper = new DbHelper(getContext());
        return false;
    }

    @Nullable
    @Override
    public Cursor query(@NonNull Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {

        SQLiteQueryBuilder builder = new SQLiteQueryBuilder();

        switch (sUriMatcher.match(uri)) {

            case SINGLE_EXPENSE:
                builder.setTables(JOINED_EXPENSES_CATEGORIES_TABLE);
                builder.appendWhereEscapeString("_ID=" + ContentUris.parseId(uri));
                break;

            case SINGLE_CATEGORY:
                builder.setTables(Contract.CategoryEntry.TABLE_NAME);
                builder.appendWhereEscapeString("_ID=" + ContentUris.parseId(uri));
                break;

            case EXPENSES:
                builder.setTables(JOINED_EXPENSES_CATEGORIES_TABLE);
                break;

            case CATEGORIES:
                builder.setTables(Contract.CategoryEntry.TABLE_NAME);
                break;

            default:
                throw new IllegalArgumentException();
        }

        Cursor cursor = builder.query(
                mOpenHelper.getReadableDatabase(),
                projection,
                selection,
                selectionArgs,
                null,
                null,
                sortOrder
        );

        assert getContext() != null;
        Log.d(LOG_TAG, "Notification Uri: " + uri);
        cursor.setNotificationUri(getContext().getContentResolver(), uri);
        return cursor;
    }

    @Nullable
    @Override
    public String getType(@NonNull Uri uri) {

        switch (sUriMatcher.match(uri)) {

            case SINGLE_EXPENSE:
                return Contract.ExpenseEntry.CONTENT_ITEM_TYPE;

            case SINGLE_CATEGORY:
                return Contract.CategoryEntry.CONTENT_ITEM_TYPE;

            case EXPENSES:
                return Contract.ExpenseEntry.CONTENT_TYPE;

            case CATEGORIES:
                return Contract.CategoryEntry.CONTENT_TYPE;

            default:
                throw new UnsupportedOperationException("Unknown uri: " + uri);
        }
    }

    @Nullable
    @Override
    public Uri insert(@NonNull Uri uri, ContentValues values) {

        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        Uri returnUri;

        switch (sUriMatcher.match(uri)) {

            case EXPENSES: {
                long id = db.insert(Contract.ExpenseEntry.TABLE_NAME, null, values);
                if (id > 0) {
                    returnUri = Contract.ExpenseEntry.buildExpenseUri(id);
                } else {
                    throw new android.database.SQLException("Failed to insert row into " + uri);
                }
                break;
            }

            case CATEGORIES: {
                long id = db.insert(Contract.CategoryEntry.TABLE_NAME, null, values);
                if (id > 0) {
                    returnUri = Contract.CategoryEntry.buildCategoryUri(id);
                } else {
                    throw new android.database.SQLException("Failed to insert row into " + uri);
                }
                break;
            }

            default:
                throw new UnsupportedOperationException("Unknown Uri: " + uri);
        }

        assert getContext() != null;
        Log.d(LOG_TAG, "Change on: " + uri);
        getContext().getContentResolver().notifyChange(uri, null);
        return returnUri;
    }

    @Override
    public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) {

        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        int rowsDeleted;

        // ensure number of rows deleted is returned
        // when all rows should be deleted (selection == null)
        if (selection == null)
        {
            selection = "1";
        }

        switch (sUriMatcher.match(uri)) {

            case EXPENSES:
                rowsDeleted = db.delete(
                        Contract.ExpenseEntry.TABLE_NAME,
                        selection,
                        selectionArgs
                );
                break;

            case CATEGORIES:
                rowsDeleted = db.delete(
                        Contract.CategoryEntry.TABLE_NAME,
                        selection,
                        selectionArgs
                );
                break;

            default:
                throw new UnsupportedOperationException("Unknown Uri: " + uri);
        }

        if (rowsDeleted > 0) {
            assert getContext() != null;
            getContext().getContentResolver().notifyChange(uri, null);
        }
        return rowsDeleted;
    }

    @Override
    public int update(@NonNull Uri uri, ContentValues values, String selection, String[] selectionArgs) {

        final SQLiteDatabase db = mOpenHelper.getWritableDatabase();
        int rowsUpdated;

        switch (sUriMatcher.match(uri)) {

            case EXPENSES:
                rowsUpdated = db.update(
                        Contract.ExpenseEntry.TABLE_NAME,
                        values,
                        selection,
                        selectionArgs
                );
                break;

            case CATEGORIES:
                rowsUpdated = db.update(
                        Contract.CategoryEntry.TABLE_NAME,
                        values,
                        selection,
                        selectionArgs
                );
                break;

            default:
                throw new UnsupportedOperationException("Unknown Uri: " + uri);
        }

        if (rowsUpdated > 0) {
            assert getContext() != null;
            getContext().getContentResolver().notifyChange(uri, null);
        }
        return rowsUpdated;
    }
}

The method in MainActivity that uses the `DummyUtils

@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
        assert fab != null;
        fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                new DummyUtils().insertSamples(getContentResolver());
                //Snackbar.make(view, R.string.add_expense, Snackbar.LENGTH_LONG).show();
            }
        });

    }

DummyUtils

package com.y0hy0h.moneytracker.dummy;

import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;

import com.y0hy0h.moneytracker.data.Contract;

import java.util.ArrayList;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.LinkedList;
import java.util.List;
import java.util.Random;

public class DummyUtils {

    public void insertSamples(ContentResolver resolver) {

        List<String> categoryNames = new ArrayList<>();
        categoryNames.add("Nutrition");
        categoryNames.add("Entertainment");
        categoryNames.add("Health");

        ContentValues categories[] = new ContentValues[3];
        long categoryIds[] = new long[categories.length];

        for (int i = 0; i < categoryNames.size(); i++) {

            categories[i] = new ContentValues();
            categories[i].put(Contract.CategoryEntry.COLUMN_NAME_LABEL,
                    categoryNames.get(i));
            categoryIds[i] = ContentUris.parseId(
                    resolver.insert(Contract.CategoryEntry.CONTENT_URI, categories[i]));
        }

        List<Expense> expensesList = new LinkedList<>();
        Random random = new Random(1);
        final int AMOUNT_OF_EXPENSES = 5;

        for (int i = 0; i < AMOUNT_OF_EXPENSES; i++) {
            GregorianCalendar date = new GregorianCalendar();
            date.add(Calendar.DATE, -random.nextInt(30));

            expensesList.add(new Expense(
                    random.nextInt(1000),
                    categoryIds[random.nextInt(categoryIds.length)],
                    date.getTimeInMillis(),
                    random.nextFloat() > 0.9 ? "Eine Notiz" : null));
        }

        ContentValues expenses[] = new ContentValues[AMOUNT_OF_EXPENSES];

        for (int i = 0; i < AMOUNT_OF_EXPENSES; i++) {

            expenses[i] = new ContentValues();
            expenses[i].put(Contract.ExpenseEntry.COLUMN_NAME_AMOUNT,
                    expensesList.get(i).amount);
            expenses[i].put(Contract.ExpenseEntry.COLUMN_NAME_CATEGORY_ID,
                    expensesList.get(i).categoryId);
            expenses[i].put(Contract.ExpenseEntry.COLUMN_NAME_DATE,
                    expensesList.get(i).date);
            expenses[i].put(Contract.ExpenseEntry.COLUMN_NAME_NOTE,
                    expensesList.get(i).note);

            ContentUris.parseId(
                    resolver.insert(Contract.ExpenseEntry.CONTENT_URI, expenses[i]));
        }
    }

    public class Expense {
        int amount;
        long categoryId;
        long date;
        String note;

        public Expense(int amount, long categoryId, long date, String note) {
            this.amount = amount;
            this.categoryId = categoryId;
            this.date = date;
            this.note = note;
        }
    }
}

Aucun commentaire:

Enregistrer un commentaire