/* 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); } }