ConversationSelectionSet.java revision 0a22d4482396f3717b36796e594d5f8e9760d509
1/*******************************************************************************
2 *      Copyright (C) 2012 Google Inc.
3 *      Licensed to The Android Open Source Project.
4 *
5 *      Licensed under the Apache License, Version 2.0 (the "License");
6 *      you may not use this file except in compliance with the License.
7 *      You may obtain a copy of the License at
8 *
9 *           http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *      Unless required by applicable law or agreed to in writing, software
12 *      distributed under the License is distributed on an "AS IS" BASIS,
13 *      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *      See the License for the specific language governing permissions and
15 *      limitations under the License.
16 *******************************************************************************/
17
18package com.android.mail.ui;
19
20import com.google.common.annotations.VisibleForTesting;
21import com.google.common.collect.BiMap;
22import com.google.common.collect.HashBiMap;
23import com.google.common.collect.Lists;
24import com.google.common.collect.Sets;
25
26import android.os.Parcel;
27import android.os.Parcelable;
28
29import com.android.mail.browse.ConversationItemView;
30import com.android.mail.browse.ConversationCursor;
31import com.android.mail.providers.Conversation;
32import com.android.mail.utils.Utils;
33
34import java.util.ArrayList;
35import java.util.Collection;
36import java.util.Collections;
37import java.util.HashMap;
38import java.util.HashSet;
39import java.util.Set;
40
41/**
42 * A simple thread-safe wrapper over a set of conversations representing a
43 * selection set (e.g. in a conversation list). This class dispatches changes
44 * when the set goes empty, and when it becomes unempty. For simplicity, this
45 * class <b>does not allow modifications</b> to the collection in observers when
46 * responding to change events.
47 */
48public class ConversationSelectionSet implements Parcelable {
49    public static final Parcelable.Creator<ConversationSelectionSet> CREATOR =
50            new Parcelable.Creator<ConversationSelectionSet>() {
51
52        @Override
53        public ConversationSelectionSet createFromParcel(Parcel source) {
54            ConversationSelectionSet result = new ConversationSelectionSet();
55            Parcelable[] conversations = source.readParcelableArray(
56                            Conversation.class.getClassLoader());
57            for (Parcelable parceled : conversations) {
58                Conversation conversation = (Conversation) parceled;
59                result.put(conversation.id, conversation);
60            }
61            return result;
62        }
63
64        @Override
65        public ConversationSelectionSet[] newArray(int size) {
66            return new ConversationSelectionSet[size];
67        }
68    };
69
70    private final Object mLock = new Object();
71    private final HashMap<Long, Conversation> mInternalMap =
72            new HashMap<Long, Conversation>();
73
74    /**
75     * Map of conversation IDs to {@link ConversationItemView} objects. The views are <b>not</b>
76     * updated when a new list view object is created on orientation change.
77     */
78    private final HashMap<Long, ConversationItemView> mInternalViewMap =
79            new HashMap<Long, ConversationItemView>();
80    private final BiMap<String, Long> mConversationUriToIdMap = HashBiMap.create();
81
82    @VisibleForTesting
83    final ArrayList<ConversationSetObserver> mObservers = new ArrayList<ConversationSetObserver>();
84
85    /**
86     * Registers an observer to listen for interesting changes on this set.
87     *
88     * @param observer the observer to register.
89     */
90    public void addObserver(ConversationSetObserver observer) {
91        synchronized (mLock) {
92            mObservers.add(observer);
93        }
94    }
95
96    /**
97     * Clear the selected set entirely.
98     */
99    public void clear() {
100        synchronized (mLock) {
101            boolean initiallyNotEmpty = !mInternalMap.isEmpty();
102            mInternalViewMap.clear();
103            mInternalMap.clear();
104            mConversationUriToIdMap.clear();
105
106            if (mInternalMap.isEmpty() && initiallyNotEmpty) {
107                ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
108                dispatchOnChange(observersCopy);
109                dispatchOnEmpty(observersCopy);
110            }
111        }
112    }
113
114    /**
115     * Returns true if the given key exists in the conversation selection set. This assumes
116     * the internal representation holds conversation.id values.
117     * @param key the id of the conversation
118     * @return true if the key exists in this selected set.
119     */
120    public boolean containsKey(Long key) {
121        synchronized (mLock) {
122            return mInternalMap.containsKey(key);
123        }
124    }
125
126    /**
127     * Returns true if the given conversation is stored in the selection set.
128     * @param conversation
129     * @return true if the conversation exists in the selected set.
130     */
131    public boolean contains(Conversation conversation) {
132        synchronized (mLock) {
133            return containsKey(conversation.id);
134        }
135    }
136
137    @Override
138    public int describeContents() {
139        return 0;
140    }
141
142    private void dispatchOnBecomeUnempty(ArrayList<ConversationSetObserver> observers) {
143        synchronized (mLock) {
144            for (ConversationSetObserver observer : observers) {
145                observer.onSetPopulated(this);
146            }
147        }
148    }
149
150    private void dispatchOnChange(ArrayList<ConversationSetObserver> observers) {
151        synchronized (mLock) {
152            // Copy observers so that they may unregister themselves as listeners on
153            // event handling.
154            for (ConversationSetObserver observer : observers) {
155                observer.onSetChanged(this);
156            }
157        }
158    }
159
160    private void dispatchOnEmpty(ArrayList<ConversationSetObserver> observers) {
161        synchronized (mLock) {
162            for (ConversationSetObserver observer : observers) {
163                observer.onSetEmpty();
164            }
165        }
166    }
167
168    /**
169     * Is this conversation set empty?
170     * @return true if the conversation selection set is empty. False otherwise.
171     */
172    public boolean isEmpty() {
173        synchronized (mLock) {
174            return mInternalMap.isEmpty();
175        }
176    }
177
178    private void put(Long id, Conversation info) {
179        synchronized (mLock) {
180            final boolean initiallyEmpty = mInternalMap.isEmpty();
181            mInternalMap.put(id, info);
182            // Fill out the view map with null. The sizes will match, but
183            // we won't have any views available yet to store.
184            mInternalViewMap.put(id, null);
185            mConversationUriToIdMap.put(info.uri.toString(), id);
186
187            final ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
188            dispatchOnChange(observersCopy);
189            if (initiallyEmpty) {
190                dispatchOnBecomeUnempty(observersCopy);
191            }
192        }
193    }
194
195    /** @see java.util.HashMap#put */
196    private void put(Long id, ConversationItemView info) {
197        synchronized (mLock) {
198            boolean initiallyEmpty = mInternalMap.isEmpty();
199            mInternalViewMap.put(id, info);
200            mInternalMap.put(id, info.mHeader.conversation);
201            mConversationUriToIdMap.put(info.mHeader.conversation.uri.toString(), id);
202
203            final ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
204            dispatchOnChange(observersCopy);
205            if (initiallyEmpty) {
206                dispatchOnBecomeUnempty(observersCopy);
207            }
208        }
209    }
210
211    /** @see java.util.HashMap#remove */
212    private void remove(Long id) {
213        synchronized (mLock) {
214            removeAll(Collections.singleton(id));
215        }
216    }
217
218    private void removeAll(Collection<Long> ids) {
219        synchronized (mLock) {
220            final boolean initiallyNotEmpty = !mInternalMap.isEmpty();
221
222            final BiMap<Long, String> inverseMap = mConversationUriToIdMap.inverse();
223
224            for (Long id : ids) {
225                mInternalViewMap.remove(id);
226                mInternalMap.remove(id);
227                inverseMap.remove(id);
228            }
229
230            ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
231            dispatchOnChange(observersCopy);
232            if (mInternalMap.isEmpty() && initiallyNotEmpty) {
233                dispatchOnEmpty(observersCopy);
234            }
235        }
236    }
237
238    /**
239     * Unregisters an observer for change events.
240     *
241     * @param observer the observer to unregister.
242     */
243    public void removeObserver(ConversationSetObserver observer) {
244        synchronized (mLock) {
245            mObservers.remove(observer);
246        }
247    }
248
249    /**
250     * Returns the number of conversations that are currently selected
251     * @return the number of selected conversations.
252     */
253    public int size() {
254        synchronized (mLock) {
255            return mInternalMap.size();
256        }
257    }
258
259    /**
260     * Toggles the existence of the given conversation in the selection set. If the conversation is
261     * currently selected, it is deselected. If it doesn't exist in the selection set, then it is
262     * selected. If you are certain that you are deselecting a conversation (you have verified
263     * that {@link #contains(Conversation)} or {@link #containsKey(Long)} are true), then you
264     * may pass a null {@link ConversationItemView}.
265     * @param conversation
266     */
267    public void toggle(ConversationItemView view, Conversation conversation) {
268        long conversationId = conversation.id;
269        if (containsKey(conversationId)) {
270            // We must not do anything with view here.
271            remove(conversationId);
272        } else {
273            put(conversationId, view);
274        }
275    }
276
277    /** @see java.util.HashMap#values */
278    public Collection<Conversation> values() {
279        synchronized (mLock) {
280            return mInternalMap.values();
281        }
282    }
283
284    /** @see java.util.HashMap#keySet() */
285    public Set<Long> keySet() {
286        synchronized (mLock) {
287            return mInternalMap.keySet();
288        }
289    }
290
291    /**
292     * Puts all conversations given in the input argument into the selection set. If there are
293     * any listeners they are notified once after adding <em>all</em> conversations to the selection
294     * set.
295     * @see java.util.HashMap#putAll(java.util.Map)
296     */
297    public void putAll(ConversationSelectionSet other) {
298        if (other == null) {
299            return;
300        }
301
302        final boolean initiallyEmpty = mInternalMap.isEmpty();
303        mInternalMap.putAll(other.mInternalMap);
304
305        final Set<Long> keys = other.mInternalMap.keySet();
306        for (Long key : keys) {
307            // Fill out the view map with null. The sizes will match, but
308            // we won't have any views available yet to store.
309            mInternalViewMap.put(key, null);
310        }
311
312        ArrayList<ConversationSetObserver> observersCopy = Lists.newArrayList(mObservers);
313        dispatchOnChange(observersCopy);
314        if (initiallyEmpty) {
315            dispatchOnBecomeUnempty(observersCopy);
316        }
317    }
318
319    @Override
320    public void writeToParcel(Parcel dest, int flags) {
321        Conversation[] values = values().toArray(new Conversation[size()]);
322        dest.writeParcelableArray(values, flags);
323    }
324
325    public Collection<ConversationItemView> views() {
326        return mInternalViewMap.values();
327    }
328
329    /**
330     * @param deletedRows an arraylist of conversation IDs which have been deleted.
331     */
332    public void delete(ArrayList<Integer> deletedRows) {
333        for (long id : deletedRows) {
334            remove(id);
335        }
336    }
337
338    /**
339     * Iterates through a cursor of conversations and ensures that the current set is present
340     * within the result set denoted by the cursor. Any conversations not foun in the result set
341     * is removed from the collection.
342     */
343    public void validateAgainstCursor(ConversationCursor cursor) {
344        synchronized (mLock) {
345            if (isEmpty()) {
346                return;
347            }
348
349            if (cursor == null) {
350                clear();
351                return;
352            }
353
354            // First ask the ConversationCursor for the list of conversations that have been deleted
355            final Set<String> deletedConversations = cursor.getDeletedItems();
356            // For each of the uris in the deleted set, add the conversation id to the
357            // itemsToRemoveFromBatch set.
358            final Set<Long> itemsToRemoveFromBatch = Sets.newHashSet();
359            for (String conversationUri : deletedConversations) {
360                final Long conversationId = mConversationUriToIdMap.get(conversationUri);
361                if (conversationId != null) {
362                    itemsToRemoveFromBatch.add(conversationId);
363                }
364            }
365
366            // Get the set of the items that had been in the batch
367            final Set<Long> batchConversationToCheck = new HashSet<Long>(keySet());
368
369            // Remove all of the items that we know are missing.  This will leave the items where
370            // we need to check for existence in the cursor
371            batchConversationToCheck.removeAll(itemsToRemoveFromBatch);
372
373            // While there are items to check, remove all items that are still in the cursor.
374            final Set<Long> cursorConversationIds = cursor.getConversationIds();
375            while (!batchConversationToCheck.isEmpty() && cursorConversationIds != null) {
376                batchConversationToCheck.removeAll(cursorConversationIds);
377            }
378
379            // At this point any of the item that are remaining in the batchConversationToCheck set
380            // are to be removed from the selected conversation set
381            itemsToRemoveFromBatch.addAll(batchConversationToCheck);
382
383            removeAll(itemsToRemoveFromBatch);
384        }
385    }
386
387    @Override
388    public String toString() {
389        synchronized (mLock) {
390            return String.format("%s:%s", super.toString(), mInternalMap);
391        }
392    }
393}
394