Merge pull request #9 from brion/recentcats

Store and show recently-used categories list
This commit is contained in:
Brion Vibber 2013-04-25 10:28:34 -07:00
commit e0e7f056d2
8 changed files with 421 additions and 32 deletions

View file

@ -130,6 +130,13 @@
android:authorities="org.wikimedia.commons.modifications.contentprovider" android:authorities="org.wikimedia.commons.modifications.contentprovider"
android:exported="false"> android:exported="false">
</provider> </provider>
<provider
android:name=".category.CategoryContentProvider"
android:label="@string/provider_categories"
android:syncable="false"
android:authorities="org.wikimedia.commons.categories.contentprovider"
android:exported="false">
</provider>
</application> </application>
</manifest> </manifest>

View file

@ -5,28 +5,11 @@
<configuration> <configuration>
<option name="GEN_FOLDER_RELATIVE_PATH_APT" value="/target/generated-sources/r" /> <option name="GEN_FOLDER_RELATIVE_PATH_APT" value="/target/generated-sources/r" />
<option name="GEN_FOLDER_RELATIVE_PATH_AIDL" value="/target/generated-sources/aidl" /> <option name="GEN_FOLDER_RELATIVE_PATH_AIDL" value="/target/generated-sources/aidl" />
<option name="MANIFEST_FILE_RELATIVE_PATH" value="/AndroidManifest.xml" />
<option name="RES_FOLDER_RELATIVE_PATH" value="/res" />
<option name="ASSETS_FOLDER_RELATIVE_PATH" value="/assets" />
<option name="LIBS_FOLDER_RELATIVE_PATH" value="/src/main/native" /> <option name="LIBS_FOLDER_RELATIVE_PATH" value="/src/main/native" />
<option name="USE_CUSTOM_APK_RESOURCE_FOLDER" value="false" />
<option name="CUSTOM_APK_RESOURCE_FOLDER" value="/target/generated-sources/combined-resources/res" /> <option name="CUSTOM_APK_RESOURCE_FOLDER" value="/target/generated-sources/combined-resources/res" />
<option name="USE_CUSTOM_COMPILER_MANIFEST" value="false" />
<option name="CUSTOM_COMPILER_MANIFEST" value="" />
<option name="APK_PATH" value="/target/commons.apk" /> <option name="APK_PATH" value="/target/commons.apk" />
<option name="LIBRARY_PROJECT" value="false" />
<option name="RUN_PROCESS_RESOURCES_MAVEN_TASK" value="false" /> <option name="RUN_PROCESS_RESOURCES_MAVEN_TASK" value="false" />
<option name="GENERATE_UNSIGNED_APK" value="false" />
<option name="CUSTOM_DEBUG_KEYSTORE_PATH" value="" />
<option name="PACK_TEST_CODE" value="false" />
<option name="RUN_PROGUARD" value="false" />
<option name="PROGUARD_CFG_PATH" value="/proguard-project.txt" />
<resOverlayFolders>
<path>/res-overlay</path>
</resOverlayFolders>
<includeSystemProguardFile>true</includeSystemProguardFile>
<includeAssetsFromLibraries>true</includeAssetsFromLibraries> <includeAssetsFromLibraries>true</includeAssetsFromLibraries>
<additionalNativeLibs />
</configuration> </configuration>
</facet> </facet>
</component> </component>

View file

@ -80,4 +80,5 @@
<string name="about_privacy_policy"><a href="https://wikimediafoundation.org/wiki/Privacy_policy">Privacy policy</a></string> <string name="about_privacy_policy"><a href="https://wikimediafoundation.org/wiki/Privacy_policy">Privacy policy</a></string>
<string name="title_activity_about">About</string> <string name="title_activity_about">About</string>
<string name="menu_feedback">Send Feedback (via Email)</string> <string name="menu_feedback">Send Feedback (via Email)</string>
<string name="provider_categories">Recently used categories</string>
</resources> </resources>

View file

