| /******************************************************************************* |
| * 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 <b>does not allow modifications</b> to the collection in observers when |
| * responding to change events. |
| */ |
| public class ConversationSelectionSet implements Parcelable { |
| public static final ClassLoaderCreator<ConversationSelectionSet> CREATOR = |
| new ClassLoaderCreator<ConversationSelectionSet>() { |
| |
| @Override |
| public ConversationSelectionSet createFromParcel(Parcel source) { |
| return new ConversationSelectionSet(source, null); |
| } |
| |
| @Override |
| public ConversationSelectionSet createFromParcel(Parcel source, ClassLoader loader) { |
| return new ConversationSelectionSet(source, loader); |
| } |
| |
| @Override |
| public ConversationSelectionSet[] newArray(int size) { |
| return new ConversationSelectionSet[size]; |
| } |
| |
| }; |
| |
| private final Object mLock = new Object(); |
| /** Map of conversation ID to conversation objects. Every selected conversation is here. */ |
| private final HashMap<Long, Conversation> mInternalMap = new HashMap<Long, Conversation>(); |
| /** Map of Conversation URI to Conversation ID. */ |
| private final BiMap<String, Long> mConversationUriToIdMap = HashBiMap.create(); |
| /** All objects that are interested in changes to the selected set. */ |
| @VisibleForTesting |
| final Set<ConversationSetObserver> mObservers = new HashSet<ConversationSetObserver>(); |
| |
| /** |
| * Create a new object, |
| */ |
| public ConversationSelectionSet() { |
| // Do nothing. |
| } |
| |
| private ConversationSelectionSet(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<ConversationSetObserver> 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<ConversationSetObserver> observers) { |
| synchronized (mLock) { |
| for (ConversationSetObserver observer : observers) { |
| observer.onSetPopulated(this); |
| } |
| } |
| } |
| |
| private void dispatchOnChange(ArrayList<ConversationSetObserver> 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<ConversationSetObserver> 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<ConversationSetObserver> 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<Long> ids) { |
| synchronized (mLock) { |
| final boolean initiallyNotEmpty = !mInternalMap.isEmpty(); |
| |
| final BiMap<Long, String> inverseMap = mConversationUriToIdMap.inverse(); |
| |
| for (Long id : ids) { |
| mInternalMap.remove(id); |
| inverseMap.remove(id); |
| } |
| |
| ArrayList<ConversationSetObserver> 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<Conversation> values() { |
| synchronized (mLock) { |
| return mInternalMap.values(); |
| } |
| } |
| |
| /** @see java.util.HashMap#keySet() */ |
| public Set<Long> 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 <em>all</em> conversations to the selection |
| * set. |
| * @see java.util.HashMap#putAll(java.util.Map) |
| */ |
| public void putAll(ConversationSelectionSet other) { |
| if (other == null) { |
| return; |
| } |
| |
| final boolean initiallyEmpty = mInternalMap.isEmpty(); |
| mInternalMap.putAll(other.mInternalMap); |
| |
| final ArrayList<ConversationSetObserver> 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<Integer> 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<String> deletedConversations = cursor.getDeletedItems(); |
| // For each of the uris in the deleted set, add the conversation id to the |
| // itemsToRemoveFromBatch set. |
| final Set<Long> 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<Long> batchConversationToCheck = new HashSet<Long>(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<Long> 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); |
| } |
| } |
| } |