/* 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 java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;
import org.json.simple.JSONArray;
import org.mozilla.gecko.R;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.sync.Logger;
import org.mozilla.gecko.sync.Utils;
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.delegates.RepositorySessionStoreDelegate;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionWipeDelegate;
import org.mozilla.gecko.sync.repositories.domain.BookmarkRecord;
import org.mozilla.gecko.sync.repositories.domain.Record;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
public class AndroidBrowserBookmarksRepositorySession extends AndroidBrowserRepositorySession
implements BookmarksInsertionManager.BookmarkInserter {
public static final int DEFAULT_DELETION_FLUSH_THRESHOLD = 50;
public static final int DEFAULT_INSERTION_FLUSH_THRESHOLD = 50;
// TODO: synchronization for these.
private HashMap<String, Long> parentGuidToIDMap = new HashMap<String, Long>();
private HashMap<Long, String> parentIDToGuidMap = new HashMap<Long, String>();
/**
* Some notes on reparenting/reordering.
*
* Fennec stores new items with a high-negative position, because it doesn't care.
* On the other hand, it also doesn't give us any help managing positions.
*
* We can process records and folders in any order, though we'll usually see folders
* first because their sortindex is larger.
*
* We can also see folders that refer to children we haven't seen, and children we
* won't see (perhaps due to a TTL, perhaps due to a limit on our fetch).
*
* And of course folders can refer to local children (including ones that might
* be reconciled into oblivion!), or local children in other folders. And the local
* version of a folder -- which might be a reconciling target, or might not -- can
* have local additions or removals. (That causes complications with on-the-fly
* reordering: we don't know in advance which records will even exist by the end
* of the sync.)
*
* We opt to leave records in a reasonable state as we go, applying reordering/
* reparenting operations whenever possible. A final sequence is applied after all
* incoming records have been handled.
*
* As such, we need to track a bunch of stuff as we go:
*
* • For each downloaded folder, the array of children. These will be server GUIDs,
* but not necessarily identical to the remote list: if we download a record and
* it's been locally moved, it must be removed from this child array.
*
* This mapping can be discarded when final reordering has occurred, either on
* store completion or when every child has been seen within this session.
*
* • A list of orphans: records whose parent folder does not yet exist. This can be
* trimmed as orphans are reparented.
*
* • Mappings from folder GUIDs to folder IDs, so that we can parent items without
* having to look in the DB. Of course, this must be kept up-to-date as we
* reconcile.
*
* Reordering also needs to occur during fetch. That is, a folder might have been
* created locally, or modified locally without any remote changes. An order must
* be generated for the folder's children array, and it must be persisted into the
* database to act as a starting point for future changes. But of course we don't
* want to incur a database write if the children already have a satisfactory order.
*
* Do we also need a list of "adopters", parents that are still waiting for children?
* As items get picked out of the orphans list, we can do on-the-fly ordering, until
* we're left with lonely records at the end.
*
* As we modify local folders, perhaps by moving children out of their purview, we
* must bump their modification time so as to cause them to be uploaded on the next
* stage of syncing. The same applies to simple reordering.
*/
// TODO: can we guarantee serial access to these?
private HashMap<String, ArrayList<String>> missingParentToChildren = new HashMap<String, ArrayList<String>>();
private HashMap<String, JSONArray> parentToChildArray = new HashMap<String, JSONArray>();
private int needsReparenting = 0;
private AndroidBrowserBookmarksDataAccessor dataAccessor;
protected BookmarksDeletionManager deletionManager;
protected BookmarksInsertionManager insertionManager;
/**
* An array of known-special GUIDs.
*/
public static String[] SPECIAL_GUIDS = new String[] {
// Mobile and desktop places roots have to come first.
"places",
"mobile",
"toolbar",
"menu",
"unfiled"
};
/**
* = A note about folder mapping =
*
* Note that _none_ of Places's folders actually have a special GUID. They're all
* randomly generated. Special folders are indicated by membership in the
* moz_bookmarks_roots table, and by having the parent `1`.
*
* Additionally, the mobile root is annotated. In Firefox Sync, PlacesUtils is
* used to find the IDs of these special folders.
*
* Sync skips over `places` and `tags` when finding IDs.
*
* We need to consume records with these various guids, producing a local
* representation which we are able to stably map upstream.
*
* That is:
*
* * We should not upload a `places` record or a `tags` record.
* * We can stably _store_ menu/toolbar/unfiled/mobile as special GUIDs, and set
* their parent ID as appropriate on upload.
*
* Fortunately, Fennec stores our representation of the data, not Places: that is,
* there's a "places" root, containing "mobile", "menu", "toolbar", etc.
*
* These are guaranteed to exist when the database is created.
*
* = Places folders =
*
* guid root_name folder_id parent
* ---------- ---------- ---------- ----------
* ? places 1 0
* ? menu 2 1
* ? toolbar 3 1
* ? tags 4 1
* ? unfiled 5 1
*
* ? mobile* 474 1
*
*
* = Fennec folders =
*
* guid folder_id parent
* ---------- ---------- ----------
* places 0 0
* mobile 1 0
* menu 2 0
* etc.
*
*/
public static final Map<String, String> SPECIAL_GUID_PARENTS;
static {
HashMap<String, String> m = new HashMap<String, String>();
m.put("places", null);
m.put("menu", "places");
m.put("toolbar", "places");
m.put("tags", "places");
m.put("unfiled", "places");
m.put("mobile", "places");
SPECIAL_GUID_PARENTS = Collections.unmodifiableMap(m);
}
/**
* A map of guids to their localized name strings.
*/
// Oh, if only we could make this final and initialize it in the static initializer.
public static Map<String, String> SPECIAL_GUIDS_MAP;
/**
* Return true if the provided record GUID should be skipped
* in child lists or fetch results.
*/
public static boolean forbiddenGUID(String recordGUID) {
return recordGUID == null ||
"readinglist".equals(recordGUID) || // Temporary: Bug 762118
"places".equals(recordGUID) ||
"tags".equals(recordGUID);
}
public AndroidBrowserBookmarksRepositorySession(Repository repository, Context context) {
super(repository);
if (SPECIAL_GUIDS_MAP == null) {
HashMap<String, String> m = new HashMap<String, String>();
// Note that we always use the literal name "mobile" for the Mobile Bookmarks
// folder, regardless of its actual name in the database or the Fennec UI.
// This is to match desktop (working around Bug 747699) and to avoid a similar
// issue locally. See Bug 748898.
m.put("mobile", "mobile");
// Other folders use their contextualized names, and we simply rely on
// these not changing, matching desktop, and such to avoid issues.
m.put("menu", context.getString(R.string.bookmarks_folder_menu));
m.put("places", context.getString(R.string.bookmarks_folder_places));
m.put("toolbar", context.getString(R.string.bookmarks_folder_toolbar));
m.put("unfiled", context.getString(R.string.bookmarks_folder_unfiled));
SPECIAL_GUIDS_MAP = Collections.unmodifiableMap(m);
}
dbHelper = new AndroidBrowserBookmarksDataAccessor(context);
dataAccessor = (AndroidBrowserBookmarksDataAccessor) dbHelper;
}
private static int getTypeFromCursor(Cursor cur) {
return RepoUtils.getIntFromCursor(cur, BrowserContract.Bookmarks.TYPE);
}
private static boolean rowIsFolder(Cursor cur) {
return getTypeFromCursor(cur) == BrowserContract.Bookmarks.TYPE_FOLDER;
}
private String getGUIDForID(long androidID) {
String guid = parentIDToGuidMap.get(androidID);
trace(" " + androidID + " => " + guid);
return guid;
}
private long getIDForGUID(String guid) {
Long id = parentGuidToIDMap.get(guid);
if (id == null) {
Logger.warn(LOG_TAG, "Couldn't find local ID for GUID " + guid);
return -1;
}
return id.longValue();
}
private String getGUID(Cursor cur) {
return RepoUtils.getStringFromCursor(cur, "guid");
}
private long getParentID(Cursor cur) {
return RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.PARENT);
}
// More efficient for bulk operations.
private long getPosition(Cursor cur, int positionIndex) {
return cur.getLong(positionIndex);
}
private long getPosition(Cursor cur) {
return RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.POSITION);
}
private String getParentName(String parentGUID) throws ParentNotFoundException, NullCursorException {
if (parentGUID == null) {
return "";
}
if (SPECIAL_GUIDS_MAP.containsKey(parentGUID)) {
return SPECIAL_GUIDS_MAP.get(parentGUID);
}
// Get parent name from database.
String parentName = "";
Cursor name = dataAccessor.fetch(new String[] { parentGUID });
try {
name.moveToFirst();
if (!name.isAfterLast()) {
parentName = RepoUtils.getStringFromCursor(name, BrowserContract.Bookmarks.TITLE);
}
else {
Logger.error(LOG_TAG, "Couldn't find record with guid '" + parentGUID + "' when looking for parent name.");
throw new ParentNotFoundException(null);
}
} finally {
name.close();
}
return parentName;
}
/**
* Retrieve the child array for a record, repositioning and updating the database as necessary.
*
* @param folderID
* The database ID of the folder.
* @param persist
* True if generated positions should be written to the database. The modified
* time of the parent folder is only bumped if this is true.
* @param childArray
* A new, empty JSONArray which will be populated with an array of GUIDs.
* @return
* True if the resulting array is "clean" (i.e., reflects the content of the database).
* @throws NullCursorException
*/
@SuppressWarnings("unchecked")
private boolean getChildrenArray(long folderID, boolean persist, JSONArray childArray) throws NullCursorException {
trace("Calling getChildren for androidID " + folderID);
Cursor children = dataAccessor.getChildren(folderID);
try {
if (!children.moveToFirst()) {
trace("No children: empty cursor.");
return true;
}
final int positionIndex = children.getColumnIndex(BrowserContract.Bookmarks.POSITION);
final int count = children.getCount();
Logger.debug(LOG_TAG, "Expecting " + count + " children.");
// Sorted by requested position.
TreeMap<Long, ArrayList<String>> guids = new TreeMap<Long, ArrayList<String>>();
while (!children.isAfterLast()) {
final String childGuid = getGUID(children);
final long childPosition = getPosition(children, positionIndex);
trace(" Child GUID: " + childGuid);
trace(" Child position: " + childPosition);
Utils.addToIndexBucketMap(guids, Math.abs(childPosition), childGuid);
children.moveToNext();
}
// This will suffice for taking a jumble of records and indices and
// producing a sorted sequence that preserves some kind of order --
// from the abs of the position, falling back on cursor order (that
// is, creation time and ID).
// Note that this code is not intended to merge values from two sources!
boolean changed = false;
int i = 0;
for (Entry<Long, ArrayList<String>> entry : guids.entrySet()) {
long pos = entry.getKey().longValue();
int atPos = entry.getValue().size();
// If every element has a different index, and the indices are
// in strict natural order, then changed will be false.
if (atPos > 1 || pos != i) {
changed = true;
}
++i;
for (String guid : entry.getValue()) {
if (!forbiddenGUID(guid)) {
childArray.add(guid);
}
}
}
if (Logger.shouldLogVerbose(LOG_TAG)) {
// Don't JSON-encode unless we're logging.
Logger.trace(LOG_TAG, "Output child array: " + childArray.toJSONString());
}
if (!changed) {
Logger.debug(LOG_TAG, "Nothing moved! Database reflects child array.");
return true;
}
if (!persist) {
Logger.debug(LOG_TAG, "Returned array does not match database, and not persisting.");
return false;
}
Logger.debug(LOG_TAG, "Generating child array required moving records. Updating DB.");
final long time = now();
if (0 < dataAccessor.updatePositions(childArray)) {
Logger.debug(LOG_TAG, "Bumping parent time to " + time + ".");
dataAccessor.bumpModified(folderID, time);
}
return true;
} finally {
children.close();
}
}
protected static boolean isDeleted(Cursor cur) {
return RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.IS_DELETED) != 0;
}
@Override
protected Record retrieveDuringStore(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
// During storing of a retrieved record, we never care about the children
// array that's already present in the database -- we don't use it for
// reconciling. Skip all that effort for now.
return retrieveRecord(cur, false);
}
@Override
protected Record retrieveDuringFetch(Cursor cur) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
return retrieveRecord(cur, true);
}
/**
* Build a record from a cursor, with a flag to dictate whether the
* children array should be computed and written back into the database.
*/
protected BookmarkRecord retrieveRecord(Cursor cur, boolean computeAndPersistChildren) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
String recordGUID = getGUID(cur);
Logger.trace(LOG_TAG, "Record from mirror cursor: " + recordGUID);
if (forbiddenGUID(recordGUID)) {
Logger.debug(LOG_TAG, "Ignoring " + recordGUID + " record in recordFromMirrorCursor.");
return null;
}
// Short-cut for deleted items.
if (isDeleted(cur)) {
return AndroidBrowserBookmarksRepositorySession.bookmarkFromMirrorCursor(cur, null, null, null);
}
long androidParentID = getParentID(cur);
// Ensure special folders stay in the right place.
String androidParentGUID = SPECIAL_GUID_PARENTS.get(recordGUID);
if (androidParentGUID == null) {
androidParentGUID = getGUIDForID(androidParentID);
}
boolean needsReparenting = false;
if (androidParentGUID == null) {
Logger.debug(LOG_TAG, "No parent GUID for record " + recordGUID + " with parent " + androidParentID);
// If the parent has been stored and somehow has a null GUID, throw an error.
if (parentIDToGuidMap.containsKey(androidParentID)) {
Logger.error(LOG_TAG, "Have the parent android ID for the record but the parent's GUID wasn't found.");
throw new NoGuidForIdException(null);
}
// We have a parent ID but it's wrong. If the record is deleted,
// we'll just say that it was in the Unsorted Bookmarks folder.
// If not, we'll move it into Mobile Bookmarks.
needsReparenting = true;
}
// If record is a folder, and we want to see children at this time, then build out the children array.
final JSONArray childArray;
if (computeAndPersistChildren) {
childArray = getChildrenArrayForRecordCursor(cur, recordGUID, true);
} else {
childArray = null;
}
String parentName = getParentName(androidParentGUID);
BookmarkRecord bookmark = AndroidBrowserBookmarksRepositorySession.bookmarkFromMirrorCursor(cur, androidParentGUID, parentName, childArray);
if (bookmark == null) {
Logger.warn(LOG_TAG, "Unable to extract bookmark from cursor. Record GUID " + recordGUID +
", parent " + androidParentGUID + "/" + androidParentID);
return null;
}
if (needsReparenting) {
Logger.warn(LOG_TAG, "Bookmark record " + recordGUID + " has a bad parent pointer. Reparenting now.");
String destination = bookmark.deleted ? "unfiled" : "mobile";
bookmark.androidParentID = getIDForGUID(destination);
bookmark.androidPosition = getPosition(cur);
bookmark.parentID = destination;
bookmark.parentName = getParentName(destination);
if (!bookmark.deleted) {
// Actually move it.
// TODO: compute position. Persist.
relocateBookmark(bookmark);
}
}
return bookmark;
}
/**
* Ensure that the local database row for the provided bookmark
* reflects this record's parent information.
*
* @param bookmark
*/
private void relocateBookmark(BookmarkRecord bookmark) {
dataAccessor.updateParentAndPosition(bookmark.guid, bookmark.androidParentID, bookmark.androidPosition);
}
protected JSONArray getChildrenArrayForRecordCursor(Cursor cur, String recordGUID, boolean persist) throws NullCursorException {
boolean isFolder = rowIsFolder(cur);
if (!isFolder) {
return null;
}
long androidID = parentGuidToIDMap.get(recordGUID);
JSONArray childArray = new JSONArray();
getChildrenArray(androidID, persist, childArray);
Logger.debug(LOG_TAG, "Fetched " + childArray.size() + " children for " + recordGUID);
return childArray;
}
@Override
public boolean shouldIgnore(Record record) {
if (!(record instanceof BookmarkRecord)) {
return true;
}
if (record.deleted) {
return false;
}
BookmarkRecord bmk = (BookmarkRecord) record;
if (forbiddenGUID(bmk.guid)) {
Logger.debug(LOG_TAG, "Ignoring forbidden record with guid: " + bmk.guid);
return true;
}
if ("readinglist".equals(bmk.parentID)) { // Temporary: Bug 762118
Logger.debug(LOG_TAG, "Ignoring reading list item with guid: " + bmk.guid);
return true;
}
if (BrowserContractHelpers.isSupportedType(bmk.type)) {
return false;
}
Logger.debug(LOG_TAG, "Ignoring record with guid: " + bmk.guid + " and type: " + bmk.type);
return true;
}
@Override
public void begin(RepositorySessionBeginDelegate delegate) throws InvalidSessionTransitionException {
// Check for the existence of special folders
// and insert them if they don't exist.
Cursor cur;
try {
Logger.debug(LOG_TAG, "Check and build special GUIDs.");
dataAccessor.checkAndBuildSpecialGuids();
cur = dataAccessor.getGuidsIDsForFolders();
Logger.debug(LOG_TAG, "Got GUIDs for folders.");
} catch (android.database.sqlite.SQLiteConstraintException e) {
Logger.error(LOG_TAG, "Got sqlite constraint exception working with Fennec bookmark DB.", e);
delegate.onBeginFailed(e);
return;
} catch (NullCursorException e) {
delegate.onBeginFailed(e);
return;
} catch (Exception e) {
delegate.onBeginFailed(e);
return;
}
// To deal with parent mapping of bookmarks we have to do some
// hairy stuff. Here's the setup for it.
Logger.debug(LOG_TAG, "Preparing folder ID mappings.");
// Fake our root.
Logger.debug(LOG_TAG, "Tracking places root as ID 0.");
parentIDToGuidMap.put(0L, "places");
parentGuidToIDMap.put("places", 0L);
try {
cur.moveToFirst();
while (!cur.isAfterLast()) {
String guid = getGUID(cur);
long id = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks._ID);
parentGuidToIDMap.put(guid, id);
parentIDToGuidMap.put(id, guid);
Logger.debug(LOG_TAG, "GUID " + guid + " maps to " + id);
cur.moveToNext();
}
} finally {
cur.close();
}
deletionManager = new BookmarksDeletionManager(dataAccessor, DEFAULT_DELETION_FLUSH_THRESHOLD);
// We just crawled the database enumerating all folders; we'll start the
// insertion manager with exactly these folders as the known parents (the
// collection is copied) in the manager constructor.
insertionManager = new BookmarksInsertionManager(DEFAULT_INSERTION_FLUSH_THRESHOLD, parentGuidToIDMap.keySet(), this);
Logger.debug(LOG_TAG, "Done with initial setup of bookmarks session.");
super.begin(delegate);
}
/**
* Implement method of BookmarksInsertionManager.BookmarkInserter.
*/
@Override
public boolean insertFolder(BookmarkRecord record) {
// A folder that is *not* deleted needs its androidID updated, so that
// updateBookkeeping can re-parent, etc.
Record toStore = prepareRecord(record);
try {
Uri recordURI = dbHelper.insert(toStore);
if (recordURI == null) {
delegate.onRecordStoreFailed(new RuntimeException("Got null URI inserting folder with guid " + toStore.guid + "."), record.guid);
return false;
}
toStore.androidID = ContentUris.parseId(recordURI);
Logger.debug(LOG_TAG, "Inserted folder with guid " + toStore.guid + " as androidID " + toStore.androidID);
updateBookkeeping(toStore);
} catch (Exception e) {
delegate.onRecordStoreFailed(e, record.guid);
return false;
}
trackRecord(toStore);
delegate.onRecordStoreSucceeded(record.guid);
return true;
}
/**
* Implement method of BookmarksInsertionManager.BookmarkInserter.
*/
@Override
public void bulkInsertNonFolders(Collection<BookmarkRecord> records) {
// All of these records are *not* deleted and *not* folders, so we don't
// need to update androidID at all!
// TODO: persist records that fail to insert for later retry.
ArrayList<Record> toStores = new ArrayList<Record>(records.size());
for (Record record : records) {
toStores.add(prepareRecord(record));
}
try {
int stored = dataAccessor.bulkInsert(toStores);
if (stored != toStores.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 (Record failed : toStores) {
delegate.onRecordStoreFailed(new RuntimeException("Possibly failed to bulkInsert non-folder with guid " + failed.guid + "."), failed.guid);
}
return;
}
} catch (NullCursorException e) {
for (Record failed : toStores) {
delegate.onRecordStoreFailed(e, failed.guid);
}
return;
}
// Success For All!
for (Record succeeded : toStores) {
try {
updateBookkeeping(succeeded);
} catch (Exception e) {
Logger.warn(LOG_TAG, "Got exception updating bookkeeping of non-folder with guid " + succeeded.guid + ".", e);
}
trackRecord(succeeded);
delegate.onRecordStoreSucceeded(succeeded.guid);
}
}
@Override
public void finish(RepositorySessionFinishDelegate delegate) throws InactiveSessionException {
// Allow these to be GCed.
deletionManager = null;
insertionManager = null;
// Override finish to do this check; make sure all records
// needing re-parenting have been re-parented.
if (needsReparenting != 0) {
Logger.error(LOG_TAG, "Finish called but " + needsReparenting +
" bookmark(s) have been placed in unsorted bookmarks and not been reparented.");
// TODO: handling of failed reparenting.
// E.g., delegate.onFinishFailed(new BookmarkNeedsReparentingException(null));
}
super.finish(delegate);
};
@Override
public void setStoreDelegate(RepositorySessionStoreDelegate delegate) {
super.setStoreDelegate(delegate);
if (deletionManager != null) {
deletionManager.setDelegate(delegate);
}
}
@Override
protected Record reconcileRecords(Record remoteRecord, Record localRecord,
long lastRemoteRetrieval,
long lastLocalRetrieval) {
BookmarkRecord reconciled = (BookmarkRecord) super.reconcileRecords(remoteRecord, localRecord,
lastRemoteRetrieval,
lastLocalRetrieval);
// For now we *always* use the remote record's children array as a starting point.
// We won't write it into the database yet; we'll record it and process as we go.
reconciled.children = ((BookmarkRecord) remoteRecord).children;
// *Always* track folders, though: if we decide we need to reposition items, we'll
// untrack later.
if (reconciled.isFolder()) {
trackRecord(reconciled);
}
return reconciled;
}
/**
* Rename mobile folders to "mobile", both in and out. The other half of
* this logic lives in {@link #computeParentFields(BookmarkRecord, String, String)}, where
* the parent name of a record is set from {@link #SPECIAL_GUIDS_MAP} rather than
* from source data.
*
* Apply this approach generally for symmetry.
*/
@Override
protected void fixupRecord(Record record) {
final BookmarkRecord r = (BookmarkRecord) record;
final String parentName = SPECIAL_GUIDS_MAP.get(r.parentID);
if (parentName == null) {
return;
}
if (Logger.shouldLogVerbose(LOG_TAG)) {
Logger.trace(LOG_TAG, "Replacing parent name \"" + r.parentName + "\" with \"" + parentName + "\".");
}
r.parentName = parentName;
}
@Override
protected Record prepareRecord(Record record) {
if (record.deleted) {
Logger.debug(LOG_TAG, "No need to prepare deleted record " + record.guid);
return record;
}
BookmarkRecord bmk = (BookmarkRecord) record;
if (!isSpecialRecord(record)) {
// We never want to reparent special records.
handleParenting(bmk);
}
if (Logger.LOG_PERSONAL_INFORMATION) {
if (bmk.isFolder()) {
Logger.pii(LOG_TAG, "Inserting folder " + bmk.guid + ", " + bmk.title +
" with parent " + bmk.androidParentID +
" (" + bmk.parentID + ", " + bmk.parentName +
", " + bmk.androidPosition + ")");
} else {
Logger.pii(LOG_TAG, "Inserting bookmark " + bmk.guid + ", " + bmk.title + ", " +
bmk.bookmarkURI + " with parent " + bmk.androidParentID +
" (" + bmk.parentID + ", " + bmk.parentName +
", " + bmk.androidPosition + ")");
}
} else {
if (bmk.isFolder()) {
Logger.debug(LOG_TAG, "Inserting folder " + bmk.guid + ", parent " +
bmk.androidParentID +
" (" + bmk.parentID + ", " + bmk.androidPosition + ")");
} else {
Logger.debug(LOG_TAG, "Inserting bookmark " + bmk.guid + " with parent " +
bmk.androidParentID +
" (" + bmk.parentID + ", " + ", " + bmk.androidPosition + ")");
}
}
return bmk;
}
/**
* If the provided record doesn't have correct parent information,
* update appropriate bookkeeping to improve the situation.
*
* @param bmk
*/
private void handleParenting(BookmarkRecord bmk) {
if (parentGuidToIDMap.containsKey(bmk.parentID)) {
bmk.androidParentID = parentGuidToIDMap.get(bmk.parentID);
// Might as well set a basic position from the downloaded children array.
JSONArray children = parentToChildArray.get(bmk.parentID);
if (children != null) {
int index = children.indexOf(bmk.guid);
if (index >= 0) {
bmk.androidPosition = index;
}
}
}
else {
bmk.androidParentID = parentGuidToIDMap.get("unfiled");
ArrayList<String> children;
if (missingParentToChildren.containsKey(bmk.parentID)) {
children = missingParentToChildren.get(bmk.parentID);
} else {
children = new ArrayList<String>();
}
children.add(bmk.guid);
needsReparenting++;
missingParentToChildren.put(bmk.parentID, children);
}
}
private boolean isSpecialRecord(Record record) {
return SPECIAL_GUID_PARENTS.containsKey(record.guid);
}
@Override
protected void updateBookkeeping(Record record) throws NoGuidForIdException,
NullCursorException,
ParentNotFoundException {
super.updateBookkeeping(record);
BookmarkRecord bmk = (BookmarkRecord) record;
// If record is folder, update maps and re-parent children if necessary.
if (!bmk.isFolder()) {
Logger.debug(LOG_TAG, "Not a folder. No bookkeeping.");
return;
}
Logger.debug(LOG_TAG, "Updating bookkeeping for folder " + record.guid);
// Mappings between ID and GUID.
// TODO: update our persisted children arrays!
// TODO: if our Android ID just changed, replace parents for all of our children.
parentGuidToIDMap.put(bmk.guid, bmk.androidID);
parentIDToGuidMap.put(bmk.androidID, bmk.guid);
JSONArray childArray = bmk.children;
if (Logger.shouldLogVerbose(LOG_TAG)) {
Logger.trace(LOG_TAG, bmk.guid + " has children " + childArray.toJSONString());
}
parentToChildArray.put(bmk.guid, childArray);
// Re-parent.
if (missingParentToChildren.containsKey(bmk.guid)) {
for (String child : missingParentToChildren.get(bmk.guid)) {
// This might return -1; that's OK, the bookmark will
// be properly repositioned later.
long position = childArray.indexOf(child);
dataAccessor.updateParentAndPosition(child, bmk.androidID, position);
needsReparenting--;
}
missingParentToChildren.remove(bmk.guid);
}
}
@Override
protected void insert(Record record) throws NoGuidForIdException, NullCursorException, ParentNotFoundException {
try {
insertionManager.enqueueRecord((BookmarkRecord) record);
} catch (Exception e) {
throw new NullCursorException(e);
}
}
@Override
protected void storeRecordDeletion(final Record record, final Record existingRecord) {
if (SPECIAL_GUIDS_MAP.containsKey(record.guid)) {
Logger.debug(LOG_TAG, "Told to delete record " + record.guid + ". Ignoring.");
return;
}
final BookmarkRecord bookmarkRecord = (BookmarkRecord) record;
final BookmarkRecord existingBookmark = (BookmarkRecord) existingRecord;
final boolean isFolder = existingBookmark.isFolder();
final String parentGUID = existingBookmark.parentID;
deletionManager.deleteRecord(bookmarkRecord.guid, isFolder, parentGUID);
}
protected void flushQueues() {
long now = now();
Logger.debug(LOG_TAG, "Applying remaining insertions.");
try {
insertionManager.finishUp();
Logger.debug(LOG_TAG, "Done applying remaining insertions.");
} catch (Exception e) {
Logger.warn(LOG_TAG, "Unable to apply remaining insertions.", e);
}
Logger.debug(LOG_TAG, "Applying deletions.");
try {
untrackGUIDs(deletionManager.flushAll(getIDForGUID("unfiled"), now));
Logger.debug(LOG_TAG, "Done applying deletions.");
} catch (Exception e) {
Logger.error(LOG_TAG, "Unable to apply deletions.", e);
}
}
@SuppressWarnings("unchecked")
private void finishUp() {
try {
flushQueues();
Logger.debug(LOG_TAG, "Have " + parentToChildArray.size() + " folders whose children might need repositioning.");
for (Entry<String, JSONArray> entry : parentToChildArray.entrySet()) {
String guid = entry.getKey();
JSONArray onServer = entry.getValue();
try {
final long folderID = getIDForGUID(guid);
final JSONArray inDB = new JSONArray();
final boolean clean = getChildrenArray(folderID, false, inDB);
final boolean sameArrays = Utils.sameArrays(onServer, inDB);
// If the local children and the remote children are already
// the same, then we don't need to bump the modified time of the
// parent: we wouldn't upload a different record, so avoid the cycle.
if (!sameArrays) {
int added = 0;
for (Object o : inDB) {
if (!onServer.contains(o)) {
onServer.add(o);
added++;
}
}
Logger.debug(LOG_TAG, "Added " + added + " items locally.");
Logger.debug(LOG_TAG, "Untracking and bumping " + guid + "(" + folderID + ")");
dataAccessor.bumpModified(folderID, now());
untrackGUID(guid);
}
// If the arrays are different, or they're the same but not flushed to disk,
// write them out now.
if (!sameArrays || !clean) {
dataAccessor.updatePositions(new ArrayList<String>(onServer));
}
} catch (Exception e) {
Logger.warn(LOG_TAG, "Error repositioning children for " + guid, e);
}
}
} finally {
super.storeDone();
}
}
/**
* Hook into the deletion manager on wipe.
*/
class BookmarkWipeRunnable extends WipeRunnable {
public BookmarkWipeRunnable(RepositorySessionWipeDelegate delegate) {
super(delegate);
}
@Override
public void run() {
try {
// Clear our queued deletions.
deletionManager.clear();
insertionManager.clear();
super.run();
} catch (Exception ex) {
delegate.onWipeFailed(ex);
return;
}
}
}
@Override
protected WipeRunnable getWipeRunnable(RepositorySessionWipeDelegate delegate) {
return new BookmarkWipeRunnable(delegate);
}
@Override
public void storeDone() {
Runnable command = new Runnable() {
@Override
public void run() {
finishUp();
}
};
storeWorkQueue.execute(command);
}
@Override
protected String buildRecordString(Record record) {
BookmarkRecord bmk = (BookmarkRecord) record;
String parent = bmk.parentName + "/";
if (bmk.isBookmark()) {
return "b" + parent + bmk.bookmarkURI + ":" + bmk.title;
}
if (bmk.isFolder()) {
return "f" + parent + bmk.title;
}
if (bmk.isSeparator()) {
return "s" + parent + bmk.androidPosition;
}
if (bmk.isQuery()) {
return "q" + parent + bmk.bookmarkURI;
}
return null;
}
public static BookmarkRecord computeParentFields(BookmarkRecord rec, String suggestedParentGUID, String suggestedParentName) {
final String guid = rec.guid;
if (guid == null) {
// Oh dear.
Logger.error(LOG_TAG, "No guid in computeParentFields!");
return null;
}
String realParent = SPECIAL_GUID_PARENTS.get(guid);
if (realParent == null) {
// No magic parent. Use whatever the caller suggests.
realParent = suggestedParentGUID;
} else {
Logger.debug(LOG_TAG, "Ignoring suggested parent ID " + suggestedParentGUID +
" for " + guid + "; using " + realParent);
}
if (realParent == null) {
// Oh dear.
Logger.error(LOG_TAG, "No parent for record " + guid);
return null;
}
// Always set the parent name for special folders back to default.
String parentName = SPECIAL_GUIDS_MAP.get(realParent);
if (parentName == null) {
parentName = suggestedParentName;
}
rec.parentID = realParent;
rec.parentName = parentName;
return rec;
}
private static BookmarkRecord logBookmark(BookmarkRecord rec) {
try {
Logger.debug(LOG_TAG, "Returning " + (rec.deleted ? "deleted " : "") +
"bookmark record " + rec.guid + " (" + rec.androidID +
", parent " + rec.parentID + ")");
if (!rec.deleted && Logger.LOG_PERSONAL_INFORMATION) {
Logger.pii(LOG_TAG, "> Parent name: " + rec.parentName);
Logger.pii(LOG_TAG, "> Title: " + rec.title);
Logger.pii(LOG_TAG, "> Type: " + rec.type);
Logger.pii(LOG_TAG, "> URI: " + rec.bookmarkURI);
Logger.pii(LOG_TAG, "> Position: " + rec.androidPosition);
if (rec.isFolder()) {
Logger.pii(LOG_TAG, "FOLDER: Children are " +
(rec.children == null ?
"null" :
rec.children.toJSONString()));
}
}
} catch (Exception e) {
Logger.debug(LOG_TAG, "Exception logging bookmark record " + rec, e);
}
return rec;
}
// Create a BookmarkRecord object from a cursor on a row containing a Fennec bookmark.
public static BookmarkRecord bookmarkFromMirrorCursor(Cursor cur, String parentGUID, String parentName, JSONArray children) {
final String collection = "bookmarks";
final String guid = RepoUtils.getStringFromCursor(cur, BrowserContract.SyncColumns.GUID);
final long lastModified = RepoUtils.getLongFromCursor(cur, BrowserContract.SyncColumns.DATE_MODIFIED);
final boolean deleted = isDeleted(cur);
BookmarkRecord rec = new BookmarkRecord(guid, collection, lastModified, deleted);
// No point in populating it.
if (deleted) {
return logBookmark(rec);
}
int rowType = getTypeFromCursor(cur);
String typeString = BrowserContractHelpers.typeStringForCode(rowType);
if (typeString == null) {
Logger.warn(LOG_TAG, "Unsupported type code " + rowType);
return null;
} else {
Logger.trace(LOG_TAG, "Record " + guid + " has type " + typeString);
}
rec.type = typeString;
rec.title = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.TITLE);
rec.bookmarkURI = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.URL);
rec.description = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.DESCRIPTION);
rec.tags = RepoUtils.getJSONArrayFromCursor(cur, BrowserContract.Bookmarks.TAGS);
rec.keyword = RepoUtils.getStringFromCursor(cur, BrowserContract.Bookmarks.KEYWORD);
rec.androidID = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks._ID);
rec.androidPosition = RepoUtils.getLongFromCursor(cur, BrowserContract.Bookmarks.POSITION);
rec.children = children;
// Need to restore the parentId since it isn't stored in content provider.
// We also take this opportunity to fix up parents for special folders,
// allowing us to map between the hierarchies used by Fennec and Places.
BookmarkRecord withParentFields = computeParentFields(rec, parentGUID, parentName);
if (withParentFields == null) {
// Oh dear. Something went wrong.
return null;
}
return logBookmark(withParentFields);
}
}