/* 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.HashSet;
import java.util.Map;
import java.util.Set;
import org.mozilla.gecko.db.BrowserContract;
import org.mozilla.gecko.sync.Logger;
import org.mozilla.gecko.sync.repositories.NullCursorException;
import org.mozilla.gecko.sync.repositories.delegates.RepositorySessionStoreDelegate;
/**
* Queue up deletions. Process them at the end.
*
* Algorithm:
*
* * Collect GUIDs as we go. For convenience we partition these into
* folders and non-folders.
*
* * Non-folders can be deleted in batches as we go.
*
* * At the end of the sync:
* * Delete all that aren't folders.
* * Move the remaining children of any that are folders to an "Orphans" folder.
* - We do this even for children that are _marked_ as deleted -- we still want
* to upload them, and their parent is irrelevant.
* * Delete all the folders.
*
* * Any outstanding records -- the ones we moved to "Orphans" -- are true orphans.
* These should be reuploaded (because their parent has changed), as should their
* new parent (because its children array has changed).
* We achieve the former by moving them without tracking (but we don't make any
* special effort here -- warning! Lurking bug!).
* We achieve the latter by bumping its mtime. The caller should take care of untracking it.
*
* Note that we make no particular effort to handle repositioning or reparenting:
* batching deletes at the end should be handled seamlessly by existing code,
* because the deleted records could have arrived in a batch at the end regardless.
*
* Note that this class is not thread safe. This should be fine: call it only
* from within a store runnable.
*
*/
public class BookmarksDeletionManager {
private static final String LOG_TAG = "BookmarkDelete";
private final AndroidBrowserBookmarksDataAccessor dataAccessor;
private RepositorySessionStoreDelegate delegate;
private final int flushThreshold;
private final HashSet<String> folders = new HashSet<String>();
private final HashSet<String> nonFolders = new HashSet<String>();
private int nonFolderCount = 0;
// Records that we need to touch once we've deleted the non-folders.
private HashSet<String> nonFolderParents = new HashSet<String>();
private HashSet<String> folderParents = new HashSet<String>();
/**
* Create an instance to be used for tracking deletions in a bookmarks
* repository session.
*
* @param dataAccessor
* Used to effect database changes.
*
* @param flushThreshold
* When this many non-folder records have been stored for deletion,
* an incremental flush occurs.
*/
public BookmarksDeletionManager(AndroidBrowserBookmarksDataAccessor dataAccessor, int flushThreshold) {
this.dataAccessor = dataAccessor;
this.flushThreshold = flushThreshold;
}
/**
* Set the delegate to use for callbacks.
* If not invoked, no callbacks will be submitted.
*
* @param delegate a delegate, which should already be a delayed delegate.
*/
public void setDelegate(RepositorySessionStoreDelegate delegate) {
this.delegate = delegate;
}
public void deleteRecord(String guid, boolean isFolder, String parentGUID) {
if (guid == null) {
Logger.warn(LOG_TAG, "Cannot queue deletion of record with no GUID.");
return;
}
Logger.debug(LOG_TAG, "Queuing deletion of " + guid);
if (isFolder) {
folders.add(guid);
if (!folders.contains(parentGUID)) {
// We're not going to delete its parent; will need to bump it.
folderParents.add(parentGUID);
}
nonFolderParents.remove(guid);
folderParents.remove(guid);
return;
}
if (!folders.contains(parentGUID)) {
// We're not going to delete its parent; will need to bump it.
nonFolderParents.add(parentGUID);
}
if (nonFolders.add(guid)) {
if (++nonFolderCount >= flushThreshold) {
deleteNonFolders();
}
}
}
/**
* Flush deletions that can be easily taken care of right now.
*/
public void incrementalFlush() {
// Yes, this means we only bump when we finish, not during an incremental flush.
deleteNonFolders();
}
/**
* Apply all pending deletions and reset state for the next batch of stores.
*
* @param orphanDestination the ID of the folder to which orphaned children
* should be moved.
*
* @throws NullCursorException
* @return a set of IDs to untrack. Will not be null.
*/
public Set<String> flushAll(long orphanDestination, long now) throws NullCursorException {
Logger.debug(LOG_TAG, "Doing complete flush of deleted items. Moving orphans to " + orphanDestination);
deleteNonFolders();
// Find out which parents *won't* be deleted, and thus need to have their
// modified times bumped.
nonFolderParents.removeAll(folders);
Logger.debug(LOG_TAG, "Bumping modified times for " + nonFolderParents.size() +
" parents of deleted non-folders.");
dataAccessor.bumpModifiedByGUID(nonFolderParents, now);
if (folders.size() > 0) {
final String[] folderGUIDs = folders.toArray(new String[folders.size()]);
final String[] folderIDs = getIDs(folderGUIDs); // Throws if any don't exist.
int moved = dataAccessor.moveChildren(folderIDs, orphanDestination);
if (moved > 0) {
dataAccessor.bumpModified(orphanDestination, now);
}
// We've deleted or moved anything that might be under these folders.
// Just delete them.
final String folderWhere = RepoUtils.computeSQLInClause(folders.size(), BrowserContract.Bookmarks.GUID);
dataAccessor.delete(folderWhere, folderGUIDs);
invokeCallbacks(delegate, folderGUIDs);
folderParents.removeAll(folders);
Logger.debug(LOG_TAG, "Bumping modified times for " + folderParents.size() +
" parents of deleted folders.");
dataAccessor.bumpModifiedByGUID(folderParents, now);
// Clean up.
folders.clear();
}
HashSet<String> ret = nonFolderParents;
ret.addAll(folderParents);
nonFolderParents = new HashSet<String>();
folderParents = new HashSet<String>();
return ret;
}
private String[] getIDs(String[] guids) throws NullCursorException {
// Convert GUIDs to numeric IDs.
String[] ids = new String[guids.length];
Map<String, Long> guidsToIDs = dataAccessor.idsForGUIDs(guids);
for (int i = 0; i < guids.length; ++i) {
String guid = guids[i];
Long id = guidsToIDs.get(guid);
if (id == null) {
throw new IllegalArgumentException("Can't get ID for unknown record " + guid);
}
ids[i] = id.toString();
}
return ids;
}
/**
* Flush non-folder deletions. This can be called at any time.
*/
private void deleteNonFolders() {
if (nonFolderCount == 0) {
Logger.debug(LOG_TAG, "No non-folders to delete.");
return;
}
Logger.debug(LOG_TAG, "Applying deletion of " + nonFolderCount + " non-folders.");
final String[] nonFolderGUIDs = nonFolders.toArray(new String[nonFolderCount]);
final String nonFolderWhere = RepoUtils.computeSQLInClause(nonFolderCount, BrowserContract.Bookmarks.GUID);
dataAccessor.delete(nonFolderWhere, nonFolderGUIDs);
invokeCallbacks(delegate, nonFolderGUIDs);
// Discard these.
// Note that we maintain folderParents and nonFolderParents; we need them later.
nonFolders.clear();
nonFolderCount = 0;
}
private void invokeCallbacks(RepositorySessionStoreDelegate delegate,
String[] nonFolderGUIDs) {
if (delegate == null) {
return;
}
Logger.trace(LOG_TAG, "Invoking store callback for " + nonFolderGUIDs.length + " GUIDs.");
for (String guid : nonFolderGUIDs) {
delegate.onRecordStoreSucceeded(guid);
}
}
/**
* Clear state in case of redundancy (e.g., wipe).
*/
public void clear() {
nonFolders.clear();
nonFolderCount = 0;
folders.clear();
nonFolderParents.clear();
folderParents.clear();
}
}