Initial cut of Modifications syncing

Provides one naive modifier (which blindly adds categories).
Provides a sync service & a content provider. Insert appropriate
items into the  content provider and wait for the sync to happen.

Sync currently likes to 'fail early' rather than recover.

Blank post upload activity also present, simply adds random category
to the page that was uploaded. Will need appropriate UI
This commit is contained in:
YuviPanda 2013-03-27 18:08:46 +05:30
parent 03277af6cc
commit 780af9d07d
17 changed files with 650 additions and 8 deletions

View file

@ -33,6 +33,9 @@
android:name=".auth.LoginActivity"
android:theme="@style/NoTitle" >
</activity>
<activity
android:name=".modifications.PostUploadActivity"
/>
<activity
android:name=".ShareActivity"
android:icon="@drawable/ic_launcher"
@ -96,7 +99,19 @@
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/syncadapter" />
android:resource="@xml/contributions_sync_adapter" />
</service>
<service
android:name=".modifications.ModificationsSyncService"
android:exported="true">
<intent-filter>
<action
android:name="android.content.SyncAdapter" />
</intent-filter>
<meta-data
android:name="android.content.SyncAdapter"
android:resource="@xml/modifications_sync_adapter" />
</service>
<provider
@ -106,6 +121,13 @@
android:authorities="org.wikimedia.commons.contributions.contentprovider"
android:exported="false">
</provider>
<provider
android:name=".modifications.ModificationsContentProvider"
android:label="@string/provider_modifications"
android:syncable="true"
android:authorities="org.wikimedia.commons.modifications.contentprovider"
android:exported="false">
</provider>
</application>
</manifest>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
</LinearLayout>

View file

@ -51,6 +51,7 @@
<string name="share_upload_button">Upload</string>
<string name="multiple_share_base_title">Name this set</string>
<string name="provider_modifications">Modifications</string>
<plurals name="contributions_subtitle">
<item quantity="zero">No uploads yet</item>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
android:contentAuthority="org.wikimedia.commons.modifications.contentprovider"
android:accountType="org.wikimedia.commons"
android:supportsUploading="true"
android:userVisible="true"
android:isAlwaysSyncable="true"
/>

View file

@ -14,6 +14,7 @@ import android.view.*;
import org.wikimedia.commons.contributions.*;
import org.wikimedia.commons.auth.*;
import org.wikimedia.commons.modifications.PostUploadActivity;
public class ShareActivity extends AuthenticatedActivity {
@ -64,11 +65,13 @@ public class ShareActivity extends AuthenticatedActivity {
@Override
protected void onPostExecute(Contribution contribution) {
super.onPostExecute(contribution);
Intent postUploadIntent = new Intent(ShareActivity.this, PostUploadActivity.class);
postUploadIntent.putExtra(PostUploadActivity.EXTRA_MEDIA_URI, contribution.getContentUri());
startActivity(postUploadIntent);
finish();
}
}
@Override
public void onBackPressed() {
super.onBackPressed();

View file

@ -16,6 +16,7 @@ import android.support.v4.app.NavUtils;
import org.wikimedia.commons.*;
import org.wikimedia.commons.EventLog;
import org.wikimedia.commons.contributions.*;
import org.wikimedia.commons.modifications.ModificationsContentProvider;
public class LoginActivity extends AccountAuthenticatorActivity {
@ -64,6 +65,7 @@ public class LoginActivity extends AccountAuthenticatorActivity {
}
// FIXME: If the user turns it off, it shouldn't be auto turned back on
ContentResolver.setSyncAutomatically(account, ContributionsContentProvider.AUTHORITY, true); // Enable sync by default!
ContentResolver.setSyncAutomatically(account, ModificationsContentProvider.AUTHORITY, true); // Enable sync by default!
context.finish();
} else {
int response;

View file

@ -297,8 +297,11 @@ public class Contribution extends Media {
onUpdate(db, from, to);
return;
}
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME);
onCreate(db);
if(from == 3) {
// Do nothing
from++;
return;
}
}
}
}

View file

