diff --git a/commons/AndroidManifest.xml b/commons/AndroidManifest.xml index c6317582c..c6b93aacc 100644 --- a/commons/AndroidManifest.xml +++ b/commons/AndroidManifest.xml @@ -130,6 +130,13 @@ android:authorities="org.wikimedia.commons.modifications.contentprovider" android:exported="false"> + + diff --git a/commons/commons.iml b/commons/commons.iml index 0fec16f50..55cc5b2fb 100644 --- a/commons/commons.iml +++ b/commons/commons.iml @@ -5,28 +5,11 @@ diff --git a/commons/res/values/strings.xml b/commons/res/values/strings.xml index db693cd1b..91ab3261d 100644 --- a/commons/res/values/strings.xml +++ b/commons/res/values/strings.xml @@ -80,4 +80,5 @@ Privacy policy About Send Feedback (via Email) + Recently used categories diff --git a/commons/src/main/java/org/wikimedia/commons/CategorizationFragment.java b/commons/src/main/java/org/wikimedia/commons/CategorizationFragment.java index 5e02701bb..a95172937 100644 --- a/commons/src/main/java/org/wikimedia/commons/CategorizationFragment.java +++ b/commons/src/main/java/org/wikimedia/commons/CategorizationFragment.java @@ -1,11 +1,10 @@ package org.wikimedia.commons; import android.app.Activity; +import android.content.ContentProviderClient; import android.content.Context; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.Parcel; -import android.os.Parcelable; +import android.database.Cursor; +import android.os.*; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; @@ -18,10 +17,14 @@ import com.actionbarsherlock.view.Menu; import com.actionbarsherlock.view.MenuItem; import org.mediawiki.api.ApiResult; import org.mediawiki.api.MWApi; +import org.wikimedia.commons.category.Category; +import org.wikimedia.commons.category.CategoryContentProvider; +import org.wikimedia.commons.contributions.Contribution; import java.io.IOException; import java.io.Serializable; import java.util.ArrayList; +import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.concurrent.ScheduledThreadPoolExecutor; @@ -45,6 +48,10 @@ public class CategorizationFragment extends SherlockFragment{ private HashMap> categoriesCache; + private ContentProviderClient client; + + private final int SEARCH_CATS_LIMIT = 25; + public static class CategoryItem implements Parcelable { public String name; public boolean selected; @@ -114,16 +121,38 @@ public class CategorizationFragment extends SherlockFragment{ categoriesAdapter.setItems(items); categoriesAdapter.notifyDataSetInvalidated(); categoriesSearchInProgress.setVisibility(View.GONE); - if(!TextUtils.isEmpty(filter) && categories.size() == 0) { - categoriesNotFoundView.setText(getString(R.string.categories_not_found, filter)); - categoriesNotFoundView.setVisibility(View.VISIBLE); + if (categories.size() == 0) { + if(!TextUtils.isEmpty(filter)) { + categoriesNotFoundView.setText(getString(R.string.categories_not_found, filter)); + categoriesNotFoundView.setVisibility(View.VISIBLE); + } + } else { + // If we found recent cats, hide the skip message! + categoriesSkip.setVisibility(View.GONE); } } @Override protected ArrayList doInBackground(Void... voids) { if(TextUtils.isEmpty(filter)) { - return new ArrayList(); + ArrayList items = new ArrayList(); + try { + Cursor cursor = client.query( + CategoryContentProvider.BASE_URI, + Category.Table.ALL_FIELDS, + null, + new String[]{}, + Category.Table.COLUMN_LAST_USED + " DESC"); + // fixme add a limit on the original query instead of falling out of the loop? + while (cursor.moveToNext() && cursor.getPosition() < SEARCH_CATS_LIMIT) { + Category cat = Category.fromCursor(cursor); + items.add(cat.getName()); + } + } catch (RemoteException e) { + // faaaail + throw new RuntimeException(e); + } + return items; } if(categoriesCache.containsKey(filter)) { return categoriesCache.get(filter); @@ -135,7 +164,7 @@ public class CategorizationFragment extends SherlockFragment{ result = api.action("query") .param("list", "allcategories") .param("acprefix", filter) - .param("aclimit", 25) + .param("aclimit", SEARCH_CATS_LIMIT) .get(); } catch (IOException e) { throw new RuntimeException(e); @@ -211,6 +240,55 @@ public class CategorizationFragment extends SherlockFragment{ return count; } + private Category lookupCategory(String name) { + try { + Cursor cursor = client.query( + CategoryContentProvider.BASE_URI, + Category.Table.ALL_FIELDS, + Category.Table.COLUMN_NAME + "=?", + new String[] {name}, + null); + if (cursor.moveToFirst()) { + Category cat = Category.fromCursor(cursor); + return cat; + } + } catch (RemoteException e) { + // This feels lazy, but to hell with checked exceptions. :) + throw new RuntimeException(e); + } + + // Newly used category... + Category cat = new Category(); + cat.setName(name); + cat.setLastUsed(new Date()); + cat.setTimesUsed(0); + return cat; + } + + private class CategoryCountUpdater extends AsyncTask { + + private String name; + + public CategoryCountUpdater(String name) { + this.name = name; + } + + @Override + protected Void doInBackground(Void... voids) { + Category cat = lookupCategory(name); + cat.incTimesUsed(); + + cat.setContentProviderClient(client); + cat.save(); + + return null; // Make the compiler happy. + } + } + + private void updateCategoryCount(String name) { + Utils.executeAsyncTask(new CategoryCountUpdater(name), executor); + } + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View rootView = inflater.inflate(R.layout.fragment_categorization, null); @@ -245,6 +323,9 @@ public class CategorizationFragment extends SherlockFragment{ CategoryItem item = (CategoryItem) adapterView.getAdapter().getItem(index); item.selected = !item.selected; checkedView.setChecked(item.selected); + if (item.selected) { + updateCategoryCount(item.name); + } } }); @@ -253,11 +334,7 @@ public class CategorizationFragment extends SherlockFragment{ } public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) { - if(lastUpdater != null) { - lastUpdater.cancel(true); - } - lastUpdater = new CategoriesUpdater(); - Utils.executeAsyncTask(lastUpdater, executor); + startUpdatingCategoryList(); } public void afterTextChanged(Editable editable) { @@ -265,10 +342,19 @@ public class CategorizationFragment extends SherlockFragment{ } }); + startUpdatingCategoryList(); return rootView; } + private void startUpdatingCategoryList() { + if (lastUpdater != null) { + lastUpdater.cancel(true); + } + lastUpdater = new CategoriesUpdater(); + Utils.executeAsyncTask(lastUpdater, executor); + } + @Override public void onCreateOptionsMenu(Menu menu, com.actionbarsherlock.view.MenuInflater inflater) { menu.clear(); @@ -280,6 +366,13 @@ public class CategorizationFragment extends SherlockFragment{ super.onCreate(savedInstanceState); setHasOptionsMenu(true); getActivity().setTitle(R.string.categories_activity_title); + client = getActivity().getContentResolver().acquireContentProviderClient(CategoryContentProvider.AUTHORITY); + } + + @Override + public void onDestroy() { + super.onDestroy(); + client.release(); } @Override diff --git a/commons/src/main/java/org/wikimedia/commons/category/Category.java b/commons/src/main/java/org/wikimedia/commons/category/Category.java new file mode 100644 index 000000000..ae2045867 --- /dev/null +++ b/commons/src/main/java/org/wikimedia/commons/category/Category.java @@ -0,0 +1,140 @@ +package org.wikimedia.commons.category; + +import android.content.ContentProviderClient; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.RemoteException; +import android.text.TextUtils; +import org.wikimedia.commons.contributions.ContributionsContentProvider; + +import java.util.Date; + +public class Category { + private ContentProviderClient client; + private Uri contentUri; + + private String name; + private Date lastUsed; + private int timesUsed; + + // Getters/setters + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Date getLastUsed() { + // warning: Date objects are mutable. + return (Date)lastUsed.clone(); + } + + public void setLastUsed(Date lastUsed) { + // warning: Date objects are mutable. + this.lastUsed = (Date)lastUsed.clone(); + } + + public void touch() { + lastUsed = new Date(); + } + + public int getTimesUsed() { + return timesUsed; + } + + public void setTimesUsed(int timesUsed) { + this.timesUsed = timesUsed; + } + + public void incTimesUsed() { + timesUsed++; + touch(); + } + + // Database/content-provider stuff + public void setContentProviderClient(ContentProviderClient client) { + this.client = client; + } + + public void save() { + try { + if(contentUri == null) { + contentUri = client.insert(CategoryContentProvider.BASE_URI, this.toContentValues()); + } else { + client.update(contentUri, toContentValues(), null, null); + } + } catch(RemoteException e) { + throw new RuntimeException(e); + } + } + + public ContentValues toContentValues() { + ContentValues cv = new ContentValues(); + cv.put(Table.COLUMN_NAME, getName()); + cv.put(Table.COLUMN_LAST_USED, getLastUsed().getTime()); + cv.put(Table.COLUMN_TIMES_USED, getTimesUsed()); + return cv; + } + + public static Category fromCursor(Cursor cursor) { + // Hardcoding column positions! + Category c = new Category(); + c.contentUri = CategoryContentProvider.uriForId(cursor.getInt(0)); + c.name = cursor.getString(1); + c.lastUsed = new Date(cursor.getLong(2)); + c.timesUsed = cursor.getInt(3); + return c; + } + + public static class Table { + public static final String TABLE_NAME = "categories"; + + public static final String COLUMN_ID = "_id"; + public static final String COLUMN_NAME = "name"; + public static final String COLUMN_LAST_USED = "last_used"; + public static final String COLUMN_TIMES_USED = "times_used"; + + // NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES. + public static final String[] ALL_FIELDS = { + COLUMN_ID, + COLUMN_NAME, + COLUMN_LAST_USED, + COLUMN_TIMES_USED + }; + + + private static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " (" + + COLUMN_ID + " INTEGER PRIMARY KEY," + + COLUMN_NAME + " STRING," + + COLUMN_LAST_USED + " INTEGER," + + COLUMN_TIMES_USED + " INTEGER" + + ");"; + + + public static void onCreate(SQLiteDatabase db) { + db.execSQL(CREATE_TABLE_STATEMENT); + } + + public static void onUpdate(SQLiteDatabase db, int from, int to) { + if(from == to) { + return; + } + if(from < 4) { + // doesn't exist yet + from++; + onUpdate(db, from, to); + return; + } + if(from == 4) { + // table added in version 5 + onCreate(db); + } + } + } +} diff --git a/commons/src/main/java/org/wikimedia/commons/category/CategoryContentProvider.java b/commons/src/main/java/org/wikimedia/commons/category/CategoryContentProvider.java new file mode 100644 index 000000000..fa1f86c36 --- /dev/null +++ b/commons/src/main/java/org/wikimedia/commons/category/CategoryContentProvider.java @@ -0,0 +1,157 @@ +package org.wikimedia.commons.category; + +import android.content.ContentProvider; +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.text.TextUtils; +import android.util.Log; +import org.wikimedia.commons.CommonsApplication; +import org.wikimedia.commons.data.DBOpenHelper; + +public class CategoryContentProvider extends ContentProvider { + + // For URI matcher + private static final int CATEGORIES = 1; + private static final int CATEGORIES_ID = 2; + + public static final String AUTHORITY = "org.wikimedia.commons.categories.contentprovider"; + private static final String BASE_PATH = "categories"; + + public static final Uri BASE_URI = Uri.parse("content://" + AUTHORITY + "/" + BASE_PATH); + + private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH); + static { + uriMatcher.addURI(AUTHORITY, BASE_PATH, CATEGORIES); + uriMatcher.addURI(AUTHORITY, BASE_PATH + "/#", CATEGORIES_ID); + } + + + public static Uri uriForId(int id) { + return Uri.parse(BASE_URI.toString() + "/" + id); + } + + private DBOpenHelper dbOpenHelper; + @Override + public boolean onCreate() { + dbOpenHelper = ((CommonsApplication)this.getContext().getApplicationContext()).getDbOpenHelper(); + return false; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + SQLiteQueryBuilder queryBuilder = new SQLiteQueryBuilder(); + queryBuilder.setTables(Category.Table.TABLE_NAME); + + int uriType = uriMatcher.match(uri); + + SQLiteDatabase db = dbOpenHelper.getReadableDatabase(); + Cursor cursor; + + switch(uriType) { + case CATEGORIES: + cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder); + break; + case CATEGORIES_ID: + cursor = queryBuilder.query(db, + Category.Table.ALL_FIELDS, + "_id = ?", + new String[] { uri.getLastPathSegment() }, + null, + null, + sortOrder + ); + break; + default: + throw new IllegalArgumentException("Unknown URI" + uri); + } + + cursor.setNotificationUri(getContext().getContentResolver(), uri); + + return cursor; + } + + @Override + public String getType(Uri uri) { + return null; + } + + @Override + public Uri insert(Uri uri, ContentValues contentValues) { + int uriType = uriMatcher.match(uri); + SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); + long id = 0; + switch (uriType) { + case CATEGORIES: + id = sqlDB.insert(Category.Table.TABLE_NAME, null, contentValues); + break; + default: + throw new IllegalArgumentException("Unknown URI: " + uri); + } + getContext().getContentResolver().notifyChange(uri, null); + return Uri.parse(BASE_URI + "/" + id); + } + + @Override + public int delete(Uri uri, String s, String[] strings) { + return 0; + } + + @Override + public int bulkInsert(Uri uri, ContentValues[] values) { + Log.d("Commons", "Hello, bulk insert!"); + int uriType = uriMatcher.match(uri); + SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); + sqlDB.beginTransaction(); + switch (uriType) { + case CATEGORIES: + for(ContentValues value: values) { + Log.d("Commons", "Inserting! " + value.toString()); + sqlDB.insert(Category.Table.TABLE_NAME, null, value); + } + break; + default: + throw new IllegalArgumentException("Unknown URI: " + uri); + } + sqlDB.setTransactionSuccessful(); + sqlDB.endTransaction(); + getContext().getContentResolver().notifyChange(uri, null); + return values.length; + } + + @Override + public int update(Uri uri, ContentValues contentValues, String selection, String[] selectionArgs) { + /* + SQL Injection warnings: First, note that we're not exposing this to the outside world (exported="false") + Even then, we should make sure to sanitize all user input appropriately. Input that passes through ContentValues + should be fine. So only issues are those that pass in via concating. + + In here, the only concat created argument is for id. It is cast to an int, and will error out otherwise. + */ + int uriType = uriMatcher.match(uri); + SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase(); + int rowsUpdated = 0; + switch (uriType) { + case CATEGORIES_ID: + int id = Integer.valueOf(uri.getLastPathSegment()); + + if (TextUtils.isEmpty(selection)) { + rowsUpdated = sqlDB.update(Category.Table.TABLE_NAME, + contentValues, + Category.Table.COLUMN_ID + " = ?", + new String[] { String.valueOf(id) } ); + } else { + throw new IllegalArgumentException("Parameter `selection` should be empty when updating an ID"); + } + break; + default: + throw new IllegalArgumentException("Unknown URI: " + uri + " with type " + uriType); + } + getContext().getContentResolver().notifyChange(uri, null); + return rowsUpdated; + } +} + diff --git a/commons/src/main/java/org/wikimedia/commons/contributions/Contribution.java b/commons/src/main/java/org/wikimedia/commons/contributions/Contribution.java index 81892ac48..ece2719b6 100644 --- a/commons/src/main/java/org/wikimedia/commons/contributions/Contribution.java +++ b/commons/src/main/java/org/wikimedia/commons/contributions/Contribution.java @@ -302,6 +302,11 @@ public class Contribution extends Media { from++; return; } + if(from == 4) { + // Do nothing -- added Category + from++; + return; + } } } } diff --git a/commons/src/main/java/org/wikimedia/commons/data/DBOpenHelper.java b/commons/src/main/java/org/wikimedia/commons/data/DBOpenHelper.java index 011baa429..4430ac59a 100644 --- a/commons/src/main/java/org/wikimedia/commons/data/DBOpenHelper.java +++ b/commons/src/main/java/org/wikimedia/commons/data/DBOpenHelper.java @@ -3,13 +3,14 @@ package org.wikimedia.commons.data; import android.content.*; import android.database.sqlite.*; +import org.wikimedia.commons.category.Category; import org.wikimedia.commons.contributions.*; import org.wikimedia.commons.modifications.ModifierSequence; public class DBOpenHelper extends SQLiteOpenHelper{ private static final String DATABASE_NAME = "commons.db"; - private static final int DATABASE_VERSION = 4; + private static final int DATABASE_VERSION = 5; public DBOpenHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); @@ -19,11 +20,13 @@ public class DBOpenHelper extends SQLiteOpenHelper{ public void onCreate(SQLiteDatabase sqLiteDatabase) { Contribution.Table.onCreate(sqLiteDatabase); ModifierSequence.Table.onCreate(sqLiteDatabase); + Category.Table.onCreate(sqLiteDatabase); } @Override public void onUpgrade(SQLiteDatabase sqLiteDatabase, int from, int to) { Contribution.Table.onUpdate(sqLiteDatabase, from, to); ModifierSequence.Table.onUpdate(sqLiteDatabase, from, to); + Category.Table.onUpdate(sqLiteDatabase, from, to); } }