/******************************************************************************* * Copyright (C) 2012 Google Inc. * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. *******************************************************************************/ package com.android.mail.ui; import android.os.Parcel; import android.os.Parcelable; import com.android.mail.browse.ConversationCursor; import com.android.mail.providers.Conversation; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.BiMap; import com.google.common.collect.HashBiMap; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Set; /** * A simple thread-safe wrapper over a set of conversations representing a * selection set (e.g. in a conversation list). This class dispatches changes * when the set goes empty, and when it becomes unempty. For simplicity, this * class does not allow modifications to the collection in observers when * responding to change events. */ public class ConversationCheckedSet implements Parcelable { public static final ClassLoaderCreator CREATOR = new ClassLoaderCreator() { @Override public ConversationCheckedSet createFromParcel(Parcel source) { return new ConversationCheckedSet(source, null); } @Override public ConversationCheckedSet createFromParcel(Parcel source, ClassLoader loader) { return new ConversationCheckedSet(source, loader); } @Override public ConversationCheckedSet[] newArray(int size) { return new ConversationCheckedSet[size]; } }; private final Object mLock = new Object(); /** Map of conversation ID to conversation objects. Every selected conversation is here. */ private final HashMap mInternalMap = new HashMap(); /** Map of Conversation URI to Conversation ID. */ private final BiMap mConversationUriToIdMap = HashBiMap.create(); /** All objects that are interested in changes to the selected set. */ @VisibleForTesting final Set mObservers = new HashSet(); /** * Create a new object, */ public ConversationCheckedSet() { // Do nothing. } private ConversationCheckedSet(Parcel source, ClassLoader loader) { Parcelable[] conversations = source.readParcelableArray(loader); for (Parcelable parceled : conversations) { Conversation conversation = (Conversation) parceled; put(conversation.id, conversation); } } /** * Registers an observer to listen for interesting changes on this set. * * @param observer the observer to register. */ public void addObserver(ConversationSetObserver observer) { synchronized (mLock) { mObservers.add(observer); } } /** * Clear the selected set entirely. */ public void clear() { synchronized (mLock) { boolean initiallyNotEmpty = !mInternalMap.isEmpty(); mInternalMap.clear(); mConversationUriToIdMap.clear(); if (mInternalMap.isEmpty() && initiallyNotEmpty) { ArrayList observersCopy = Lists.newArrayList(mObservers); dispatchOnChange(observersCopy); dispatchOnEmpty(observersCopy); } } } /** * Returns true if the given key exists in the conversation selection set. This assumes * the internal representation holds conversation.id values. * @param key the id of the conversation * @return true if the key exists in this selected set. */ private boolean containsKey(Long key) { synchronized (mLock) { return mInternalMap.containsKey(key); } } /** * Returns true if the given conversation is stored in the selection set. * @param conversation * @return true if the conversation exists in the selected set. */ public boolean contains(Conversation conversation) { synchronized (mLock) { return containsKey(conversation.id); } } @Override public int describeContents() { return 0; } private void dispatchOnBecomeUnempty(ArrayList observers) { synchronized (mLock) { for (ConversationSetObserver observer : observers) { observer.onSetPopulated(this); } } } private void dispatchOnChange(ArrayList observers) { synchronized (mLock) { // Copy observers so that they may unregister themselves as listeners on // event handling. for (ConversationSetObserver observer : observers) { observer.onSetChanged(this); } } } private void dispatchOnEmpty(ArrayList observers) { synchronized (mLock) { for (ConversationSetObserver observer : observers) { observer.onSetEmpty(); } } } /** * Is this conversation set empty? * @return true if the conversation selection set is empty. False otherwise. */ public boolean isEmpty() { synchronized (mLock) { return mInternalMap.isEmpty(); } } private void put(Long id, Conversation info) { synchronized (mLock) { final boolean initiallyEmpty = mInternalMap.isEmpty(); mInternalMap.put(id, info); mConversationUriToIdMap.put(info.uri.toString(), id); final ArrayList observersCopy = Lists.newArrayList(mObservers); dispatchOnChange(observersCopy); if (initiallyEmpty) { dispatchOnBecomeUnempty(observersCopy); } } } /** @see java.util.HashMap#remove */ private void remove(Long id) { synchronized (mLock) { removeAll(Collections.singleton(id)); } } private void removeAll(Collection ids) { synchronized (mLock) { final boolean initiallyNotEmpty = !mInternalMap.isEmpty(); final BiMap inverseMap = mConversationUriToIdMap.inverse(); for (Long id : ids) { mInternalMap.remove(id); inverseMap.remove(id); } ArrayList observersCopy = Lists.newArrayList(mObservers); dispatchOnChange(observersCopy); if (mInternalMap.isEmpty() && initiallyNotEmpty) { dispatchOnEmpty(observersCopy); } } } /** * Unregisters an observer for change events. * * @param observer the observer to unregister. */ public void removeObserver(ConversationSetObserver observer) { synchronized (mLock) { mObservers.remove(observer); } } /** * Returns the number of conversations that are currently selected * @return the number of selected conversations. */ public int size() { synchronized (mLock) { return mInternalMap.size(); } } /** * Toggles the existence of the given conversation in the selection set. If the conversation is * currently selected, it is deselected. If it doesn't exist in the selection set, then it is * selected. * @param conversation */ public void toggle(Conversation conversation) { final long conversationId = conversation.id; if (containsKey(conversationId)) { // We must not do anything with view here. remove(conversationId); } else { put(conversationId, conversation); } } /** @see java.util.HashMap#values */ public Collection values() { synchronized (mLock) { return mInternalMap.values(); } } /** @see java.util.HashMap#keySet() */ public Set keySet() { synchronized (mLock) { return mInternalMap.keySet(); } } /** * Puts all conversations given in the input argument into the selection set. If there are * any listeners they are notified once after adding all conversations to the selection * set. * @see java.util.HashMap#putAll(java.util.Map) */ public void putAll(ConversationCheckedSet other) { if (other == null) { return; } final boolean initiallyEmpty = mInternalMap.isEmpty(); mInternalMap.putAll(other.mInternalMap); final ArrayList observersCopy = Lists.newArrayList(mObservers); dispatchOnChange(observersCopy); if (initiallyEmpty) { dispatchOnBecomeUnempty(observersCopy); } } @Override public void writeToParcel(Parcel dest, int flags) { Conversation[] values = values().toArray(new Conversation[size()]); dest.writeParcelableArray(values, flags); } /** * @param deletedRows an arraylist of conversation IDs which have been deleted. */ public void delete(ArrayList deletedRows) { for (long id : deletedRows) { remove(id); } } /** * Iterates through a cursor of conversations and ensures that the current set is present * within the result set denoted by the cursor. Any conversations not foun in the result set * is removed from the collection. */ public void validateAgainstCursor(ConversationCursor cursor) { synchronized (mLock) { if (isEmpty()) { return; } if (cursor == null) { clear(); return; } // First ask the ConversationCursor for the list of conversations that have been deleted final Set deletedConversations = cursor.getDeletedItems(); // For each of the uris in the deleted set, add the conversation id to the // itemsToRemoveFromBatch set. final Set itemsToRemoveFromBatch = Sets.newHashSet(); for (String conversationUri : deletedConversations) { final Long conversationId = mConversationUriToIdMap.get(conversationUri); if (conversationId != null) { itemsToRemoveFromBatch.add(conversationId); } } // Get the set of the items that had been in the batch final Set batchConversationToCheck = new HashSet(keySet()); // Remove all of the items that we know are missing. This will leave the items where // we need to check for existence in the cursor batchConversationToCheck.removeAll(itemsToRemoveFromBatch); // At this point batchConversationToCheck contains the conversation ids for the // conversations that had been in the batch selection, with the items we know have been // deleted removed. // This set contains the conversation ids that are in the conversation cursor final Set cursorConversationIds = cursor.getConversationIds(); // We want to remove all of the valid items that are in the conversation cursor, from // the batchConversations to check. The goal is after this block, anything remaining // would be items that don't exist in the conversation cursor anymore. if (!batchConversationToCheck.isEmpty() && cursorConversationIds != null) { batchConversationToCheck.removeAll(cursorConversationIds); } // At this point any of the item that are remaining in the batchConversationToCheck set // are to be removed from the selected conversation set itemsToRemoveFromBatch.addAll(batchConversationToCheck); removeAll(itemsToRemoveFromBatch); } } @Override public String toString() { synchronized (mLock) { return String.format("%s:%s", super.toString(), mInternalMap); } } }