/*
* Copyright (C) 2014 Murray Cumming
*
* This file is part of android-galaxyzoo.
*
* android-galaxyzoo 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 3 of the License, or
* (at your option) any later version.
*
* android-galaxyzoo 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 android-galaxyzoo. If not, see <http://www.gnu.org/licenses/>.
*/
package com.murrayc.galaxyzoo.app.provider;
import android.content.ClipDescription;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteQueryBuilder;
import android.net.Uri;
import android.os.Bundle;
import android.os.ParcelFileDescriptor;
import android.provider.BaseColumns;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.test.mock.MockContentResolver;
import android.text.TextUtils;
import com.murrayc.galaxyzoo.app.Log;
import com.murrayc.galaxyzoo.app.Utils;
import com.murrayc.galaxyzoo.app.provider.client.ZooniverseClient;
import com.murrayc.galaxyzoo.app.syncadapter.SubjectAdder;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class ItemsContentProvider extends ContentProvider {
public static final String URI_PART_ITEM = "item";
public static final String URI_PART_ITEM_ID_NEXT = "next"; //Use in place of the item ID to get the next unclassified item.
public static final String URI_PART_FILE = "file";
public static final String URI_PART_CLASSIFICATION_ANSWER = "classification-answer";
public static final String URI_PART_CLASSIFICATION_CHECKBOX = "classification-checkbox";
private static final String URI_PART_CLASSIFICATION = "classification";
/** The standard _data field used by the ContentProvider/ContentResolver for
* the local URI corresponding to the row (identified by a Content URI) in the table.
*/
public static final String URI_PART_DATA = "_data";
/**
* The MIME type of {@link Item#CONTENT_URI} providing a directory of items.
*/
private static final String CONTENT_TYPE_ITEMS =
"vnd.android.cursor.dir/vnd.android-galaxyzoo.item";
/**
* The MIME type of a {@link Item#CONTENT_URI} sub-directory of a single
* item.
*/
private static final String CONTENT_TYPE_ITEM =
"vnd.android.cursor.item/vnd.android-galaxyzoo.item";
/**
* The MIME type of {@link Item#CONTENT_URI} providing a directory of classifications.
*/
private static final String CONTENT_TYPE_CLASSIFICATIONS =
"vnd.android.cursor.dir/vnd.android-galaxyzoo.classification";
/**
* The MIME type of a {@link Item#CONTENT_URI} sub-directory of a single
* classification.
*/
private static final String CONTENT_TYPE_CLASSIFICATION =
"vnd.android.cursor.item/vnd.android-galaxyzoo.classification";
/**
* The MIME type of {@link Item#CONTENT_URI} providing a directory of classifications.
*/
private static final String CONTENT_TYPE_CLASSIFICATION_ANSWERS =
"vnd.android.cursor.dir/vnd.android-galaxyzoo.classification-answer";
/**
* The MIME type of a {@link Item#CONTENT_URI} sub-directory of a single
* classification answer.
*/
private static final String CONTENT_TYPE_CLASSIFICATION_ANSWER =
"vnd.android.cursor.item/vnd.android-galaxyzoo.classification-answer";
/**
* The MIME type of {@link Item#CONTENT_URI} providing a directory of classifications.
*/
private static final String CONTENT_TYPE_CLASSIFICATION_CHECKBOXES =
"vnd.android.cursor.dir/vnd.android-galaxyzoo.classification-checkboxes";
/**
* The MIME type of a {@link Item#CONTENT_URI} sub-directory of a single
* classification checkbox.
*/
private static final String CONTENT_TYPE_CLASSIFICATION_CHECKBOX =
"vnd.android.cursor.item/vnd.android-galaxyzoo.classification-checkbox";
//TODO: Use an enum?
private static final int MATCHER_ID_ITEMS = 1;
private static final int MATCHER_ID_ITEM = 2;
private static final int MATCHER_ID_ITEM_NEXT = 3;
private static final int MATCHER_ID_FILE = 4;
private static final int MATCHER_ID_CLASSIFICATIONS = 5;
private static final int MATCHER_ID_CLASSIFICATION = 6;
private static final int MATCHER_ID_CLASSIFICATION_ANSWERS = 7;
private static final int MATCHER_ID_CLASSIFICATION_ANSWER = 8;
private static final int MATCHER_ID_CLASSIFICATION_CHECKBOXES = 9;
private static final int MATCHER_ID_CLASSIFICATION_CHECKBOX = 10;
private static final UriMatcher sUriMatcher;
static {
sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
// A URI for the list of all items:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_ITEM, MATCHER_ID_ITEMS);
// A URI for a single item:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_ITEM + "/" + URI_PART_ITEM_ID_NEXT, MATCHER_ID_ITEM_NEXT);
// A URI for a single item:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_ITEM + "/#", MATCHER_ID_ITEM);
// A URI for a single file:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_FILE + "/#", MATCHER_ID_FILE);
// A URI for the list of all classifications:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_CLASSIFICATION, MATCHER_ID_CLASSIFICATIONS);
// A URI for a single classification:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_CLASSIFICATION + "/#", MATCHER_ID_CLASSIFICATION);
// A URI for the list of all classifications:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_CLASSIFICATION_ANSWER, MATCHER_ID_CLASSIFICATION_ANSWERS);
// A URI for a single classification:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_CLASSIFICATION_ANSWER + "/#", MATCHER_ID_CLASSIFICATION_ANSWER);
// A URI for the list of all classifications:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_CLASSIFICATION_CHECKBOX, MATCHER_ID_CLASSIFICATION_CHECKBOXES);
// A URI for a single classification:
sUriMatcher.addURI(Item.AUTHORITY, URI_PART_CLASSIFICATION_CHECKBOX + "/#", MATCHER_ID_CLASSIFICATION_CHECKBOX);
}
private static final String[] FILE_MIME_TYPES = new String[]{"application/x-glom"};
/**
* A map of GlomContentProvider projection column names to underlying Sqlite column names
* for /item/ URIs, mapping to the items tables.
*/
private static final Map<String, String> sItemsProjectionMap;
private static final Map<String, String> sClassificationAnswersProjectionMap;
private static final Map<String, String> sClassificationCheckboxesProjectionMap;
static {
sItemsProjectionMap = new HashMap<>();
sItemsProjectionMap.put(BaseColumns._ID, BaseColumns._ID);
sItemsProjectionMap.put(Item.Columns.DONE, DatabaseHelper.ItemsDbColumns.DONE);
sItemsProjectionMap.put(Item.Columns.UPLOADED, DatabaseHelper.ItemsDbColumns.UPLOADED);
sItemsProjectionMap.put(Item.Columns.SUBJECT_ID, DatabaseHelper.ItemsDbColumns.SUBJECT_ID);
sItemsProjectionMap.put(Item.Columns.ZOONIVERSE_ID, DatabaseHelper.ItemsDbColumns.ZOONIVERSE_ID);
sItemsProjectionMap.put(Item.Columns.GROUP_ID, DatabaseHelper.ItemsDbColumns.GROUP_ID);
sItemsProjectionMap.put(Item.Columns.LOCATION_STANDARD_URI_REMOTE, DatabaseHelper.ItemsDbColumns.LOCATION_STANDARD_URI_REMOTE);
sItemsProjectionMap.put(Item.Columns.LOCATION_STANDARD_URI, DatabaseHelper.ItemsDbColumns.LOCATION_STANDARD_URI);
sItemsProjectionMap.put(Item.Columns.LOCATION_STANDARD_DOWNLOADED, DatabaseHelper.ItemsDbColumns.LOCATION_STANDARD_DOWNLOADED);
sItemsProjectionMap.put(Item.Columns.LOCATION_THUMBNAIL_URI_REMOTE, DatabaseHelper.ItemsDbColumns.LOCATION_THUMBNAIL_URI_REMOTE);
sItemsProjectionMap.put(Item.Columns.LOCATION_THUMBNAIL_URI, DatabaseHelper.ItemsDbColumns.LOCATION_THUMBNAIL_URI);
sItemsProjectionMap.put(Item.Columns.LOCATION_THUMBNAIL_DOWNLOADED, DatabaseHelper.ItemsDbColumns.LOCATION_THUMBNAIL_DOWNLOADED);
sItemsProjectionMap.put(Item.Columns.LOCATION_INVERTED_URI_REMOTE, DatabaseHelper.ItemsDbColumns.LOCATION_INVERTED_URI_REMOTE);
sItemsProjectionMap.put(Item.Columns.LOCATION_INVERTED_URI, DatabaseHelper.ItemsDbColumns.LOCATION_INVERTED_URI);
sItemsProjectionMap.put(Item.Columns.LOCATION_INVERTED_DOWNLOADED, DatabaseHelper.ItemsDbColumns.LOCATION_INVERTED_DOWNLOADED);
sItemsProjectionMap.put(Item.Columns.FAVORITE, DatabaseHelper.ItemsDbColumns.FAVORITE);
sItemsProjectionMap.put(Item.Columns.DATETIME_DONE, DatabaseHelper.ItemsDbColumns.DATETIME_DONE);
sClassificationAnswersProjectionMap = new HashMap<>();
sClassificationAnswersProjectionMap.put(BaseColumns._ID, BaseColumns._ID);
sClassificationAnswersProjectionMap.put(ClassificationAnswer.Columns.ITEM_ID, DatabaseHelper.ClassificationAnswersDbColumns.ITEM_ID);
sClassificationAnswersProjectionMap.put(ClassificationAnswer.Columns.SEQUENCE, DatabaseHelper.ClassificationAnswersDbColumns.SEQUENCE);
sClassificationAnswersProjectionMap.put(ClassificationAnswer.Columns.QUESTION_ID, DatabaseHelper.ClassificationAnswersDbColumns.QUESTION_ID);
sClassificationAnswersProjectionMap.put(ClassificationAnswer.Columns.ANSWER_ID, DatabaseHelper.ClassificationAnswersDbColumns.ANSWER_ID);
sClassificationCheckboxesProjectionMap = new HashMap<>();
sClassificationCheckboxesProjectionMap.put(BaseColumns._ID, BaseColumns._ID);
sClassificationCheckboxesProjectionMap.put(ClassificationCheckbox.Columns.ITEM_ID, DatabaseHelper.ClassificationCheckboxesDbColumns.ITEM_ID);
sClassificationCheckboxesProjectionMap.put(ClassificationCheckbox.Columns.SEQUENCE, DatabaseHelper.ClassificationCheckboxesDbColumns.SEQUENCE);
sClassificationCheckboxesProjectionMap.put(ClassificationCheckbox.Columns.QUESTION_ID, DatabaseHelper.ClassificationCheckboxesDbColumns.QUESTION_ID);
sClassificationCheckboxesProjectionMap.put(ClassificationCheckbox.Columns.CHECKBOX_ID, DatabaseHelper.ClassificationCheckboxesDbColumns.CHECKBOX_ID);
}
private DatabaseHelper mOpenDbHelper = null;
//These are only used in the rare case that we need to explicitly get a "next" item,
//and block on the result, if the SyncAdapter hasn't done that for us.
private ZooniverseClient mZooniverseClient = null;
private SubjectAdder mSubjectAdder = null;
private static final String[] PROJECTION_REMOVE_ITEM = {
DatabaseHelper.ItemsDbColumns.LOCATION_STANDARD_URI,
DatabaseHelper.ItemsDbColumns.LOCATION_THUMBNAIL_URI,
DatabaseHelper.ItemsDbColumns.LOCATION_INVERTED_URI
};
private static final String[] PROJECTION_FILES_FILE_DATA = {DatabaseHelper.FilesDbColumns.FILE_DATA};
/** A where clause to find all the subjects that have not yet been classified,
* and which are ready to be classified.
*/
private static final String WHERE_CLAUSE_NOT_DONE = "(" +
DatabaseHelper.ItemsDbColumns.DONE + " != 1" +
") AND (" +
DatabaseHelper.ItemsDbColumns.LOCATION_STANDARD_DOWNLOADED + " == 1" +
") AND (" +
DatabaseHelper.ItemsDbColumns.LOCATION_THUMBNAIL_DOWNLOADED + " == 1" +
") AND (" +
DatabaseHelper.ItemsDbColumns.LOCATION_INVERTED_DOWNLOADED + " == 1" +
")";
public ItemsContentProvider() {
}
private static ContentValues getMappedContentValues(final ContentValues values, final Map<String, String> projectionMap) {
final ContentValues result = new ContentValues();
for (final String keyExternal : values.keySet()) {
final String keyInternal = projectionMap.get(keyExternal);
if (!TextUtils.isEmpty(keyInternal)) {
final Object value = values.get(keyExternal);
putValueInContentValues(result, keyInternal, value);
}
}
return result;
}
/**
* There is no ContentValues.put(key, object),
* only put(key, String), put(key, Boolean), etc.
* so we use this tedious implementation instead,
* so our code can be more generic.
*
* @param values
* @param key
* @param value
*/
private static void putValueInContentValues(final ContentValues values, final String key, final Object value) {
if (value instanceof String) {
values.put(key, (String) value);
} else if (value instanceof Boolean) {
values.put(key, (Boolean) value);
} else if (value instanceof Integer) {
values.put(key, (Integer) value);
} else if (value instanceof Long) {
values.put(key, (Long) value);
} else if (value instanceof Double) {
values.put(key, (Double) value);
}
}
@Override
public int delete(@NonNull final Uri uri, final String selection, final String[] selectionArgs) {
final int match = sUriMatcher.match(uri);
final int affected;
switch (match) {
//TODO: Do not support this because it would delete everything in one go?
case MATCHER_ID_ITEMS:
affected = getDb().delete(DatabaseHelper.TABLE_NAME_ITEMS,
(!TextUtils.isEmpty(selection) ?
" AND (" + selection + ')' : ""),
selectionArgs
);
//TODO: Delete all associated files too.
break;
case MATCHER_ID_ITEM: {
//TODO: Use selection.
final UriParts uriParts = parseContentUri(uri);
removeItem(uriParts.itemId);
affected = 1; //TODO: Check the removeItem() result.
break;
}
//TODO: Do not support this because it would delete everything in one go?
case MATCHER_ID_CLASSIFICATION_ANSWERS:
affected = getDb().delete(DatabaseHelper.TABLE_NAME_CLASSIFICATION_ANSWERS,
(!TextUtils.isEmpty(selection) ?
" AND (" + selection + ')' : ""),
selectionArgs
);
//TODO: Delete all associated files too.
break;
case MATCHER_ID_CLASSIFICATION_ANSWER: {
final UriParts uriParts = parseContentUri(uri);
affected = getDb().delete(DatabaseHelper.TABLE_NAME_CLASSIFICATION_ANSWERS,
prependIdToSelection(selection),
prependToArray(selectionArgs, uriParts.itemId)
);
break;
}
//TODO: Do not support this because it would delete everything in one go?
case MATCHER_ID_CLASSIFICATION_CHECKBOXES:
affected = getDb().delete(DatabaseHelper.TABLE_NAME_CLASSIFICATION_CHECKBOXES,
(!TextUtils.isEmpty(selection) ?
" AND (" + selection + ')' : ""),
selectionArgs
);
//TODO: Delete all associated files too.
break;
case MATCHER_ID_CLASSIFICATION_CHECKBOX:
final UriParts uriParts = parseContentUri(uri);
affected = getDb().delete(DatabaseHelper.TABLE_NAME_CLASSIFICATION_CHECKBOXES,
prependIdToSelection(selection),
prependToArray(selectionArgs, uriParts.itemId)
);
break;
//TODO?: case MATCHER_ID_FILE:
default:
throw new IllegalArgumentException("unknown item: " +
uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return affected;
}
@Override
public String getType(@NonNull final Uri uri) {
switch (sUriMatcher.match(uri)) {
case MATCHER_ID_ITEMS:
return CONTENT_TYPE_ITEMS;
case MATCHER_ID_ITEM:
case MATCHER_ID_ITEM_NEXT:
return CONTENT_TYPE_ITEM;
case MATCHER_ID_CLASSIFICATION_ANSWERS:
return CONTENT_TYPE_CLASSIFICATION_ANSWERS;
case MATCHER_ID_CLASSIFICATION_ANSWER:
return CONTENT_TYPE_CLASSIFICATION_ANSWER;
case MATCHER_ID_CLASSIFICATION_CHECKBOXES:
return CONTENT_TYPE_CLASSIFICATION_CHECKBOXES;
case MATCHER_ID_CLASSIFICATION_CHECKBOX:
return CONTENT_TYPE_CLASSIFICATION_CHECKBOX;
default:
throw new IllegalArgumentException("Unknown item type: " +
uri);
}
}
public String[] getStreamTypes(@NonNull final Uri uri, @NonNull final String mimeTypeFilter) {
switch (sUriMatcher.match(uri)) {
case MATCHER_ID_FILE:
if (mimeTypeFilter != null) {
// We use ClipDescription just so we can use its filterMimeTypes()
// though we are not intested in ClipData here.
// TODO: Find a more suitable utility function?
final ClipDescription clip = new ClipDescription(null, FILE_MIME_TYPES);
return clip.filterMimeTypes(mimeTypeFilter);
} else {
//We return a clone rather than the array itself,
//because that would theoretically allow the caller to
//modify the items, which is theoretically a
//security vulnerability.
return FILE_MIME_TYPES.clone();
}
default:
throw new IllegalArgumentException("Unknown type: " +
uri);
}
}
@Override
public ParcelFileDescriptor openFile(@NonNull final Uri uri, @NonNull final String mode)
throws FileNotFoundException {
return super.openFileHelper(uri, mode);
}
//TODO: Is this actually used by anything?
@Override
public Uri insert(@NonNull final Uri uri, final ContentValues values) {
// Note: We map the values' columns names to the internal database columns names.
// Strangely, I can't find any example code, or open source code, that bothers to do this,
// though examples for query() generally do.
// Maybe they don't do it because it's so awkward. murrayc.
// But if we don't do this then we are leaking the internal database structure out as our API.
final Uri uriInserted;
switch (sUriMatcher.match(uri)) {
case MATCHER_ID_ITEMS:
case MATCHER_ID_ITEM:
//Refuse to insert without a Subject ID:
final String subjectId = values.getAsString(Item.Columns.SUBJECT_ID);
if (TextUtils.isEmpty(subjectId)) {
throw new IllegalArgumentException("Refusing to insert without a SubjectID: " + uri);
}
// Get (our) local content URIs for the local caches of any (or any future) remote URIs for the images:
// Notice that we allow the client to provide a remote URI for each but we then change
// it to our local URI of our local cache of that remote file.
// Even if no URI is provided by the client, we still create the local URI and put
// it in the table row for later use.
final ContentValues valuesComplete = new ContentValues(values);
//This doesn't actually get any data from the locations.
boolean fileUrisCreated = false;
try {
fileUrisCreated = createFileUrisForImages(valuesComplete);
} catch (final IOException e) {
Log.error("insert(): createFileUrisForImages() failed", e);
}
if (!fileUrisCreated) {
//Abandon the item.
//We cannot add an item without its file URIs.
return null;
}
uriInserted = insertMappedValues(DatabaseHelper.TABLE_NAME_ITEMS, valuesComplete,
sItemsProjectionMap, Item.ITEMS_URI);
//The caller (SyncAdapter) will do this: cacheUrisToFiles(subjectId, listFiles, true /* async */);
requestSync();
break;
case MATCHER_ID_CLASSIFICATION_ANSWERS:
case MATCHER_ID_CLASSIFICATION_ANSWER:
uriInserted = insertMappedValues(DatabaseHelper.TABLE_NAME_CLASSIFICATION_ANSWERS,
values, sClassificationAnswersProjectionMap,
ClassificationAnswer.CLASSIFICATION_ANSWERS_URI);
break;
case MATCHER_ID_CLASSIFICATION_CHECKBOXES:
case MATCHER_ID_CLASSIFICATION_CHECKBOX:
uriInserted = insertMappedValues(DatabaseHelper.TABLE_NAME_CLASSIFICATION_CHECKBOXES,
values, sClassificationCheckboxesProjectionMap,
ClassificationCheckbox.CLASSIFICATION_CHECKBOXES_URI);
break;
default:
//This could be because of an invalid -1 ID in the # position.
throw new IllegalArgumentException("unsupported uri: " + uri);
}
return uriInserted;
}
private int updateMappedValues(final String tableName, final ContentValues values, final Map<String, String> projectionMap, final String selection,
final String[] selectionArgs) {
final ContentValues valuesToUse = getMappedContentValues(values, projectionMap);
// insert the initialValues into a new database row
final SQLiteDatabase db = getDb();
return db.update(tableName, valuesToUse,
selection, selectionArgs);
}
@Nullable
private Uri insertMappedValues(final String tableName, final ContentValues values, final Map<String, String> projectionMap, final Uri uriPrefix) {
final ContentValues valuesToUse = getMappedContentValues(values, projectionMap);
// insert the initialValues into a new database row
final SQLiteDatabase db = getDb();
try {
final long rowId = db.insertOrThrow(tableName,
DatabaseHelper.ItemsDbColumns._ID, valuesToUse);
if (rowId >= 0) {
final Uri itemUri =
ContentUris.withAppendedId(
uriPrefix, rowId);
getContext().getContentResolver().notifyChange(itemUri, null);
return itemUri; //The URI of the newly-added Item.
} else {
throw new IllegalStateException("could not insert " +
"content values: " + values);
}
} catch (final SQLException e) {
//TODO: Let the caller catch this?
Log.error("insert failed", e);
}
return null;
}
/** Get a the content URI of a new file, whose data will actually be on the local system.
*/
private Uri createFileUri() throws IOException {
//Log.info("createFileUri(): subject id=" + subjectId + ", imageType=" + imageType);
final SQLiteDatabase db = getDb();
final long fileId = db.insertOrThrow(DatabaseHelper.TABLE_NAME_FILES,
DatabaseHelper.FilesDbColumns.FILE_DATA, null);
//Build a value for the _data column, using the autogenerated file _id:
final String realFileUri = createCacheFile(Long.toString(fileId)); //TODO: Is toString() affected by the locale?)
if (TextUtils.isEmpty(realFileUri)) {
Log.error("createFileUri(): createCacheFile() returned null.");
return null;
}
//Put the value for the _data column in the files table:
//This will be used implicitly by openOutputStream() and openInputStream():
final ContentValues valuesUpdate = new ContentValues();
valuesUpdate.put(DatabaseHelper.FilesDbColumns.FILE_DATA, realFileUri);
db.update(DatabaseHelper.TABLE_NAME_FILES, valuesUpdate,
BaseColumns._ID + " = ?", new String[]{Double.toString(fileId)});
//Build the content: URI for the file to put in the Item's table:
Uri fileUri = null;
if (fileId >= 0) {
fileUri = ContentUris.withAppendedId(Item.FILE_URI, fileId);
//TODO? getContext().getContentResolver().notifyChange(fileId, null);
}
return fileUri;
}
/**
* Actually create the file on disk in the cache directory,
* and return the absolute path of the new file.
*
* @param filename
* @return
*/
@Nullable
private String createCacheFile(final String filename) throws IOException {
final Context context = getContext();
if (context == null) {
return null;
}
final File cacheDir = Utils.getExternalCacheDir(context);
if (cacheDir == null) {
Log.error("createFileUri(): getExternalCacheDir returned null.");
return null;
}
final File file = new File(cacheDir, filename);
//Actually create an empty file there -
//otherwise when we try to write to it via openOutputStream()
//we will get a FileNotFoundException.
try {
if(!file.createNewFile()) {
//This can happen while debugging, if we wipe the database but don't wipe the cached files.
//You can do that by uninstalling the app.
//When this happens we just reuse the file.
Log.error("createCacheFile(): The file already exists: " + file.getAbsolutePath());
}
/*
else {
Log.info("createFileUri(): subject id=" + subjectId +", file created: " + realFile.getAbsolutePath());
}
*/
} catch (final IOException|UnsupportedOperationException e) {
//This happens while running under ProviderTestCase2.
//so we just catch it and provide a useful value,
//so at least the other functionality can be tested.
//TODO: Find a way to let it succeed.
if (context.getContentResolver() instanceof MockContentResolver) {
Log.error("createCacheFile(): exception expected during testing: for filename=" + file.getAbsolutePath(), e);
return "testuri";
} else {
throw e;
}
}
return file.getAbsolutePath();
}
@Override
public boolean onCreate() {
final Context context = getContext();
mOpenDbHelper = new DatabaseHelper(context);
//This is useful to wipe the database when testing.
//Note that the cached image files in files/ will not be deleted
//so you will see "the file already exists" errors in the log,
//but we will then just reuse the files.
//mOpenDbHelper.onUpgrade(mOpenDbHelper.getWritableDatabase(), 0, 1);
mZooniverseClient = new ZooniverseClient(context, Config.SERVER);
mSubjectAdder = new SubjectAdder(context, mZooniverseClient.getRequestQueue());
//This isn't necessary when using the private getExternalCacheDir():
//Make sure that the .nomedia file exists,
//to prevent the media indexer from checking or listing our files.
//createCacheFile(".nomedia");
return true;
}
@Override
public Cursor query(@NonNull final Uri uri, final String[] projection, final String selection,
final String[] selectionArgs, final String sortOrder) {
//TODO: Avoid a direct implicit mapping between the Cursor column names in "selection" and the
//underlying SQL database names.
// If no sort order is specified use the default
final String orderBy;
if (TextUtils.isEmpty(sortOrder)) {
orderBy = DatabaseHelper.DEFAULT_SORT_ORDER;
} else {
orderBy = sortOrder;
}
final int match = sUriMatcher.match(uri);
Cursor c;
switch (match) {
case MATCHER_ID_ITEMS: {
// query the database for all items:
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_ITEMS);
builder.setProjectionMap(sItemsProjectionMap);
c = builder.query(getDb(), projection,
selection, selectionArgs,
null, null, orderBy);
c.setNotificationUri(getContext().getContentResolver(),
Item.CONTENT_URI);
//The client must call(TODO) sometime to actually fill the database with items,
//and the client will then be notified via the cursor that there are new items.
break;
}
case MATCHER_ID_ITEM: {
// query the database for a specific item:
final UriParts uriParts = parseContentUri(uri);
//Prepend our ID=? argument to the selection arguments.
//This lets us use the ? syntax to avoid SQL injection
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_ITEMS);
builder.setProjectionMap(sItemsProjectionMap);
builder.appendWhere(BaseColumns._ID + " = ?"); //We use ? to avoid SQL Injection.
c = builder.query(getDb(), projection,
selection, prependToArray(selectionArgs, uriParts.itemId),
null, null, orderBy);
c.setNotificationUri(getContext().getContentResolver(),
Item.CONTENT_URI); //TODO: More precise?
break;
}
case MATCHER_ID_ITEM_NEXT:
c = queryItemNext(projection, selection, selectionArgs, orderBy);
if (c == null) {
Log.error("ItemsContentProvider.query(): c is null.");
} else {
final int count = c.getCount();
if (count < 1) {
//Immediately get some more from the REST server and then try again.
//Get one synchronously, for now.
//This is a (small) duplicate of what SyncProvider does.
//TODO: Find a way to ask the SyncProvider to do exactly this and no more,
//and block until it has finished?
// Try this more than once, in case we are using multiple groups
// and some of the groups are no longer available from the server.
// This doesn't guarantee that we will try all groups, but it makes
// it much more likely that we will hit one that works.
boolean found = false;
for(int i = 0; i < 3; i++) {
List<ZooniverseClient.Subject> subjects = null;
try {
subjects = mZooniverseClient.requestMoreItemsSync(1);
} catch (final HttpUtils.NoNetworkException e) {
//Return the empty cursor,
//and let the caller guess at the cause.
//If we let the exception be thrown by this query() method then
//it will causes an app crash in AsyncTask.done(), as used by CursorLoader.
//TODO: Find a better way to respond to errors when using CursorLoader?
Log.error("ItemsContentProvider.query(): next: requestMoreItemsSync threw NoNetworkException.");
} catch (final ZooniverseClient.RequestMoreItemsException e) {
//Return the empty cursor,
//and let the caller guess at the cause.
//If we let the exception be thrown by this query() method then
//it will causes an app crash in AsyncTask.done(), as used by CursorLoader.
//TODO: Find a better way to respond to errors when using CursorLoader?
Log.error("ItemsContentProvider.query(): next: requestMoreItemsSync threw RequestMoreItemsException.");
}
if ((subjects == null) || (subjects.isEmpty())) {
Log.error("ItemsContentProvider.query(): next: requestMoreItemsSync returned no items.");
} else {
found = true;
if(!mSubjectAdder.addSubjects(subjects, false /* not async - we need it immediately. */)) {
found = false; //Something went wrong when getting the first item.
}
break;
}
}
if (!found) {
//Return the empty cursor,
//and let the caller guess at the cause.
Log.error("ItemsContentProvider.query(): next: requestMoreItemsSync returned no items even after multiple attempts");
return c;
}
//Close the cursor and try again, now that we expect to succeed:
c.close();
c = queryItemNext(projection, selection, selectionArgs, orderBy);
}
c.setNotificationUri(getContext().getContentResolver(),
Item.CONTENT_URI); //TODO: More precise?
}
//Make sure we have enough soon enough
//by getting the rest asynchronously:
requestSync();
break;
case MATCHER_ID_FILE:
// query the database for a specific file:
// The caller will then use the _data value (the normal filesystem URI of a file).
final long fileId = ContentUris.parseId(uri);
//Prepend our ID=? argument to the selection arguments.
//This lets us use the ? syntax to avoid SQL injection
c = getDb().query(DatabaseHelper.TABLE_NAME_FILES, projection,
prependIdToSelection(selection),
prependToArray(selectionArgs, fileId), null, null, orderBy
);
c.setNotificationUri(getContext().getContentResolver(),
Item.FILE_URI); //TODO: More precise?
break;
case MATCHER_ID_CLASSIFICATION_ANSWERS: {
// query the database for all items:
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_CLASSIFICATION_ANSWERS);
builder.setProjectionMap(sClassificationAnswersProjectionMap);
c = builder.query(getDb(), projection,
selection, selectionArgs,
null, null, orderBy);
c.setNotificationUri(getContext().getContentResolver(),
ClassificationAnswer.CONTENT_URI);
break;
}
case MATCHER_ID_CLASSIFICATION_ANSWER: {
// query the database for a specific item:
final UriParts uriParts = parseContentUri(uri);
//Prepend our ID=? argument to the selection arguments.
//This lets us use the ? syntax to avoid SQL injection
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_CLASSIFICATION_ANSWERS);
builder.setProjectionMap(sClassificationAnswersProjectionMap);
builder.appendWhere(BaseColumns._ID + " = ?"); //We use ? to avoid SQL Injection.
c = builder.query(getDb(), projection,
selection, prependToArray(selectionArgs, uriParts.itemId),
null, null, orderBy);
c.setNotificationUri(getContext().getContentResolver(),
ClassificationAnswer.CONTENT_URI); //TODO: More precise?
break;
}
case MATCHER_ID_CLASSIFICATION_CHECKBOXES: {
// query the database for all items:
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_CLASSIFICATION_CHECKBOXES);
builder.setProjectionMap(sClassificationCheckboxesProjectionMap);
c = builder.query(getDb(), projection,
selection, selectionArgs,
null, null, orderBy);
c.setNotificationUri(getContext().getContentResolver(),
ClassificationCheckbox.CONTENT_URI);
break;
}
case MATCHER_ID_CLASSIFICATION_CHECKBOX:
// query the database for a specific item:
final UriParts uriParts = parseContentUri(uri);
//Prepend our ID=? argument to the selection arguments.
//This lets us use the ? syntax to avoid SQL injection
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_CLASSIFICATION_CHECKBOXES);
builder.setProjectionMap(sClassificationCheckboxesProjectionMap);
builder.appendWhere(BaseColumns._ID + " = ?"); //We use ? to avoid SQL Injection.
c = builder.query(getDb(), projection,
selection, prependToArray(selectionArgs, uriParts.itemId),
null, null, orderBy);
c.setNotificationUri(getContext().getContentResolver(),
ClassificationCheckbox.CONTENT_URI); //TODO: More precise?
break;
default:
//This could be because of an invalid -1 ID in the # position.
throw new IllegalArgumentException("unsupported uri: " + uri);
}
//TODO: Can we avoid passing a Sqlite cursor up as a ContentResolver cursor?
return c;
}
private Cursor queryItemNext(final String[] projection, final String selection, final String[] selectionArgs, final String orderBy) {
// query the database for a single item that is not yet done:
//Prepend our ID=? argument to the selection arguments.
//This lets us use the ? syntax to avoid SQL injection
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_ITEMS);
builder.setProjectionMap(sItemsProjectionMap);
builder.appendWhere(WHERE_CLAUSE_NOT_DONE);
//Default to the order of creation,
//so we are more likely to get the first record that was created synchronously
//so we could be sure that it was fully loaded.
String orderByToUse = orderBy;
if (orderBy == null || orderBy.isEmpty()) {
orderByToUse = DatabaseHelper.ItemsDbColumns._ID + " ASC";
}
return builder.query(getDb(), projection,
selection, selectionArgs,
null, null, orderByToUse, "1");
}
private static String[] prependToArray(final String[] selectionArgs, final long value) {
return prependToArray(selectionArgs, Double.toString(value));
}
private static String[] prependToArray(final String[] array, final String value) {
//Handle array being null:
if (array == null) {
final String[] result = new String[1];
result[0] = value;
return result;
}
final int arrayLength = array.length;
final String[] result = new String[arrayLength + 1];
result[0] = value;
if (arrayLength > 0) {
System.arraycopy(array, 0, result, 1, result.length);
}
return result;
}
private static UriParts parseContentUri(final Uri uri) {
final UriParts result = new UriParts();
//ContentUris.parseId(uri) gets the first ID, not the last.
//final long userId = ContentUris.parseId(uri);
final List<String> uriParts = uri.getPathSegments();
final int size = uriParts.size();
if (size < 2) {
Log.error("The URI did not have the expected number of parts.");
}
//Note: The UriMatcher will not even match the URI if this id (#) is -1
//so we will never reach this code then:
result.itemId = uriParts.get(1);
return result;
}
@Override
public int update(@NonNull final Uri uri, final ContentValues values, final String selection,
final String[] selectionArgs) {
final int affected;
// Note: We map the values' columns names to the internal database columns names.
// Strangely, I can't find any example code, or open source code, that bothers to do this,
// though examples for query() generally do.
// Maybe they don't do it because it's so awkward. murrayc.
// But if we don't do this then we are leaking the internal database structure out as our API.
switch (sUriMatcher.match(uri)) {
case MATCHER_ID_ITEMS:
affected = updateMappedValues(DatabaseHelper.TABLE_NAME_ITEMS, values, sItemsProjectionMap,
selection, selectionArgs);
requestSync();
break;
case MATCHER_ID_ITEM: {
final UriParts uriParts = parseContentUri(uri);
//Prepend our ID=? argument to the selection arguments.
//This lets us use the ? syntax to avoid SQL injection
affected = updateMappedValues(DatabaseHelper.TABLE_NAME_ITEMS, values, sItemsProjectionMap,
prependIdToSelection(selection),
prependToArray(selectionArgs, uriParts.itemId));
requestSync();
break;
}
case MATCHER_ID_CLASSIFICATION_ANSWERS:
affected = updateMappedValues(DatabaseHelper.TABLE_NAME_CLASSIFICATION_ANSWERS,
values, sClassificationAnswersProjectionMap,
selection, selectionArgs);
break;
case MATCHER_ID_CLASSIFICATION_ANSWER: {
final UriParts uriParts = parseContentUri(uri);
//Prepend our ID=? argument to the selection arguments.
//This lets us use the ? syntax to avoid SQL injection
affected = updateMappedValues(DatabaseHelper.TABLE_NAME_CLASSIFICATION_ANSWERS,
values, sClassificationAnswersProjectionMap,
prependIdToSelection(selection),
prependToArray(selectionArgs, uriParts.itemId)
);
break;
}
case MATCHER_ID_CLASSIFICATION_CHECKBOXES:
affected = updateMappedValues(DatabaseHelper.TABLE_NAME_CLASSIFICATION_CHECKBOXES,
values, sClassificationCheckboxesProjectionMap,
selection, selectionArgs);
break;
case MATCHER_ID_CLASSIFICATION_CHECKBOX:
final UriParts uriParts = parseContentUri(uri);
//Prepend our ID=? argument to the selection arguments.
//This lets us use the ? syntax to avoid SQL injection
affected = updateMappedValues(DatabaseHelper.TABLE_NAME_CLASSIFICATION_CHECKBOXES,
values, sClassificationCheckboxesProjectionMap,
prependIdToSelection(selection),
prependToArray(selectionArgs, uriParts.itemId)
);
break;
default:
throw new IllegalArgumentException("Unknown URI " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return affected;
}
private static String prependIdToSelection(final String selection) {
return BaseColumns._ID + " = ?"
+ (!TextUtils.isEmpty(selection) ?
" AND (" + selection + ')' : "");
}
/**
* Get the database.
*
* We don't need to close() this SQLiteDatabase.
* See http://stackoverflow.com/a/12715032/1123654
*
* @return
*/
private SQLiteDatabase getDb() {
return mOpenDbHelper.getWritableDatabase();
}
private void removeItem(final String itemId) {
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_ITEMS);
builder.appendWhere(Item.Columns._ID + " = ?"); //We use ? to avoid SQL Injection.
final String[] selectionArgs = {itemId}; //TODO: locale-independent?
final Cursor c = builder.query(getDb(), PROJECTION_REMOVE_ITEM,
null, selectionArgs,
null, null, null);
final String[] imageUris = new String[3];
if (c.moveToFirst()) {
imageUris[0] = c.getString(0);
imageUris[1] = c.getString(1);
imageUris[2] = c.getString(2);
}
c.close();
removeItem(itemId, imageUris);
}
private void removeItem(final String itemId, final String[] imageUris) {
final SQLiteDatabase db = getDb();
final SQLiteQueryBuilder builder = new SQLiteQueryBuilder();
builder.setTables(DatabaseHelper.TABLE_NAME_FILES);
builder.appendWhere(DatabaseHelper.FilesDbColumns._ID + " = ?");
//Get the cached image files, delete them, and forget them:
for (final String contentUri : imageUris) {
if (contentUri == null) {
continue;
}
//Get the real local URI for the file:
final Uri uri = Uri.parse(contentUri);
final long fileId = ContentUris.parseId(uri);
final String strFileId = Double.toString(fileId); //TODO: Is this locale-independent?
final String[] selectionArgs = {strFileId};
final Cursor c = builder.query(db, PROJECTION_FILES_FILE_DATA,
null, selectionArgs,
null, null, null);
if (c.moveToFirst()) {
final String realFileUri = c.getString(0);
final File realFile = new File(realFileUri);
if(!realFile.delete()) {
Log.error("removeItem(): File.delete() failed.");
}
}
c.close();
final String[] whereArgs = {strFileId};
if (db.delete(DatabaseHelper.TABLE_NAME_FILES,
DatabaseHelper.FilesDbColumns._ID + " = ?",
whereArgs) <= 0) {
Log.error("removeItem(): Could not remove the file row.");
}
}
// Remove the related classification answers:
final String[] whereArgs = {itemId};
if (db.delete(DatabaseHelper.TABLE_NAME_CLASSIFICATION_ANSWERS,
DatabaseHelper.ClassificationAnswersDbColumns.ITEM_ID + " = ?",
whereArgs) <= 0) {
//This would only be an error worth reporting if we know that this item
//has a classification already.
//Log.error("removeItem(): Could not remove the classification answers rows.");
}
// Remove the related classification checkboxes:
// We don't check that at least 1 row was deleted,
// because there are not always answers with checkboxes.
if (db.delete(DatabaseHelper.TABLE_NAME_CLASSIFICATION_CHECKBOXES,
DatabaseHelper.ClassificationCheckboxesDbColumns.ITEM_ID + " = ?",
whereArgs) <= 0) {
//This would only be an error worth reporting if we know that this item
//has a classification already.
//Log.error("removeItem(): Could not remove the classification answers rows.");
}
//Delete the item:
if (db.delete(DatabaseHelper.TABLE_NAME_ITEMS,
Item.Columns._ID + " = ?",
whereArgs) <= 0) {
Log.error("removeItem(): No item rows were removed.");
}
}
/**
* Create Content URIs that point to local files, so we can download the remote files to those
* files as a cache.
*
* @param values
* @return
*/
private boolean createFileUrisForImages(final ContentValues values) throws IOException {
Uri fileUri = createFileUri();
if (fileUri == null) {
return false;
}
values.put(DatabaseHelper.ItemsDbColumns.LOCATION_STANDARD_URI, fileUri.toString());
fileUri = createFileUri();
if (fileUri == null) {
return false;
}
values.put(DatabaseHelper.ItemsDbColumns.LOCATION_THUMBNAIL_URI, fileUri.toString());
fileUri = createFileUri();
if (fileUri == null) {
return false;
}
values.put(DatabaseHelper.ItemsDbColumns.LOCATION_INVERTED_URI, fileUri.toString());
return true;
}
/**
* There are 2 tables: items and files.
* The items table has a uri field that specifies a record in the files tables.
* The files table has a (standard for openInput/OutputStream()) _data field that
* contains the URI of the file for the item.
* <p/>
* The location and creation of the SQLite database is left entirely up to the SQLiteOpenHelper
* class. We just store its name in the Document.
*/
private static class DatabaseHelper extends SQLiteOpenHelper {
//After the first official release, try to preserve data when changing this. See onUpgrade()
private static final int DATABASE_VERSION = 21;
private static final String DATABASE_NAME = "items.db";
private static final String TABLE_NAME_ITEMS = "items";
private static final String TABLE_NAME_FILES = "files";
//Each item row has many classification_answers rows.
private static final String TABLE_NAME_CLASSIFICATION_ANSWERS = "classification_answers";
//Each item row has some classification_checkboxes rows.
private static final String TABLE_NAME_CLASSIFICATION_CHECKBOXES = "classification_checkboxes";
private static final String DEFAULT_SORT_ORDER = Item.Columns._ID + " ASC";
DatabaseHelper(final Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(final SQLiteDatabase sqLiteDatabase) {
createTable(sqLiteDatabase);
}
@Override
public void onUpgrade(final SQLiteDatabase sqLiteDatabase,
final int oldv, final int newv) {
if (oldv != newv) {
switch(oldv) {
case 20: {
//Add the groupId field to the items:
try {
sqLiteDatabase.execSQL("ALTER TABLE " + TABLE_NAME_ITEMS + " ADD COLUMN "
+ ItemsDbColumns.GROUP_ID + " TEXT;");
} catch( final SQLiteException ex) {
Log.error("onUpgrade: ALTER TABLE ADD COLUMN failed", ex);
//Fall through to the default case to recreate the tables completely.
}
break;
}
default: {
dropTable(sqLiteDatabase, TABLE_NAME_ITEMS);
dropTable(sqLiteDatabase, TABLE_NAME_FILES);
dropTable(sqLiteDatabase, TABLE_NAME_CLASSIFICATION_ANSWERS);
dropTable(sqLiteDatabase, TABLE_NAME_CLASSIFICATION_CHECKBOXES);
createTable(sqLiteDatabase);
break;
}
}
}
}
private static void dropTable(final SQLiteDatabase sqLiteDatabase, final String tableName) {
sqLiteDatabase.execSQL("DROP TABLE IF EXISTS " +
tableName + ";");
}
private static void createTable(final SQLiteDatabase sqLiteDatabase) {
String qs = "CREATE TABLE " + TABLE_NAME_ITEMS + " (" +
BaseColumns._ID +
" INTEGER PRIMARY KEY AUTOINCREMENT, " +
ItemsDbColumns.DONE + " INTEGER DEFAULT 0, " +
ItemsDbColumns.UPLOADED + " INTEGER DEFAULT 0, " +
ItemsDbColumns.SUBJECT_ID + " TEXT, " +
ItemsDbColumns.ZOONIVERSE_ID + " TEXT, " +
ItemsDbColumns.GROUP_ID + " TEXT, " +
ItemsDbColumns.LOCATION_STANDARD_URI_REMOTE + " TEXT, " +
ItemsDbColumns.LOCATION_STANDARD_URI + " TEXT, " +
ItemsDbColumns.LOCATION_STANDARD_DOWNLOADED + " INTEGER DEFAULT 0, " +
ItemsDbColumns.LOCATION_THUMBNAIL_URI_REMOTE + " TEXT, " +
ItemsDbColumns.LOCATION_THUMBNAIL_URI + " TEXT, " +
ItemsDbColumns.LOCATION_THUMBNAIL_DOWNLOADED + " INTEGER DEFAULT 0, " +
ItemsDbColumns.LOCATION_INVERTED_URI_REMOTE + " TEXT, " +
ItemsDbColumns.LOCATION_INVERTED_URI + " TEXT, " +
ItemsDbColumns.LOCATION_INVERTED_DOWNLOADED + " INTEGER DEFAULT 0, " +
ItemsDbColumns.FAVORITE + " INTEGER DEFAULT 0, " +
ItemsDbColumns.DATETIME_DONE + " TEXT)";
sqLiteDatabase.execSQL(qs);
createIndex(sqLiteDatabase, TABLE_NAME_ITEMS, ItemsDbColumns.SUBJECT_ID);
createIndex(sqLiteDatabase, TABLE_NAME_ITEMS, ItemsDbColumns.UPLOADED);
createIndex(sqLiteDatabase, TABLE_NAME_ITEMS, ItemsDbColumns.DONE);
createIndex(sqLiteDatabase, TABLE_NAME_ITEMS, ItemsDbColumns.DATETIME_DONE);
createIndex(sqLiteDatabase, TABLE_NAME_ITEMS, ItemsDbColumns.LOCATION_STANDARD_DOWNLOADED);
createIndex(sqLiteDatabase, TABLE_NAME_ITEMS, ItemsDbColumns.LOCATION_THUMBNAIL_DOWNLOADED);
createIndex(sqLiteDatabase, TABLE_NAME_ITEMS, ItemsDbColumns.LOCATION_INVERTED_DOWNLOADED);
qs = "CREATE TABLE " + TABLE_NAME_FILES + " (" +
BaseColumns._ID +
" INTEGER PRIMARY KEY AUTOINCREMENT, " +
FilesDbColumns.FILE_DATA + " TEXT);";
sqLiteDatabase.execSQL(qs);
qs = "CREATE TABLE " + TABLE_NAME_CLASSIFICATION_ANSWERS + " (" +
BaseColumns._ID +
" INTEGER PRIMARY KEY AUTOINCREMENT, " +
ClassificationAnswersDbColumns.SEQUENCE + " INTEGER DEFAULT 0, " +
ClassificationAnswersDbColumns.ITEM_ID + " INTEGER, " +
ClassificationAnswersDbColumns.QUESTION_ID + " TEXT, " +
ClassificationAnswersDbColumns.ANSWER_ID + " TEXT)";
sqLiteDatabase.execSQL(qs);
createIndex(sqLiteDatabase, TABLE_NAME_CLASSIFICATION_ANSWERS, ClassificationAnswersDbColumns.ITEM_ID);
qs = "CREATE TABLE " + TABLE_NAME_CLASSIFICATION_CHECKBOXES + " (" +
BaseColumns._ID +
" INTEGER PRIMARY KEY AUTOINCREMENT, " +
ClassificationCheckboxesDbColumns.SEQUENCE + " INTEGER DEFAULT 0, " +
ClassificationCheckboxesDbColumns.ITEM_ID + " INTEGER, " +
ClassificationCheckboxesDbColumns.QUESTION_ID + " TEXT, " +
ClassificationCheckboxesDbColumns.CHECKBOX_ID + " TEXT)";
sqLiteDatabase.execSQL(qs);
createIndex(sqLiteDatabase, TABLE_NAME_CLASSIFICATION_CHECKBOXES, ClassificationCheckboxesDbColumns.ITEM_ID);
createIndex(sqLiteDatabase, TABLE_NAME_CLASSIFICATION_CHECKBOXES, ClassificationCheckboxesDbColumns.QUESTION_ID);
}
private static void createIndex(final SQLiteDatabase sqLiteDatabase, final String tableName, final String fieldName) {
final String qs = "CREATE INDEX " + tableName + "_" + fieldName + "_index" +
" ON " + tableName +
" ( " + fieldName + " )";
sqLiteDatabase.execSQL(qs);
}
private static class ItemsDbColumns implements BaseColumns {
//Specific to our app:
static final String DONE = "done"; //1 or 0. Whether the user has classified it already.
static final String UPLOADED = "uploaded"; //1 or 0. Whether its classification has been submitted.
//From the REST API:
static final String SUBJECT_ID = "subjectId";
static final String ZOONIVERSE_ID = "zooniverseId";
static final String GROUP_ID = "groupId";
static final String LOCATION_STANDARD_URI_REMOTE = "locationStandardUriRemote"; //The original file on the remote server.
static final String LOCATION_STANDARD_URI = "locationStandardUri"; //The content URI for a file in the files table.
static final String LOCATION_STANDARD_DOWNLOADED = "locationStandardDownloaded"; //1 or 0. Whether the file has finished downloading.
static final String LOCATION_THUMBNAIL_URI_REMOTE = "locationThumbnailUriRemote"; //The original file on the remote server.
static final String LOCATION_THUMBNAIL_URI = "locationThumbnailUri"; //The content URI for a file in the files table.
static final String LOCATION_THUMBNAIL_DOWNLOADED = "locationThumbnailDownloaded"; //1 or 0. Whether the file has finished downloading.
static final String LOCATION_INVERTED_URI_REMOTE = "locationInvertedUriRemote"; //The original file on the remote server.
static final String LOCATION_INVERTED_URI = "locationInvertedUri"; //The content URI for a file in the files table.
static final String LOCATION_INVERTED_DOWNLOADED = "locationInvertedDownloaded"; //1 or 0. Whether the file has finished downloading.
// static final String LOCATIONS_REQUESTED_DATETIME = "locationsRequestedDateTime"; //When we last tried to download the images. An ISO8601 string ("YYYY-MM-DD HH:MM:SS.SSS")
static final String FAVORITE = "favorite"; //1 or 0. Whether the user has marked this as a favorite.
static final String DATETIME_DONE = "dateTimeDone"; //An ISO8601 string ("YYYY-MM-DD HH:MM:SS.SSS").
}
private static class FilesDbColumns implements BaseColumns {
private static final String FILE_DATA = URI_PART_DATA; //The real URI
}
private static class ClassificationAnswersDbColumns implements BaseColumns {
private static final String ITEM_ID = "itemId";
private static final String SEQUENCE = "sequence";
private static final String QUESTION_ID = "questionId";
private static final String ANSWER_ID = "answerId";
}
private static class ClassificationCheckboxesDbColumns implements BaseColumns {
private static final String ITEM_ID = "itemId";
private static final String SEQUENCE = "sequence";
private static final String QUESTION_ID = "questionId";
private static final String CHECKBOX_ID = "checkboxId";
}
}
/** Ask the SyncAdapter to do its work.
* We call this when we think it's likely that some work is necessary.
*/
private static void requestSync() {
final Bundle extras = new Bundle();
extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true);
//Ask the framework to run our SyncAdapter.
//We call this far too often,
//but we trust the SyncAdapter system to not actually do the sync too often.
//That seems to work fine as long as the SyncAdapter is in its own process.
//See android:process=":sync" in AndroidManifest.xml
ContentResolver.requestSync(null, Item.AUTHORITY, extras);
}
private static class UriParts {
public String itemId = null;
}
}