/* 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 org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.sync.Logger;
import org.mozilla.gecko.sync.repositories.InactiveSessionException;
import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException;
import org.mozilla.gecko.sync.repositories.NoGuidForIdException;
import org.mozilla.gecko.sync.repositories.NullCursorException;
import org.mozilla.gecko.sync.repositories.ParentNotFoundException;
import org.mozilla.gecko.sync.repositories.Repository;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionFinishDelegate;
import org.mozilla.gecko.sync.repositories.domain.HistoryRecord;
import org.mozilla.gecko.sync.repositories.domain.Record;
import android.content.Context;
import android.database.Cursor;
public class AndroidBrowserHistoryRepositorySession extends AndroidBrowserRepositorySession {
public static final String LOG_TAG = "ABHistoryRepoSess";
public static final String KEY_DATE = "date";
public static final String KEY_TYPE = "type";
public static final long DEFAULT_VISIT_TYPE = 1;
/**
* The number of records to queue for insertion before writing to databases.
*/
public static int INSERT_RECORD_THRESHOLD = 50;
public AndroidBrowserHistoryRepositorySession(Repository repository, Context context) {
super(repository);
dbHelper = new AndroidBrowserHistoryDataAccessor(context);
}
@Override
public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
// HACK: Fennec creates history records without a GUID. Mercilessly drop
// them on the floor. See Bug 739514.
try {
dbHelper.delete(BrowserContract.History.GUID + " IS NULL", null);
} catch (Exception e) {
// Ignore.
}
super.begin(delegate);
}
@Override
protected Record retrieveDuringStore(Cursor cur) {
return RepoUtils.historyFromMirrorCursor(cur);
}
@Override
protected Record retrieveDuringFetch(Cursor cur) {
return RepoUtils.historyFromMirrorCursor(cur);
}
@Override
protected String buildRecordString(Record record) {
HistoryRecord hist = (HistoryRecord) record;
return hist.histURI;
}
@Override
public boolean shouldIgnore(Record record) {
if (super.shouldIgnore(record)) {
return true;
}
if (!(record instanceof HistoryRecord)) {
return true;
}
HistoryRecord r = (HistoryRecord) record;
return !RepoUtils.isValidHistoryURI(r.histURI);
}
@Override
protected Record transformRecord(Record record) throws NullCursorException {
return addVisitsToRecord(record);
}
@SuppressWarnings("unchecked")
private void addVisit(JSONArray visits, long date, long visitType) {
JSONObject visit = new JSONObject();
visit.put(KEY_DATE, date); // Microseconds since epoch.
visit.put(KEY_TYPE, visitType);
visits.add(visit);
}
private void addVisit(JSONArray visits, long date) {
addVisit(visits, date, DEFAULT_VISIT_TYPE);
}
private AndroidBrowserHistoryDataExtender getDataExtender() {
return ((AndroidBrowserHistoryDataAccessor) dbHelper).getHistoryDataExtender();
}
private Record addVisitsToRecord(Record record) throws NullCursorException {
Logger.debug(LOG_TAG, "Adding visits for GUID " + record.guid);
HistoryRecord hist = (HistoryRecord) record;
JSONArray visitsArray = getDataExtender().visitsForGUID(hist.guid);
long missingRecords = hist.fennecVisitCount - visitsArray.size();
// Note that Fennec visit times are milliseconds, and we are working
// in microseconds. This is the point at which we translate.
// Add (missingRecords - 1) fake visits...
if (missingRecords > 0) {
long fakes = missingRecords - 1;
for (int j = 0; j < fakes; j++) {
// Set fake visit timestamp to be just previous to
// the real one we are about to add.
// TODO: make these equidistant?
long fakeDate = (hist.fennecDateVisited - (1 + j)) * 1000;
addVisit(visitsArray, fakeDate);
}
// ... and the 1 actual record we have.
// We still have to fake the visit type: Fennec doesn't track that.
addVisit(visitsArray, hist.fennecDateVisited * 1000);
}
hist.visits = visitsArray;
return hist;
}
@Override
protected Record prepareRecord(Record record) {
return record;
}
@Override
public void abort() {
if (dbHelper != null) {
((AndroidBrowserHistoryDataAccessor) dbHelper).closeExtender();
dbHelper = null;
}
super.abort();
}
@Override
public void finish(final RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
if (dbHelper != null) {
((AndroidBrowserHistoryDataAccessor) dbHelper).closeExtender();
dbHelper = null;
}
super.finish(delegate);
}
protected Object recordsBufferMonitor = new Object();
protected ArrayList<HistoryRecord> recordsBuffer = new ArrayList<HistoryRecord>();
/**
* Queue record for insertion, possibly flushing the queue.
* <p>
* Must be called on <code>storeWorkQueue</code> thread! But this is only
* called from <code>store</code>, which is called on the queue thread.
*
* @param record
* A <code>Record</code> with a GUID that is not present locally.
*/
@Override
protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
enqueueNewRecord((HistoryRecord) prepareRecord(record));
}
/**
* Batch incoming records until some reasonable threshold is hit or storeDone
* is received.
* <p>
* Must be called on <code>storeWorkQueue</code> thread!
*
* @param record A <code>Record</code> with a GUID that is not present locally.
* @throws NullCursorException
*/
protected void enqueueNewRecord(HistoryRecord record) throws NullCursorException {
synchronized (recordsBufferMonitor) {
if (recordsBuffer.size() >= INSERT_RECORD_THRESHOLD) {
flushNewRecords();
}
Logger.debug(LOG_TAG, "Enqueuing new record with GUID " + record.guid);
recordsBuffer.add(record);
}
}
/**
* Flush queue of incoming records to database.
* <p>
* Must be called on <code>storeWorkQueue</code> thread!
* <p>
* Must be locked by recordsBufferMonitor!
* @throws NullCursorException
*/
protected void flushNewRecords() throws NullCursorException {
if (recordsBuffer.size() < 1) {
Logger.debug(LOG_TAG, "No records to flush, returning.");
return;
}
final ArrayList<HistoryRecord> outgoing = recordsBuffer;
recordsBuffer = new ArrayList<HistoryRecord>();
Logger.debug(LOG_TAG, "Flushing " + outgoing.size() + " records to database.");
// TODO: move bulkInsert to AndroidBrowserDataAccessor?
int inserted = ((AndroidBrowserHistoryDataAccessor) dbHelper).bulkInsert(outgoing);
if (inserted != outgoing.size()) {
// Something failed; most pessimistic action is to declare that all insertions failed.
// TODO: perform the bulkInsert in a transaction and rollback unless all insertions succeed?
for (HistoryRecord failed : outgoing) {
delegate.onRecordStoreFailed(new RuntimeException("Failed to insert history item with guid " + failed.guid + "."), failed.guid);
}
return;
}
// All good, everybody succeeded.
for (HistoryRecord succeeded : outgoing) {
try {
// Does not use androidID -- just GUID -> String map.
updateBookkeeping(succeeded);
} catch (NoGuidForIdException e) {
// Should not happen.
throw new NullCursorException(e);
} catch (ParentNotFoundException e) {
// Should not happen.
throw new NullCursorException(e);
} catch (NullCursorException e) {
throw e;
}
trackRecord(succeeded);
delegate.onRecordStoreSucceeded(succeeded.guid); // At this point, we are really inserted.
}
}
@Override
public void storeDone() {
storeWorkQueue.execute(new Runnable() {
@Override
public void run() {
synchronized (recordsBufferMonitor) {
try {
flushNewRecords();
} catch (Exception e) {
Logger.warn(LOG_TAG, "Error flushing records to database.", e);
}
}
storeDone(System.currentTimeMillis());
}
});
}
}