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 import java.util.Collection;
9 import java.util.HashMap;
10 import java.util.Map;
11 
12 import org.json.simple.JSONArray;
13 import org.mozilla.gecko.background.common.log.Logger;
14 import org.mozilla.gecko.db.BrowserContract;
15 import org.mozilla.gecko.sync.repositories.NullCursorException;
16 import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
17 import org.mozilla.gecko.sync.repositories.domain.Record;
18 
19 import android.content.ContentUris;
20 import android.content.ContentValues;
21 import android.content.Context;
22 import android.database.Cursor;
23 import android.net.Uri;
24 import android.os.Bundle;
25 
26 public class BookmarksDataAccessor extends DataAccessor {
27 
28   private static final String LOG_TAG = "BookmarksDataAccessor";
29 
30   /*
31    * Fragments of SQL to make our lives easier.
32    */
33   private static final String BOOKMARK_IS_FOLDER = BrowserContract.Bookmarks.TYPE + " = " +
34                                                    BrowserContract.Bookmarks.TYPE_FOLDER;
35 
36   // SQL fragment to retrieve GUIDs whose ID mappings should be tracked by this session.
37   // Exclude folders we don't want to sync.
38   private static final String GUID_SHOULD_TRACK = BrowserContract.SyncColumns.GUID + " NOT IN ('" +
39                                                   BrowserContract.Bookmarks.TAGS_FOLDER_GUID + "', '" +
40                                                   BrowserContract.Bookmarks.PLACES_FOLDER_GUID + "', '" +
41                                                   BrowserContract.Bookmarks.PINNED_FOLDER_GUID + "')";
42 
43   private static final String EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE;
44   static {
45     if (BookmarksRepositorySession.SPECIAL_GUIDS.length > 0) {
46       StringBuilder b = new StringBuilder(BrowserContract.SyncColumns.GUID + " NOT IN (");
47 
48       int remaining = BookmarksRepositorySession.SPECIAL_GUIDS.length - 1;
49       for (String specialGuid : BookmarksRepositorySession.SPECIAL_GUIDS) {
50         b.append('"');
51         b.append(specialGuid);
52         b.append('"');
53         if (remaining-- > 0) {
54           b.append(", ");
55         }
56       }
57       b.append(')');
58       EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE = b.toString();
59     } else {
60       EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE = null;       // null is a valid WHERE clause.
61     }
62   }
63 
64   public static final String TYPE_FOLDER = "folder";
65   public static final String TYPE_BOOKMARK = "bookmark";
66 
67   private final RepoUtils.QueryHelper queryHelper;
68 
BookmarksDataAccessor(Context context)69   public BookmarksDataAccessor(Context context) {
70     super(context);
71     this.queryHelper = new RepoUtils.QueryHelper(context, getUri(), LOG_TAG);
72   }
73 
74   @Override
getUri()75   protected Uri getUri() {
76     return BrowserContractHelpers.BOOKMARKS_CONTENT_URI;
77   }
78 
79   /**
80    * Order bookmarks by type, ensuring that folders will be processed before other records during
81    * an upload. This is in support of payload-size validation. See Bug 1343726.
82    */
fetchModified()83   public Cursor fetchModified() throws NullCursorException {
84     return queryHelper.safeQuery(".fetchModified",
85             getAllColumns(),
86             BrowserContract.VersionColumns.LOCAL_VERSION + " > " + BrowserContract.VersionColumns.SYNC_VERSION,
87             null, BrowserContract.Bookmarks.TYPE + " ASC");
88   }
89 
getPositionsUri()90   protected static Uri getPositionsUri() {
91     return BrowserContractHelpers.BOOKMARKS_POSITIONS_CONTENT_URI;
92   }
93 
94   @Override
wipe()95   public void wipe() {
96     Uri uri = getUri();
97     Logger.info(LOG_TAG, "wiping (except for special guids): " + uri);
98     context.getContentResolver().delete(uri, EXCLUDE_SPECIAL_GUIDS_WHERE_CLAUSE, null);
99   }
100 
101   private final String[] GUID_AND_ID = new String[] { BrowserContract.Bookmarks.GUID,
102                                                 BrowserContract.Bookmarks._ID };
103 
getGuidsIDsForFolders()104   protected Cursor getGuidsIDsForFolders() throws NullCursorException {
105     // Exclude items that we don't want to sync (pinned items, reading list,
106     // tags, the places root), in case they've ended up in the DB.
107     String where = BOOKMARK_IS_FOLDER + " AND " + GUID_SHOULD_TRACK;
108     return queryHelper.safeQuery(".getGuidsIDsForFolders", GUID_AND_ID, where, null, null);
109   }
110 
111   /**
112    * Update a record identified by GUID to new values, but only if assertion passes that localVersion
113    * did not change. This is expected to be called during record reconciliation, and so we also request
114    * that localVersion is incremented in the process.
115    */
updateAssertingLocalVersion(String guid, int expectedLocalVersion, boolean shouldIncrementLocalVersion, Record newRecord)116   /* package-private */ boolean updateAssertingLocalVersion(String guid, int expectedLocalVersion, boolean shouldIncrementLocalVersion, Record newRecord) {
117     final ContentValues cv = getContentValues(newRecord);
118 
119     // A (hopefully) temporary hack, see https://bugzilla.mozilla.org/show_bug.cgi?id=1395703#c1
120     // We don't need this flag in CVs, since we're signaling the same thing via uri (see below).
121     cv.remove(BrowserContract.Bookmarks.PARAM_INSERT_FROM_SYNC_AS_MODIFIED);
122 
123     final Bundle data = new Bundle();
124     data.putString(BrowserContract.SyncColumns.GUID, guid);
125     data.putInt(BrowserContract.VersionColumns.LOCAL_VERSION, expectedLocalVersion);
126     data.putParcelable(BrowserContract.METHOD_PARAM_DATA, cv);
127 
128     final Uri callUri;
129     if (shouldIncrementLocalVersion) {
130       callUri = withLocalVersionIncrement(getUri());
131     } else {
132       callUri = getUri();
133     }
134 
135     final Bundle result = context.getContentResolver().call(
136             callUri,
137             BrowserContract.METHOD_UPDATE_BY_GUID_ASSERTING_LOCAL_VERSION,
138             callUri.toString(),
139             data
140     );
141     if (result == null) {
142       throw new IllegalStateException("Unexpected null result after METHOD_UPDATE_BY_GUID_ASSERTING_LOCAL_VERSION");
143     }
144     return (boolean) result.getSerializable(BrowserContract.METHOD_RESULT);
145   }
146 
withLocalVersionIncrement(Uri baseUri)147   private Uri withLocalVersionIncrement(Uri baseUri) {
148     return baseUri.buildUpon().appendQueryParameter(BrowserContractHelpers.PARAM_INCREMENT_LOCAL_VERSION_FROM_SYNC, "true").build();
149   }
150 
151 
152   /**
153    * Issue a request to the Content Provider to update the positions of the
154    * records named by the provided GUIDs to the index of their GUID in the
155    * provided array.
156    *
157    * @param childArray
158    *        A sequence of GUID strings.
159    */
updatePositions(ArrayList<String> childArray)160   public int updatePositions(ArrayList<String> childArray) {
161     final int size = childArray.size();
162     if (size == 0) {
163       return 0;
164     }
165 
166     Logger.debug(LOG_TAG, "Updating positions for " + size + " items.");
167     String[] args = childArray.toArray(new String[size]);
168     return context.getContentResolver().update(getPositionsUri(), new ContentValues(), null, args);
169   }
170 
bumpModifiedByGUID(Collection<String> ids, long modified)171   public int bumpModifiedByGUID(Collection<String> ids, long modified) {
172     final int size = ids.size();
173     if (size == 0) {
174       return 0;
175     }
176 
177     Logger.debug(LOG_TAG, "Bumping modified for " + size + " items to " + modified);
178     String where = RepoUtils.computeSQLInClause(size, BrowserContract.Bookmarks.GUID);
179     String[] selectionArgs = ids.toArray(new String[size]);
180     ContentValues values = new ContentValues();
181     values.put(BrowserContract.Bookmarks.DATE_MODIFIED, modified);
182 
183     return context.getContentResolver().update(
184             withLocalVersionIncrement(getUri()),
185             values, where, selectionArgs);
186   }
187 
188   /**
189    * Bump the modified time of a record by ID.
190    */
bumpModified(long id, long modified)191   public int bumpModified(long id, long modified) {
192     Logger.debug(LOG_TAG, "Bumping modified for " + id + " to " + modified);
193     String where = BrowserContract.Bookmarks._ID + " = ?";
194     String[] selectionArgs = new String[] { String.valueOf(id) };
195     ContentValues values = new ContentValues();
196     values.put(BrowserContract.Bookmarks.DATE_MODIFIED, modified);
197 
198     return context.getContentResolver().update(
199             withLocalVersionIncrement(getUri()),
200             values, where, selectionArgs);
201   }
202 
updateParentAndPosition(String guid, long newParentId, long position)203   protected void updateParentAndPosition(String guid, long newParentId, long position) {
204     ContentValues cv = new ContentValues();
205     cv.put(BrowserContract.Bookmarks.PARENT, newParentId);
206     if (position >= 0) {
207       cv.put(BrowserContract.Bookmarks.POSITION, position);
208     }
209     updateByGuid(guid, cv);
210   }
211 
idsForGUIDs(String[] guids)212   protected Map<String, Long> idsForGUIDs(String[] guids) throws NullCursorException {
213     final String where = RepoUtils.computeSQLInClause(guids.length, BrowserContract.Bookmarks.GUID);
214     Cursor c = queryHelper.safeQuery(".idsForGUIDs", GUID_AND_ID, where, guids, null);
215     try {
216       HashMap<String, Long> out = new HashMap<String, Long>();
217       if (!c.moveToFirst()) {
218         return out;
219       }
220       final int guidIndex = c.getColumnIndexOrThrow(BrowserContract.Bookmarks.GUID);
221       final int idIndex = c.getColumnIndexOrThrow(BrowserContract.Bookmarks._ID);
222       while (!c.isAfterLast()) {
223         out.put(c.getString(guidIndex), c.getLong(idIndex));
224         c.moveToNext();
225       }
226       return out;
227     } finally {
228       c.close();
229     }
230   }
231 
232   /**
233    * Move the children of each source folder to the destination folder.
234    * Bump the modified time of each child.
235    * The caller should bump the modified time of the destination if desired.
236    *
237    * @param fromIDs the Android IDs of the source folders.
238    * @param to the Android ID of the destination folder.
239    * @return the number of updated rows.
240    */
moveChildren(String[] fromIDs, long to)241   protected int moveChildren(String[] fromIDs, long to) {
242     long now = System.currentTimeMillis();
243     long pos = -1;
244 
245     ContentValues cv = new ContentValues();
246     cv.put(BrowserContract.Bookmarks.PARENT, to);
247     cv.put(BrowserContract.Bookmarks.DATE_MODIFIED, now);
248     cv.put(BrowserContract.Bookmarks.POSITION, pos);
249 
250     final String where = RepoUtils.computeSQLInClause(fromIDs.length, BrowserContract.Bookmarks.PARENT);
251     return context.getContentResolver().update(withLocalVersionIncrement(getUri()), cv, where, fromIDs);
252   }
253 
254   /*
255    * Verify that all special GUIDs are present and that they aren't marked as deleted.
256    * Insert them if they aren't there.
257    */
checkAndBuildSpecialGuids()258   public void checkAndBuildSpecialGuids() throws NullCursorException {
259     final String[] specialGUIDs = BookmarksRepositorySession.SPECIAL_GUIDS;
260     Cursor cur = fetch(specialGUIDs);
261     long placesRoot = 0;
262 
263     // Map from GUID to whether deleted. Non-presence implies just that.
264     HashMap<String, Boolean> statuses = new HashMap<String, Boolean>(specialGUIDs.length);
265     try {
266       if (cur.moveToFirst()) {
267         while (!cur.isAfterLast()) {
268           String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
269           if ("places".equals(guid)) {
270             placesRoot = RepoUtils.getLongFromCursor(cur, BrowserContract.CommonColumns._ID);
271           }
272           // Make sure none of these folders are marked as deleted.
273           boolean deleted = RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) == 1;
274           statuses.put(guid, deleted);
275           cur.moveToNext();
276         }
277       }
278     } finally {
279       cur.close();
280     }
281 
282     // Insert or undelete them if missing.
283     for (String guid : specialGUIDs) {
284       if (statuses.containsKey(guid)) {
285         if (statuses.get(guid)) {
286           // Undelete.
287           Logger.info(LOG_TAG, "Undeleting special GUID " + guid);
288           ContentValues cv = new ContentValues();
289           cv.put(BrowserContract.SyncColumns.IS_DELETED, 0);
290           updateByGuid(guid, cv);
291         }
292       } else {
293         // Insert.
294         if (guid.equals("places")) {
295           // This is awkward.
296           Logger.info(LOG_TAG, "No places root. Inserting one.");
297           placesRoot = insertSpecialFolder("places", 0);
298         } else if (guid.equals("mobile")) {
299           Logger.info(LOG_TAG, "No mobile folder. Inserting one under the places root.");
300           insertSpecialFolder("mobile", placesRoot);
301         } else {
302           // unfiled, menu, toolbar.
303           Logger.info(LOG_TAG, "No " + guid + " root. Inserting one under places (" + placesRoot + ").");
304           insertSpecialFolder(guid, placesRoot);
305         }
306       }
307     }
308   }
309 
insertSpecialFolder(String guid, long parentId)310   private long insertSpecialFolder(String guid, long parentId) {
311     BookmarkRecord record = new BookmarkRecord(guid);
312     record.title = BookmarksRepositorySession.SPECIAL_GUIDS_MAP.get(guid);
313     record.type = "folder";
314     record.androidParentID = parentId;
315     return ContentUris.parseId(insert(record));
316   }
317 
318   @Override
getContentValues(Record record)319   protected ContentValues getContentValues(Record record) {
320     BookmarkRecord rec = (BookmarkRecord) record;
321 
322     if (rec.deleted) {
323       ContentValues cv = new ContentValues();
324       cv.put(BrowserContract.SyncColumns.GUID,     rec.guid);
325       cv.put(BrowserContract.Bookmarks.IS_DELETED, 1);
326       return cv;
327     }
328 
329     final int recordType = BrowserContractHelpers.typeCodeForString(rec.type);
330     if (recordType == -1) {
331       throw new IllegalStateException("Unexpected record type " + rec.type);
332     }
333 
334     ContentValues cv = new ContentValues();
335     cv.put(BrowserContract.SyncColumns.GUID,      rec.guid);
336     cv.put(BrowserContract.Bookmarks.TYPE,        recordType);
337     cv.put(BrowserContract.Bookmarks.TITLE,       rec.title);
338     cv.put(BrowserContract.Bookmarks.URL,         rec.bookmarkURI);
339     cv.put(BrowserContract.Bookmarks.DESCRIPTION, rec.description);
340 
341     if (rec.dateAdded != null) {
342       cv.put(BrowserContract.Bookmarks.DATE_CREATED, rec.dateAdded);
343     }
344 
345     if (rec.tags == null) {
346       rec.tags = new JSONArray();
347     }
348 
349     // We might want to indicate that this record is to be inserted as "modified". Incoming records
350     // might be modified as we're processing them for insertion, and so should be re-uploaded.
351     // Our data provider layer manages versions, so we don't pass in localVersion explicitly.
352     if (rec.modifiedBySync) {
353       cv.put(BrowserContract.Bookmarks.PARAM_INSERT_FROM_SYNC_AS_MODIFIED, true);
354     }
355 
356     cv.put(BrowserContract.Bookmarks.TAGS,        rec.tags.toJSONString());
357     cv.put(BrowserContract.Bookmarks.KEYWORD,     rec.keyword);
358     cv.put(BrowserContract.Bookmarks.PARENT,      rec.androidParentID);
359     cv.put(BrowserContract.Bookmarks.POSITION,    rec.androidPosition);
360 
361     // Note that we don't set the modified timestamp: we allow the
362     // content provider to do that for us.
363     return cv;
364   }
365 
366   /**
367    * Returns a cursor over non-deleted records that list the given androidID as a parent.
368    */
getChildren(long androidID)369   public Cursor getChildren(long androidID) throws NullCursorException {
370     return getChildren(androidID, false);
371   }
372 
373   /**
374    * Returns a cursor with any records that list the given androidID as a parent.
375    * Excludes 'places', and optionally any deleted records.
376    */
getChildren(long androidID, boolean includeDeleted)377   public Cursor getChildren(long androidID, boolean includeDeleted) throws NullCursorException {
378     final String where = BrowserContract.Bookmarks.PARENT + " = ? AND " +
379                          BrowserContract.SyncColumns.GUID + " <> ? " +
380                          (!includeDeleted ? ("AND " + BrowserContract.SyncColumns.IS_DELETED + " = 0") : "");
381 
382     final String[] args = new String[] { String.valueOf(androidID), "places" };
383 
384     // Order by position, falling back on creation date and ID.
385     final String order = BrowserContract.Bookmarks.POSITION + ", " +
386                          BrowserContract.SyncColumns.DATE_CREATED + ", " +
387                          BrowserContract.Bookmarks._ID;
388     return queryHelper.safeQuery(".getChildren", getAllColumns(), where, args, order);
389   }
390 
391 
392   @Override
getAllColumns()393   protected String[] getAllColumns() {
394     return BrowserContractHelpers.BookmarkColumns;
395   }
396 }
397