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