1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 package org.mozilla.gecko.sync.repositories.android; 6 7 import java.util.ArrayList; 8 9 import org.mozilla.gecko.background.common.log.Logger; 10 import org.mozilla.gecko.db.BrowserContract; 11 import org.mozilla.gecko.sync.repositories.InvalidSessionTransitionException; 12 import org.mozilla.gecko.sync.repositories.NoGuidForIdException; 13 import org.mozilla.gecko.sync.repositories.NullCursorException; 14 import org.mozilla.gecko.sync.repositories.ParentNotFoundException; 15 import org.mozilla.gecko.sync.repositories.Repository; 16 import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionBeginDelegate; 17 import org.mozilla.gecko.sync.repositories.domain.HistoryRecord; 18 import org.mozilla.gecko.sync.repositories.domain.Record; 19 20 import android.content.ContentProviderClient; 21 import android.content.Context; 22 import android.database.Cursor; 23 import android.os.RemoteException; 24 25 public class AndroidBrowserHistoryRepositorySession extends AndroidBrowserRepositorySession { 26 public static final String LOG_TAG = "ABHistoryRepoSess"; 27 28 /** 29 * The number of records to queue for insertion before writing to databases. 30 */ 31 public static final int INSERT_RECORD_THRESHOLD = 50; 32 public static final int RECENT_VISITS_LIMIT = 20; 33 AndroidBrowserHistoryRepositorySession(Repository repository, Context context)34 public AndroidBrowserHistoryRepositorySession(Repository repository, Context context) { 35 super(repository); 36 dbHelper = new AndroidBrowserHistoryDataAccessor(context); 37 } 38 39 @Override begin(RepositorySessionBeginDelegate delegate)40 public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException { 41 // HACK: Fennec creates history records without a GUID. Mercilessly drop 42 // them on the floor. See Bug 739514. 43 try { 44 dbHelper.delete(BrowserContract.History.GUID + " IS NULL", null); 45 } catch (Exception e) { 46 // Ignore. 47 } 48 super.begin(delegate); 49 } 50 51 @Override retrieveDuringStore(Cursor cur)52 protected Record retrieveDuringStore(Cursor cur) { 53 return RepoUtils.historyFromMirrorCursor(cur); 54 } 55 56 @Override retrieveDuringFetch(Cursor cur)57 protected Record retrieveDuringFetch(Cursor cur) { 58 return RepoUtils.historyFromMirrorCursor(cur); 59 } 60 61 @Override buildRecordString(Record record)62 protected String buildRecordString(Record record) { 63 HistoryRecord hist = (HistoryRecord) record; 64 return hist.histURI; 65 } 66 67 @Override shouldIgnore(Record record)68 public boolean shouldIgnore(Record record) { 69 if (super.shouldIgnore(record)) { 70 return true; 71 } 72 if (!(record instanceof HistoryRecord)) { 73 return true; 74 } 75 HistoryRecord r = (HistoryRecord) record; 76 return !RepoUtils.isValidHistoryURI(r.histURI); 77 } 78 79 @Override transformRecord(Record record)80 protected Record transformRecord(Record record) throws NullCursorException { 81 return addVisitsToRecord(record); 82 } 83 addVisitsToRecord(Record record)84 private Record addVisitsToRecord(Record record) throws NullCursorException { 85 Logger.debug(LOG_TAG, "Adding visits for GUID " + record.guid); 86 87 // Sync is an object store, so what we attach here will replace what's already present on the Sync servers. 88 // We upload just a recent subset of visits for each history record for space and bandwidth reasons. 89 // We chose 20 to be conservative. See Bug 1164660 for details. 90 ContentProviderClient visitsClient = dbHelper.context.getContentResolver().acquireContentProviderClient(BrowserContractHelpers.VISITS_CONTENT_URI); 91 if (visitsClient == null) { 92 throw new IllegalStateException("Could not obtain a ContentProviderClient for Visits URI"); 93 } 94 95 try { 96 ((HistoryRecord) record).visits = VisitsHelper.getRecentHistoryVisitsForGUID( 97 visitsClient, record.guid, RECENT_VISITS_LIMIT); 98 } catch (RemoteException e) { 99 throw new IllegalStateException("Error while obtaining visits for a record", e); 100 } finally { 101 visitsClient.release(); 102 } 103 104 return record; 105 } 106 107 @Override prepareRecord(Record record)108 protected Record prepareRecord(Record record) { 109 return record; 110 } 111 112 protected final Object recordsBufferMonitor = new Object(); 113 protected ArrayList<HistoryRecord> recordsBuffer = new ArrayList<HistoryRecord>(); 114 115 /** 116 * Queue record for insertion, possibly flushing the queue. 117 * <p> 118 * Must be called on <code>storeWorkQueue</code> thread! But this is only 119 * called from <code>store</code>, which is called on the queue thread. 120 * 121 * @param record 122 * A <code>Record</code> with a GUID that is not present locally. 123 */ 124 @Override insert(Record record)125 protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException { 126 enqueueNewRecord((HistoryRecord) prepareRecord(record)); 127 } 128 129 /** 130 * Batch incoming records until some reasonable threshold is hit or storeDone 131 * is received. 132 * <p> 133 * Must be called on <code>storeWorkQueue</code> thread! 134 * 135 * @param record A <code>Record</code> with a GUID that is not present locally. 136 * @throws NullCursorException 137 */ enqueueNewRecord(HistoryRecord record)138 protected void enqueueNewRecord(HistoryRecord record) throws NullCursorException { 139 synchronized (recordsBufferMonitor) { 140 if (recordsBuffer.size() >= INSERT_RECORD_THRESHOLD) { 141 flushNewRecords(); 142 } 143 Logger.debug(LOG_TAG, "Enqueuing new record with GUID " + record.guid); 144 recordsBuffer.add(record); 145 } 146 } 147 148 /** 149 * Flush queue of incoming records to database. 150 * <p> 151 * Must be called on <code>storeWorkQueue</code> thread! 152 * <p> 153 * Must be locked by recordsBufferMonitor! 154 * @throws NullCursorException 155 */ flushNewRecords()156 protected void flushNewRecords() throws NullCursorException { 157 if (recordsBuffer.size() < 1) { 158 Logger.debug(LOG_TAG, "No records to flush, returning."); 159 return; 160 } 161 162 final ArrayList<HistoryRecord> outgoing = recordsBuffer; 163 recordsBuffer = new ArrayList<HistoryRecord>(); 164 Logger.debug(LOG_TAG, "Flushing " + outgoing.size() + " records to database."); 165 // TODO: move bulkInsert to AndroidBrowserDataAccessor? 166 int inserted = ((AndroidBrowserHistoryDataAccessor) dbHelper).bulkInsert(outgoing); 167 if (inserted != outgoing.size()) { 168 // Something failed; most pessimistic action is to declare that all insertions failed. 169 // TODO: perform the bulkInsert in a transaction and rollback unless all insertions succeed? 170 for (HistoryRecord failed : outgoing) { 171 delegate.onRecordStoreFailed(new RuntimeException("Failed to insert history item with guid " + failed.guid + "."), failed.guid); 172 } 173 return; 174 } 175 176 // All good, everybody succeeded. 177 for (HistoryRecord succeeded : outgoing) { 178 try { 179 // Does not use androidID -- just GUID -> String map. 180 updateBookkeeping(succeeded); 181 } catch (NoGuidForIdException | ParentNotFoundException e) { 182 // Should not happen. 183 throw new NullCursorException(e); 184 } catch (NullCursorException e) { 185 throw e; 186 } 187 trackRecord(succeeded); 188 delegate.onRecordStoreSucceeded(succeeded.guid); // At this point, we are really inserted. 189 } 190 } 191 192 @Override storeDone()193 public void storeDone() { 194 storeWorkQueue.execute(new Runnable() { 195 @Override 196 public void run() { 197 synchronized (recordsBufferMonitor) { 198 try { 199 flushNewRecords(); 200 } catch (Exception e) { 201 Logger.warn(LOG_TAG, "Error flushing records to database.", e); 202 } 203 } 204 storeDone(System.currentTimeMillis()); 205 } 206 }); 207 } 208 } 209