@ -45,16 +45,27 @@ public class ContributionsContentProvider extends ContentProvider{
int uriType = uriMatcher.match(uri);
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Cursor cursor;
switch(uriType) {
case CONTRIBUTIONS:
cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
break;
case CONTRIBUTIONS_ID:
cursor = queryBuilder.query(db,
Contribution.Table.ALL_FIELDS,
"_id = ?",
new String[] { uri.getLastPathSegment() },
null,
null,
sortOrder
);
break;
default:
throw new IllegalArgumentException("Unknown URI" + uri);
}
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;

View file

@ -4,11 +4,12 @@ import android.content.*;
import android.database.sqlite.*;
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 = 3;
private static final int DATABASE_VERSION = 4;
public DBOpenHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
@ -17,10 +18,12 @@ public class DBOpenHelper extends SQLiteOpenHelper{
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
Contribution.Table.onCreate(sqLiteDatabase);
ModifierSequence.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);
}
}

View file

@ -0,0 +1,49 @@
package org.wikimedia.commons.modifications;
import android.os.Bundle;
import android.os.Parcel;
import android.text.TextUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.*;
public class CategoryModifier extends PageModifier {
public static String PARAM_CATEGORIES = "categories";
public static String MODIFIER_NAME = "CategoriesModifier";
public CategoryModifier(String... categories) {
super(MODIFIER_NAME);
JSONArray categoriesArray = new JSONArray();
for(String category: categories) {
categoriesArray.put(category);
}
try {
params.putOpt(PARAM_CATEGORIES, categoriesArray);
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
public CategoryModifier(JSONObject data) {
super(MODIFIER_NAME);
this.params = data;
}
@Override
public String doModification(String pageName, String pageContents) {
JSONArray categories;
categories = params.optJSONArray(PARAM_CATEGORIES);
StringBuffer categoriesString = new StringBuffer();
for(int i=0; i < categories.length(); i++) {
String category = categories.optString(i);
categoriesString.append("\n[[Category:").append(category).append("]]");
}
return pageContents + categoriesString.toString();
}
}

View file

@ -0,0 +1,160 @@
package org.wikimedia.commons.modifications;
import android.content.*;
import android.database.*;
import android.database.sqlite.*;
import android.net.*;
import android.text.*;
import android.util.*;
import org.wikimedia.commons.*;
import org.wikimedia.commons.data.*;
public class ModificationsContentProvider extends ContentProvider{
private static final int MODIFICATIONS = 1;
private static final int MODIFICATIONS_ID = 2;
public static final String AUTHORITY = "org.wikimedia.commons.modifications.contentprovider";
private static final String BASE_PATH = "modifications";
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, MODIFICATIONS);
uriMatcher.addURI(AUTHORITY, BASE_PATH + "/#", MODIFICATIONS_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(ModifierSequence.Table.TABLE_NAME);
int uriType = uriMatcher.match(uri);
switch(uriType) {
case MODIFICATIONS:
break;
default:
throw new IllegalArgumentException("Unknown URI" + uri);
}
SQLiteDatabase db = dbOpenHelper.getReadableDatabase();
Cursor cursor = queryBuilder.query(db, projection, selection, selectionArgs, null, null, sortOrder);
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 MODIFICATIONS:
id = sqlDB.insert(ModifierSequence.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) {
int uriType = uriMatcher.match(uri);
SQLiteDatabase sqlDB = dbOpenHelper.getWritableDatabase();
switch (uriType) {
case MODIFICATIONS_ID:
String id = uri.getLastPathSegment();
sqlDB.delete(ModifierSequence.Table.TABLE_NAME,
"_id = ?",
new String[] { id }
);
return 1;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
}
@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 MODIFICATIONS:
for(ContentValues value: values) {
Log.d("Commons", "Inserting! " + value.toString());
sqlDB.insert(ModifierSequence.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 MODIFICATIONS:
rowsUpdated = sqlDB.update(ModifierSequence.Table.TABLE_NAME,
contentValues,
selection,
selectionArgs);
break;
case MODIFICATIONS_ID:
int id = Integer.valueOf(uri.getLastPathSegment());
if (TextUtils.isEmpty(selection)) {
rowsUpdated = sqlDB.update(ModifierSequence.Table.TABLE_NAME,
contentValues,
ModifierSequence.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

@ -0,0 +1,138 @@
package org.wikimedia.commons.modifications;
import android.accounts.AccountManager;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.content.*;
import android.database.Cursor;
import android.os.RemoteException;
import android.text.TextUtils;
import android.util.Log;
import android.accounts.Account;
import android.os.Bundle;
import java.io.*;
import java.util.*;
import org.mediawiki.api.*;
import org.wikimedia.commons.Utils;
import org.wikimedia.commons.*;
import org.wikimedia.commons.contributions.Contribution;
import org.wikimedia.commons.contributions.ContributionsContentProvider;
public class ModificationsSyncAdapter extends AbstractThreadedSyncAdapter {
public ModificationsSyncAdapter(Context context, boolean autoInitialize) {
super(context, autoInitialize);
}
@Override
public void onPerformSync(Account account, Bundle bundle, String s, ContentProviderClient contentProviderClient, SyncResult syncResult) {
// This code is fraught with possibilities of race conditions, but lalalalala I can't hear you!
Cursor allModifications;
try {
allModifications = contentProviderClient.query(ModificationsContentProvider.BASE_URI, null, null, null, null);
} catch (RemoteException e) {
throw new RuntimeException(e);
}
// Exit early if nothing to do
if(allModifications == null || allModifications.getCount() == 0) {
Log.d("Commons", "No modifications to perform");
return;
}
CommonsApplication app = (CommonsApplication)getContext().getApplicationContext();
String authCookie;
try {
authCookie = AccountManager.get(app).blockingGetAuthToken(account, "", false);
} catch (OperationCanceledException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
} catch (AuthenticatorException e) {
throw new RuntimeException(e);
}
MWApi api = app.getApi();
api.setAuthCookie(authCookie);
String editToken;
ApiResult requestResult, responseResult;
try {
editToken = api.getEditToken();
} catch (IOException e) {
throw new RuntimeException(e);
}
allModifications.moveToFirst();
Log.d("Commons", "Found " + allModifications.getCount() + " modifications to execute");
ContentProviderClient contributionsClient = null;
try {
contributionsClient = getContext().getContentResolver().acquireContentProviderClient(ContributionsContentProvider.AUTHORITY);
while(!allModifications.isAfterLast()) {
ModifierSequence sequence = ModifierSequence.fromCursor(allModifications);
sequence.setContentProviderClient(contentProviderClient);
Contribution contrib;
Cursor contributionCursor;
try {
contributionCursor = contributionsClient.query(sequence.getMediaUri(), null, null, null, null);
} catch (RemoteException e) {
throw new RuntimeException(e);
}
contributionCursor.moveToFirst();
contrib = Contribution.fromCursor(contributionCursor);
if(contrib.getState() == Contribution.STATE_COMPLETED) {
try {
requestResult = api.action("query")
.param("prop", "revisions")
.param("rvprop", "timestamp|content")
.param("titles", contrib.getFilename())
.get();
} catch (IOException e) {
throw new RuntimeException(e);
}
Log.d("Commons", "Page content is " + Utils.getStringFromDOM(requestResult.getDocument()));
String pageContent = requestResult.getString("/api/query/pages/page/revisions/rev");
String processedPageContent = sequence.executeModifications(contrib.getFilename(), pageContent);
try {
responseResult = api.action("edit")
.param("title", contrib.getFilename())
.param("token", editToken)
.param("text", processedPageContent)
.post();
} catch (IOException e) {
throw new RuntimeException(e);
}
Log.d("Commons", "Response is" + Utils.getStringFromDOM(responseResult.getDocument()));
String result = responseResult.getString("/api/edit/@result");
if(!result.equals("Success")) {
throw new RuntimeException();
}
sequence.delete();
allModifications.moveToNext();
}
}
} finally {
if(contributionsClient != null) {
contributionsClient.release();
}
}
}
}

View file

@ -0,0 +1,26 @@
package org.wikimedia.commons.modifications;
import android.app.*;
import android.content.*;
import android.os.*;
public class ModificationsSyncService extends Service {
private static final Object sSyncAdapterLock = new Object();
private static ModificationsSyncAdapter sSyncAdapter = null;
@Override
public void onCreate() {
synchronized (sSyncAdapterLock) {
if (sSyncAdapter == null) {
sSyncAdapter = new ModificationsSyncAdapter(getApplicationContext(), true);
}
}
}
@Override
public IBinder onBind(Intent intent) {
return sSyncAdapter.getSyncAdapterBinder();
}
}

View file

@ -0,0 +1,142 @@
package org.wikimedia.commons.modifications;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.os.RemoteException;
import android.text.TextUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.wikimedia.commons.contributions.ContributionsContentProvider;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
public class ModifierSequence {
private Uri mediaUri;
private ArrayList<PageModifier> modifiers;
private Uri contentUri;
private ContentProviderClient client;
public ModifierSequence(Uri mediaUri) {
this.mediaUri = mediaUri;
modifiers = new ArrayList<PageModifier>();
}
public ModifierSequence(Uri mediaUri, JSONObject data) {
this(mediaUri);
JSONArray modifiersJSON = data.optJSONArray("modifiers");
for(int i=0; i< modifiersJSON.length(); i++) {
modifiers.add(PageModifier.fromJSON(modifiersJSON.optJSONObject(i)));
}
}
public Uri getMediaUri() {
return mediaUri;
}
public void queueModifier(PageModifier modifier) {
modifiers.add(modifier);
}
public String executeModifications(String pageName, String pageContents) {
for(PageModifier modifier: modifiers) {
pageContents = modifier.doModification(pageName, pageContents);
}
return pageContents;
}
public JSONObject toJSON() {
JSONObject data = new JSONObject();
try {
JSONArray modifiersJSON = new JSONArray();
for(PageModifier modifier: modifiers) {
modifiersJSON.put(modifier.toJSON());
}
data.put("modifiers", modifiersJSON);
return data;
} catch (JSONException e) {
throw new RuntimeException(e);
}
}
public ContentValues toContentValues() {
ContentValues cv = new ContentValues();
cv.put(Table.COLUMN_MEDIA_URI, mediaUri.toString());
cv.put(Table.COLUMN_DATA, toJSON().toString());
return cv;
}
public static ModifierSequence fromCursor(Cursor cursor) {
// Hardcoding column positions!
ModifierSequence ms = null;
try {
ms = new ModifierSequence(Uri.parse(cursor.getString(1)), new JSONObject(cursor.getString(2)));
} catch (JSONException e) {
throw new RuntimeException(e);
}
ms.contentUri = ModificationsContentProvider.uriForId(cursor.getInt(0));
return ms;
}
public void save() {
try {
if(contentUri == null) {
contentUri = client.insert(ModificationsContentProvider.BASE_URI, this.toContentValues());
} else {
client.update(contentUri, toContentValues(), null, null);
}
} catch(RemoteException e) {
throw new RuntimeException(e);
}
}
public void delete() {
try {
client.delete(contentUri, null, null);
} catch (RemoteException e) {
throw new RuntimeException(e);
}
}
public void setContentProviderClient(ContentProviderClient client) {
this.client = client;
}
public static class Table {
public static final String TABLE_NAME = "modifications";
public static final String COLUMN_ID = "_id";
public static final String COLUMN_MEDIA_URI = "mediauri";
public static final String COLUMN_DATA = "data";
// 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_MEDIA_URI,
COLUMN_DATA
};
private static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
+ "_id INTEGER PRIMARY KEY,"
+ "mediauri STRING,"
+ "data STRING"
+ ");";
public static void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_TABLE_STATEMENT);
}
public static void onUpdate(SQLiteDatabase db, int from, int to) {
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME);
onCreate(db);
}
}
}

View file

@ -0,0 +1,37 @@
package org.wikimedia.commons.modifications;
import android.os.Bundle;
import org.json.JSONException;
import org.json.JSONObject;
public abstract class PageModifier {
public static PageModifier fromJSON(JSONObject data) {
String name = data.optString("name");
if(name.equals(CategoryModifier.MODIFIER_NAME)) {
return new CategoryModifier(data.optJSONObject("data"));
}
return null;
}
protected String name;
protected JSONObject params;
protected PageModifier(String name) {
this.name = name;
params = new JSONObject();
}
public abstract String doModification(String pageName, String pageContents);
public JSONObject toJSON() {
JSONObject data = new JSONObject();
try {
data.putOpt("name", name);
data.put("data", params);
} catch (JSONException e) {
throw new RuntimeException(e);
}
return data;
}
}

View file

@ -0,0 +1,28 @@
package org.wikimedia.commons.modifications;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.net.Uri;
import android.os.Bundle;
import android.os.IBinder;
import org.wikimedia.commons.HandlerService;
import org.wikimedia.commons.R;
import org.wikimedia.commons.UploadService;
public class PostUploadActivity extends Activity {
public static String EXTRA_MEDIA_URI = "org.wikimedia.commons.modifications.PostUploadActivity.mediauri";
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_post_upload);
Uri mediaUri = getIntent().getParcelableExtra(EXTRA_MEDIA_URI);
ModifierSequence testSequence = new ModifierSequence(mediaUri);
testSequence.queueModifier(new CategoryModifier("Hello, World!"));
testSequence.setContentProviderClient(getContentResolver().acquireContentProviderClient(ModificationsContentProvider.AUTHORITY));
testSequence.save();
}
}