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