@ -1,11 +1,10 @@
package org.wikimedia.commons; package org.wikimedia.commons;
import android.app.Activity; import android.app.Activity;
import android.content.ContentProviderClient;
import android.content.Context; import android.content.Context;
import android.os.AsyncTask; import android.database.Cursor;
import android.os.Bundle; import android.os.*;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.Editable; import android.text.Editable;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.TextWatcher; import android.text.TextWatcher;
@ -18,10 +17,14 @@ import com.actionbarsherlock.view.Menu;
import com.actionbarsherlock.view.MenuItem; import com.actionbarsherlock.view.MenuItem;
import org.mediawiki.api.ApiResult; import org.mediawiki.api.ApiResult;
import org.mediawiki.api.MWApi; 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.IOException;
import java.io.Serializable; import java.io.Serializable;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.ScheduledThreadPoolExecutor;
@ -45,6 +48,10 @@ public class CategorizationFragment extends SherlockFragment{
private HashMap<String, ArrayList<String>> categoriesCache; private HashMap<String, ArrayList<String>> categoriesCache;
private ContentProviderClient client;
private final int SEARCH_CATS_LIMIT = 25;
public static class CategoryItem implements Parcelable { public static class CategoryItem implements Parcelable {
public String name; public String name;
public boolean selected; public boolean selected;
@ -114,16 +121,38 @@ public class CategorizationFragment extends SherlockFragment{
categoriesAdapter.setItems(items); categoriesAdapter.setItems(items);
categoriesAdapter.notifyDataSetInvalidated(); categoriesAdapter.notifyDataSetInvalidated();
categoriesSearchInProgress.setVisibility(View.GONE); categoriesSearchInProgress.setVisibility(View.GONE);
if(!TextUtils.isEmpty(filter) && categories.size() == 0) { if (categories.size() == 0) {
if(!TextUtils.isEmpty(filter)) {
categoriesNotFoundView.setText(getString(R.string.categories_not_found, filter)); categoriesNotFoundView.setText(getString(R.string.categories_not_found, filter));
categoriesNotFoundView.setVisibility(View.VISIBLE); categoriesNotFoundView.setVisibility(View.VISIBLE);
} }
} else {
// If we found recent cats, hide the skip message!
categoriesSkip.setVisibility(View.GONE);
}
} }
@Override @Override
protected ArrayList<String> doInBackground(Void... voids) { protected ArrayList<String> doInBackground(Void... voids) {
if(TextUtils.isEmpty(filter)) { if(TextUtils.isEmpty(filter)) {
return new ArrayList<String>(); ArrayList<String> items = new ArrayList<String>();
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)) { if(categoriesCache.containsKey(filter)) {
return categoriesCache.get(filter); return categoriesCache.get(filter);
@ -135,7 +164,7 @@ public class CategorizationFragment extends SherlockFragment{
result = api.action("query") result = api.action("query")
.param("list", "allcategories") .param("list", "allcategories")
.param("acprefix", filter) .param("acprefix", filter)
.param("aclimit", 25) .param("aclimit", SEARCH_CATS_LIMIT)
.get(); .get();
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
@ -211,6 +240,55 @@ public class CategorizationFragment extends SherlockFragment{
return count; 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<Void, Void, Void> {
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 @Override
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View rootView = inflater.inflate(R.layout.fragment_categorization, null); 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); CategoryItem item = (CategoryItem) adapterView.getAdapter().getItem(index);
item.selected = !item.selected; item.selected = !item.selected;
checkedView.setChecked(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) { public void onTextChanged(CharSequence charSequence, int i, int i2, int i3) {
if(lastUpdater != null) { startUpdatingCategoryList();
lastUpdater.cancel(true);
}
lastUpdater = new CategoriesUpdater();
Utils.executeAsyncTask(lastUpdater, executor);
} }
public void afterTextChanged(Editable editable) { public void afterTextChanged(Editable editable) {
@ -265,10 +342,19 @@ public class CategorizationFragment extends SherlockFragment{
} }
}); });
startUpdatingCategoryList();
return rootView; return rootView;
} }
private void startUpdatingCategoryList() {
if (lastUpdater != null) {
lastUpdater.cancel(true);
}
lastUpdater = new CategoriesUpdater();
Utils.executeAsyncTask(lastUpdater, executor);
}
@Override @Override
public void onCreateOptionsMenu(Menu menu, com.actionbarsherlock.view.MenuInflater inflater) { public void onCreateOptionsMenu(Menu menu, com.actionbarsherlock.view.MenuInflater inflater) {
menu.clear(); menu.clear();
@ -280,6 +366,13 @@ public class CategorizationFragment extends SherlockFragment{
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setHasOptionsMenu(true); setHasOptionsMenu(true);
getActivity().setTitle(R.string.categories_activity_title); getActivity().setTitle(R.string.categories_activity_title);
client = getActivity().getContentResolver().acquireContentProviderClient(CategoryContentProvider.AUTHORITY);
}
@Override
public void onDestroy() {
super.onDestroy();
client.release();
} }
@Override @Override

View file

@ -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);
}
}
}
}

View file

@ -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;
}
}

View file

@ -302,6 +302,11 @@ public class Contribution extends Media {
from++; from++;
return; return;
} }
if(from == 4) {
// Do nothing -- added Category
from++;
return;
}
} }
} }
} }

View file

@ -3,13 +3,14 @@ package org.wikimedia.commons.data;
import android.content.*; import android.content.*;
import android.database.sqlite.*; import android.database.sqlite.*;
import org.wikimedia.commons.category.Category;
import org.wikimedia.commons.contributions.*; import org.wikimedia.commons.contributions.*;
import org.wikimedia.commons.modifications.ModifierSequence; import org.wikimedia.commons.modifications.ModifierSequence;
public class DBOpenHelper extends SQLiteOpenHelper{ public class DBOpenHelper extends SQLiteOpenHelper{
private static final String DATABASE_NAME = "commons.db"; 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) { public DBOpenHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION); super(context, DATABASE_NAME, null, DATABASE_VERSION);
@ -19,11 +20,13 @@ public class DBOpenHelper extends SQLiteOpenHelper{
public void onCreate(SQLiteDatabase sqLiteDatabase) { public void onCreate(SQLiteDatabase sqLiteDatabase) {
Contribution.Table.onCreate(sqLiteDatabase); Contribution.Table.onCreate(sqLiteDatabase);
ModifierSequence.Table.onCreate(sqLiteDatabase); ModifierSequence.Table.onCreate(sqLiteDatabase);
Category.Table.onCreate(sqLiteDatabase);
} }
@Override @Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int from, int to) { public void onUpgrade(SQLiteDatabase sqLiteDatabase, int from, int to) {
Contribution.Table.onUpdate(sqLiteDatabase, from, to); Contribution.Table.onUpdate(sqLiteDatabase, from, to);
ModifierSequence.Table.onUpdate(sqLiteDatabase, from, to); ModifierSequence.Table.onUpdate(sqLiteDatabase, from, to);
Category.Table.onUpdate(sqLiteDatabase, from, to);
} }
} }