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