/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.gecko.sync.repositories.android;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import org.mozilla.gecko.db.BrowserContract.DeletedFormHistory;
import org.mozilla.gecko.db.BrowserContract.FormHistory;
import org.mozilla.gecko.sync.Logger;
import org.mozilla.gecko.sync.repositories.InactiveSessionException;
import org.mozilla.gecko.sync.repositories.NoContentProviderException;
import org.mozilla.gecko.sync.repositories.NoStoreDelegateException;
import org.mozilla.gecko.sync.repositories.NullCursorException;
import org.mozilla.gecko.sync.repositories.RecordFilter;
import org.mozilla.gecko.sync.repositories.Repository;
import org.mozilla.gecko.sync.repositories.StoreTrackingRepositorySession;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionCreationDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFetchRecordsDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionGuidsSinceDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
import org.mozilla.gecko.sync.repositories.domain.FormHistoryRecord;
import org.mozilla.gecko.sync.repositories.domain.Record;
import android.content.ContentProviderClient;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.RemoteException;
public class FormHistoryRepositorySession extends
StoreTrackingRepositorySession {
public static String LOG_TAG = "FormHistoryRepoSess";
/**
* Number of records to insert in one batch.
*/
public static final int INSERT_ITEM_THRESHOLD = 200;
private static Uri FORM_HISTORY_CONTENT_URI = BrowserContractHelpers.FORM_HISTORY_CONTENT_URI;
private static Uri DELETED_FORM_HISTORY_CONTENT_URI = BrowserContractHelpers.DELETED_FORM_HISTORY_CONTENT_URI;
public static class FormHistoryRepository extends Repository {
@Override
public void createSession(RepositorySessionCreationDelegate delegate,
Context context) {
try {
final FormHistoryRepositorySession session = new FormHistoryRepositorySession(this, context);
delegate.onSessionCreated(session);
} catch (Exception e) {
delegate.onSessionCreateFailed(e);
}
}
}
protected final ContentProviderClient formsProvider;
protected final RepoUtils.QueryHelper regularHelper;
protected final RepoUtils.QueryHelper deletedHelper;
/**
* Acquire the content provider client.
* <p>
* The caller is responsible for releasing the client.
*
* @param context The application context.
* @return The <code>ContentProviderClient</code>.
* @throws NoContentProviderException
*/
public static ContentProviderClient acquireContentProvider(final Context context)
throws NoContentProviderException {
Uri uri = FormHistory.CONTENT_URI;
ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(uri);
if (client == null) {
throw new NoContentProviderException(uri);
}
return client;
}
protected void releaseProviders() {
try {
if (formsProvider != null) {
formsProvider.release();
}
} catch (Exception e) {
}
}
// Only used for testing.
public ContentProviderClient getFormsProvider() {
return formsProvider;
}
public FormHistoryRepositorySession(Repository repository, Context context)
throws NoContentProviderException {
super(repository);
formsProvider = acquireContentProvider(context);
regularHelper = new RepoUtils.QueryHelper(context, BrowserContractHelpers.FORM_HISTORY_CONTENT_URI, LOG_TAG);
deletedHelper = new RepoUtils.QueryHelper(context, BrowserContractHelpers.DELETED_FORM_HISTORY_CONTENT_URI, LOG_TAG);
}
@Override
public void abort() {
releaseProviders();
super.abort();
}
@Override
public void finish(final RepositorySessionFinishDelegate delegate)
throws InactiveSessionException {
releaseProviders();
super.finish(delegate);
}
protected static String[] GUID_COLUMNS = new String[] { FormHistory.GUID };
@Override
public void guidsSince(final long timestamp, final RepositorySessionGuidsSinceDelegate delegate) {
Runnable command = new Runnable() {
public void run() {
if (!isActive()) {
delegate.onGuidsSinceFailed(new InactiveSessionException(null));
return;
}
ArrayList<String> guids = new ArrayList<String>();
final long sharedEnd = now();
Cursor cur = null;
try {
cur = regularHelper.safeQuery(formsProvider, "", GUID_COLUMNS, regularBetween(timestamp, sharedEnd), null, null);
cur.moveToFirst();
while (!cur.isAfterLast()) {
guids.add(cur.getString(0));
cur.moveToNext();
}
} catch (RemoteException e) {
delegate.onGuidsSinceFailed(e);
return;
} catch (NullCursorException e) {
delegate.onGuidsSinceFailed(e);
return;
} finally {
if (cur != null) {
cur.close();
}
}
try {
cur = deletedHelper.safeQuery(formsProvider, "", GUID_COLUMNS, deletedBetween(timestamp, sharedEnd), null, null);
cur.moveToFirst();
while (!cur.isAfterLast()) {
guids.add(cur.getString(0));
cur.moveToNext();
}
} catch (RemoteException e) {
delegate.onGuidsSinceFailed(e);
return;
} catch (NullCursorException e) {
delegate.onGuidsSinceFailed(e);
return;
} finally {
if (cur != null) {
cur.close();
}
}
String guidsArray[] = guids.toArray(new String[0]);
delegate.onGuidsSinceSucceeded(guidsArray);
}
};
delegateQueue.execute(command);
}
protected static FormHistoryRecord retrieveDuringFetch(final Cursor cursor) {
// A simple and efficient way to distinguish two tables.
if (cursor.getColumnCount() == BrowserContractHelpers.FormHistoryColumns.length) {
return formHistoryRecordFromCursor(cursor);
} else {
return deletedFormHistoryRecordFromCursor(cursor);
}
}
protected static FormHistoryRecord formHistoryRecordFromCursor(final Cursor cursor) {
String guid = RepoUtils.getStringFromCursor(cursor, FormHistory.GUID);
String collection = "forms";
FormHistoryRecord record = new FormHistoryRecord(guid, collection, 0, false);
record.fieldName = RepoUtils.getStringFromCursor(cursor, FormHistory.FIELD_NAME);
record.fieldValue = RepoUtils.getStringFromCursor(cursor, FormHistory.VALUE);
record.androidID = RepoUtils.getLongFromCursor(cursor, FormHistory.ID);
record.lastModified = RepoUtils.getLongFromCursor(cursor, FormHistory.FIRST_USED) / 1000; // Convert microseconds to milliseconds.
record.deleted = false;
record.log(LOG_TAG);
return record;
}
protected static FormHistoryRecord deletedFormHistoryRecordFromCursor(final Cursor cursor) {
String guid = RepoUtils.getStringFromCursor(cursor, DeletedFormHistory.GUID);
String collection = "forms";
FormHistoryRecord record = new FormHistoryRecord(guid, collection, 0, false);
record.guid = RepoUtils.getStringFromCursor(cursor, DeletedFormHistory.GUID);
record.androidID = RepoUtils.getLongFromCursor(cursor, DeletedFormHistory.ID);
record.lastModified = RepoUtils.getLongFromCursor(cursor, DeletedFormHistory.TIME_DELETED);
record.deleted = true;
record.log(LOG_TAG);
return record;
}
protected static void fetchFromCursor(final Cursor cursor, final RecordFilter filter, final RepositorySessionFetchRecordsDelegate delegate)
throws NullCursorException {
Logger.debug(LOG_TAG, "Fetch from cursor");
if (cursor == null) {
throw new NullCursorException(null);
}
try {
if (!cursor.moveToFirst()) {
return;
}
while (!cursor.isAfterLast()) {
Record r = retrieveDuringFetch(cursor);
if (r != null) {
if (filter == null || !filter.excludeRecord(r)) {
Logger.trace(LOG_TAG, "Processing record " + r.guid);
delegate.onFetchedRecord(r);
} else {
Logger.debug(LOG_TAG, "Skipping filtered record " + r.guid);
}
}
cursor.moveToNext();
}
} finally {
Logger.trace(LOG_TAG, "Closing cursor after fetch.");
cursor.close();
}
}
protected void fetchHelper(final RepositorySessionFetchRecordsDelegate delegate, final long end, final List<Callable<Cursor>> cursorCallables) {
if (this.storeTracker == null) {
throw new IllegalStateException("Store tracker not yet initialized!");
}
final RecordFilter filter = this.storeTracker.getFilter();
Runnable command = new Runnable() {
@Override
public void run() {
if (!isActive()) {
delegate.onFetchFailed(new InactiveSessionException(null), null);
return;
}
for (Callable<Cursor> cursorCallable : cursorCallables) {
Cursor cursor = null;
try {
cursor = cursorCallable.call();
fetchFromCursor(cursor, filter, delegate); // Closes cursor.
} catch (Exception e) {
Logger.warn(LOG_TAG, "Exception during fetchHelper", e);
delegate.onFetchFailed(e, null);
return;
}
}
delegate.onFetchCompleted(end);
}
};
delegateQueue.execute(command);
}
protected static String regularBetween(long start, long end) {
return FormHistory.FIRST_USED + " >= " + Long.toString(1000 * start) + " AND " +
FormHistory.FIRST_USED + " <= " + Long.toString(1000 * end); // Microseconds.
}
protected static String deletedBetween(long start, long end) {
return DeletedFormHistory.TIME_DELETED + " >= " + Long.toString(start) + " AND " +
DeletedFormHistory.TIME_DELETED + " <= " + Long.toString(end); // Milliseconds.
}
@Override
public void fetchSince(final long timestamp, final RepositorySessionFetchRecordsDelegate delegate) {
Logger.trace(LOG_TAG, "Running fetchSince(" + timestamp + ").");
/*
* We need to be careful about the timestamp we complete the fetch with. If
* the first cursor Callable takes a year, then the second could return
* records long after the first was kicked off. To protect against this, we
* set an end point and bound our search.
*/
final long sharedEnd = now();
Callable<Cursor> regularCallable = new Callable<Cursor>() {
@Override
public Cursor call() throws Exception {
return regularHelper.safeQuery(formsProvider, ".fetchSince(regular)", null, regularBetween(timestamp, sharedEnd), null, null);
}
};
Callable<Cursor> deletedCallable = new Callable<Cursor>() {
@Override
public Cursor call() throws Exception {
return deletedHelper.safeQuery(formsProvider, ".fetchSince(deleted)", null, deletedBetween(timestamp, sharedEnd), null, null);
}
};
ArrayList<Callable<Cursor>> callableCursors = new ArrayList<Callable<Cursor>>();
callableCursors.add(regularCallable);
callableCursors.add(deletedCallable);
fetchHelper(delegate, sharedEnd, callableCursors);
}
@Override
public void fetchAll(RepositorySessionFetchRecordsDelegate delegate) {
Logger.trace(LOG_TAG, "Running fetchAll.");
fetchSince(0, delegate);
}
@Override
public void fetch(final String[] guids, final RepositorySessionFetchRecordsDelegate delegate) {
Logger.trace(LOG_TAG, "Running fetch.");
final long sharedEnd = now();
final String where = RepoUtils.computeSQLInClause(guids.length, FormHistory.GUID);
Callable<Cursor> regularCallable = new Callable<Cursor>() {
@Override
public Cursor call() throws Exception {
String regularWhere = where + " AND " + FormHistory.FIRST_USED + " <= " + Long.toString(1000 * sharedEnd); // Microseconds.
return regularHelper.safeQuery(formsProvider, ".fetch(regular)", null, regularWhere, guids, null);
}
};
Callable<Cursor> deletedCallable = new Callable<Cursor>() {
@Override
public Cursor call() throws Exception {
String deletedWhere = where + " AND " + DeletedFormHistory.TIME_DELETED + " <= " + Long.toString(sharedEnd); // Milliseconds.
return deletedHelper.safeQuery(formsProvider, ".fetch(deleted)", null, deletedWhere, guids, null);
}
};
ArrayList<Callable<Cursor>> callableCursors = new ArrayList<Callable<Cursor>>();
callableCursors.add(regularCallable);
callableCursors.add(deletedCallable);
fetchHelper(delegate, sharedEnd, callableCursors);
}
protected static final String GUID_IS = FormHistory.GUID + " = ?";
protected Record findExistingRecordByGuid(String guid)
throws RemoteException, NullCursorException {
Cursor cursor = null;
try {
cursor = regularHelper.safeQuery(formsProvider, ".findExistingRecordByGuid(regular)",
null, GUID_IS, new String[] { guid }, null);
if (cursor.moveToFirst()) {
return formHistoryRecordFromCursor(cursor);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
try {
cursor = deletedHelper.safeQuery(formsProvider, ".findExistingRecordByGuid(deleted)",
null, GUID_IS, new String[] { guid }, null);
if (cursor.moveToFirst()) {
return deletedFormHistoryRecordFromCursor(cursor);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
return null;
}
protected Record findExistingRecordByPayload(Record rawRecord)
throws RemoteException, NullCursorException {
if (!rawRecord.deleted) {
FormHistoryRecord record = (FormHistoryRecord) rawRecord;
Cursor cursor = null;
try {
String where = FormHistory.FIELD_NAME + " = ? AND " + FormHistory.VALUE + " = ?";
cursor = regularHelper.safeQuery(formsProvider, ".findExistingRecordByPayload",
null, where, new String[] { record.fieldName, record.fieldValue }, null);
if (cursor.moveToFirst()) {
return formHistoryRecordFromCursor(cursor);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
}
return null;
}
/**
* Called when a record with locally known GUID has been reported deleted by
* the server.
* <p>
* We purge the record's GUID from the regular and deleted tables.
*
* @param existingRecord
* The local <code>Record</code> to replace.
* @throws RemoteException
*/
protected void deleteExistingRecord(Record existingRecord) throws RemoteException {
if (existingRecord.deleted) {
formsProvider.delete(DELETED_FORM_HISTORY_CONTENT_URI, GUID_IS, new String[] { existingRecord.guid });
return;
}
formsProvider.delete(FORM_HISTORY_CONTENT_URI, GUID_IS, new String[] { existingRecord.guid });
}
protected static ContentValues contentValuesForRegularRecord(Record rawRecord) {
if (rawRecord.deleted) {
throw new IllegalArgumentException("Deleted record passed to insertNewRegularRecord.");
}
FormHistoryRecord record = (FormHistoryRecord) rawRecord;
ContentValues cv = new ContentValues();
cv.put(FormHistory.GUID, record.guid);
cv.put(FormHistory.FIELD_NAME, record.fieldName);
cv.put(FormHistory.VALUE, record.fieldValue);
cv.put(FormHistory.FIRST_USED, 1000 * record.lastModified); // Microseconds.
return cv;
}
protected Object recordsBufferMonitor = new Object();
protected ArrayList<ContentValues> recordsBuffer = new ArrayList<ContentValues>();
protected void enqueueRegularRecord(Record record) {
synchronized (recordsBufferMonitor) {
if (recordsBuffer.size() >= INSERT_ITEM_THRESHOLD) {
// Insert the existing contents, then enqueue.
try {
flushInsertQueue();
} catch (Exception e) {
delegate.onRecordStoreFailed(e, record.guid);
return;
}
}
// Store the ContentValues, rather than the record.
recordsBuffer.add(contentValuesForRegularRecord(record));
}
}
// Should always be called from storeWorkQueue.
protected void flushInsertQueue() throws RemoteException {
synchronized (recordsBufferMonitor) {
if (recordsBuffer.size() > 0) {
final ContentValues[] outgoing = recordsBuffer.toArray(new ContentValues[0]);
recordsBuffer = new ArrayList<ContentValues>();
if (outgoing == null || outgoing.length == 0) {
Logger.debug(LOG_TAG, "No form history items to insert; returning immediately.");
return;
}
long before = System.currentTimeMillis();
formsProvider.bulkInsert(FORM_HISTORY_CONTENT_URI, outgoing);
long after = System.currentTimeMillis();
Logger.debug(LOG_TAG, "Inserted " + outgoing.length + " form history items in (" + (after - before) + " milliseconds).");
}
}
}
@Override
public void storeDone() {
Runnable command = new Runnable() {
@Override
public void run() {
Logger.debug(LOG_TAG, "Checking for residual form history items to insert.");
try {
synchronized (recordsBufferMonitor) {
flushInsertQueue();
}
storeDone(now());
} catch (Exception e) {
// XXX TODO
delegate.onRecordStoreFailed(e, null);
}
}
};
storeWorkQueue.execute(command);
}
/**
* Called when a regular record with locally unknown GUID has been fetched
* from the server.
* <p>
* Since the record is regular, we insert it into the regular table.
*
* @param record The regular <code>Record</code> from the server.
* @throws RemoteException
*/
protected void insertNewRegularRecord(Record record)
throws RemoteException {
enqueueRegularRecord(record);
}
/**
* Called when a regular record with has been fetched from the server and
* should replace an existing record.
* <p>
* We delete the existing record entirely, and then insert the new record into
* the regular table.
*
* @param toStore
* The regular <code>Record</code> from the server.
* @param existingRecord
* The local <code>Record</code> to replace.
* @throws RemoteException
*/
protected void replaceExistingRecordWithRegularRecord(Record toStore, Record existingRecord)
throws RemoteException {
if (existingRecord.deleted) {
// Need two database operations -- purge from deleted table, insert into regular table.
deleteExistingRecord(existingRecord);
insertNewRegularRecord(toStore);
return;
}
final ContentValues cv = contentValuesForRegularRecord(toStore);
int updated = formsProvider.update(FORM_HISTORY_CONTENT_URI, cv, GUID_IS, new String[] { existingRecord.guid });
if (updated != 1) {
Logger.warn(LOG_TAG, "Expected to update 1 record with guid " + existingRecord.guid + " but updated " + updated + " records.");
}
}
@Override
public void store(Record rawRecord) throws NoStoreDelegateException {
if (delegate == null) {
Logger.warn(LOG_TAG, "No store delegate.");
throw new NoStoreDelegateException();
}
if (rawRecord == null) {
Logger.error(LOG_TAG, "Record sent to store was null");
throw new IllegalArgumentException("Null record passed to FormHistoryRepositorySession.store().");
}
if (!(rawRecord instanceof FormHistoryRecord)) {
Logger.error(LOG_TAG, "Can't store anything but a FormHistoryRecord");
throw new IllegalArgumentException("Non-FormHistoryRecord passed to FormHistoryRepositorySession.store().");
}
final FormHistoryRecord record = (FormHistoryRecord) rawRecord;
Runnable command = new Runnable() {
@Override
public void run() {
if (!isActive()) {
Logger.warn(LOG_TAG, "FormHistoryRepositorySession is inactive. Store failing.");
delegate.onRecordStoreFailed(new InactiveSessionException(null), record.guid);
return;
}
// TODO: lift these into the session.
// Temporary: this matches prior syncing semantics, in which only
// the relationship between the local and remote record is considered.
// In the future we'll track these two timestamps and use them to
// determine which records have changed, and thus process incoming
// records more efficiently.
long lastLocalRetrieval = 0; // lastSyncTimestamp?
long lastRemoteRetrieval = 0; // TODO: adjust for clock skew.
boolean remotelyModified = record.lastModified > lastRemoteRetrieval;
Record existingRecord;
try {
// GUID matching only: deleted records don't have a payload with which to search.
existingRecord = findExistingRecordByGuid(record.guid);
if (record.deleted) {
if (existingRecord == null) {
// We're done. Don't bother with a callback. That can change later
// if we want it to.
Logger.trace(LOG_TAG, "Incoming record " + record.guid + " is deleted, and no local version. Bye!");
return;
}
if (existingRecord.deleted) {
Logger.trace(LOG_TAG, "Local record already deleted. Purging local.");
deleteExistingRecord(existingRecord);
return;
}
// Which one wins?
if (!remotelyModified) {
Logger.trace(LOG_TAG, "Ignoring deleted record from the past.");
return;
}
boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval;
if (!locallyModified) {
Logger.trace(LOG_TAG, "Remote modified, local not. Deleting.");
deleteExistingRecord(existingRecord);
trackRecord(record);
delegate.onRecordStoreSucceeded(record.guid);
return;
}
Logger.trace(LOG_TAG, "Both local and remote records have been modified.");
if (record.lastModified > existingRecord.lastModified) {
Logger.trace(LOG_TAG, "Remote is newer, and deleted. Purging local.");
deleteExistingRecord(existingRecord);
trackRecord(record);
delegate.onRecordStoreSucceeded(record.guid);
return;
}
Logger.trace(LOG_TAG, "Remote is older, local is not deleted. Ignoring.");
if (!locallyModified) {
Logger.warn(LOG_TAG, "Inconsistency: old remote record is deleted, but local record not modified!");
// Ensure that this is tracked for upload.
}
return;
}
// End deletion logic.
// Now we're processing a non-deleted incoming record.
if (existingRecord == null) {
Logger.trace(LOG_TAG, "Looking up match for record " + record.guid);
existingRecord = findExistingRecordByPayload(record);
}
if (existingRecord == null) {
// The record is new.
Logger.trace(LOG_TAG, "No match. Inserting.");
insertNewRegularRecord(record);
trackRecord(record);
delegate.onRecordStoreSucceeded(record.guid);
return;
}
// We found a local duplicate.
Logger.trace(LOG_TAG, "Incoming record " + record.guid + " dupes to local record " + existingRecord.guid);
if (!RepoUtils.stringsEqual(record.guid, existingRecord.guid)) {
// We found a local record that does NOT have the same GUID -- keep the server's version.
Logger.trace(LOG_TAG, "Remote guid different from local guid. Storing to keep remote guid.");
replaceExistingRecordWithRegularRecord(record, existingRecord);
trackRecord(record);
delegate.onRecordStoreSucceeded(record.guid);
return;
}
// We found a local record that does have the same GUID -- check modification times.
boolean locallyModified = existingRecord.lastModified > lastLocalRetrieval;
if (!locallyModified) {
Logger.trace(LOG_TAG, "Remote modified, local not. Storing.");
replaceExistingRecordWithRegularRecord(record, existingRecord);
trackRecord(record);
delegate.onRecordStoreSucceeded(record.guid);
return;
}
Logger.trace(LOG_TAG, "Both local and remote records have been modified.");
if (record.lastModified > existingRecord.lastModified) {
Logger.trace(LOG_TAG, "Remote is newer, and not deleted. Storing.");
replaceExistingRecordWithRegularRecord(record, existingRecord);
trackRecord(record);
delegate.onRecordStoreSucceeded(record.guid);
return;
}
Logger.trace(LOG_TAG, "Remote is older, local is not deleted. Ignoring.");
if (!locallyModified) {
Logger.warn(LOG_TAG, "Inconsistency: old remote record is not deleted, but local record not modified!");
}
return;
} catch (Exception e) {
Logger.error(LOG_TAG, "Store failed for " + record.guid, e);
delegate.onRecordStoreFailed(e, record.guid);
return;
}
}
};
storeWorkQueue.execute(command);
}
/**
* Purge all data from the underlying databases.
*/
public static void purgeDatabases(ContentProviderClient formsProvider)
throws RemoteException {
formsProvider.delete(FORM_HISTORY_CONTENT_URI, null, null);
formsProvider.delete(DELETED_FORM_HISTORY_CONTENT_URI, null, null);
}
@Override
public void wipe(final RepositorySessionWipeDelegate delegate) {
Runnable command = new Runnable() {
public void run() {
if (!isActive()) {
delegate.onWipeFailed(new InactiveSessionException(null));
return;
}
try {
Logger.debug(LOG_TAG, "Wiping form history and deleted form history...");
purgeDatabases(formsProvider);
Logger.debug(LOG_TAG, "Wiping form history and deleted form history... DONE");
} catch (Exception e) {
delegate.onWipeFailed(e);
return;
}
delegate.onWipeSucceeded();
}
};
storeWorkQueue.execute(command);
}
}