1 /* 2 * Copyright (C) 2008 Esmertec AG. 3 * Copyright (C) 2008 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.mms.ui; 19 20 import com.android.mms.R; 21 import com.google.android.mms.MmsException; 22 23 import android.content.AsyncQueryHandler; 24 import android.content.ContentResolver; 25 import android.content.ContentUris; 26 import android.content.Context; 27 import android.database.Cursor; 28 import android.graphics.Bitmap; 29 import android.graphics.BitmapFactory; 30 import android.graphics.drawable.BitmapDrawable; 31 import android.graphics.drawable.Drawable; 32 import android.net.Uri; 33 import android.os.Handler; 34 import android.provider.BaseColumns; 35 import android.provider.ContactsContract.Contacts; 36 import android.provider.ContactsContract.Data; 37 import android.provider.ContactsContract.PhoneLookup; 38 import android.provider.ContactsContract.RawContacts; 39 import android.provider.ContactsContract.StatusUpdates; 40 import android.provider.ContactsContract.CommonDataKinds.Email; 41 import android.provider.ContactsContract.CommonDataKinds.Photo; 42 import android.provider.Telephony.Mms; 43 import android.provider.Telephony.MmsSms; 44 import android.provider.Telephony.Sms; 45 import android.provider.Telephony.MmsSms.PendingMessages; 46 import android.provider.Telephony.Sms.Conversations; 47 import android.text.TextUtils; 48 import android.text.format.DateUtils; 49 import android.util.Config; 50 import android.util.Log; 51 import android.view.LayoutInflater; 52 import android.view.View; 53 import android.view.ViewGroup; 54 import android.widget.CursorAdapter; 55 import android.widget.ListView; 56 57 import java.util.HashMap; 58 import java.util.HashSet; 59 import java.util.LinkedHashMap; 60 import java.util.Map; 61 import java.util.regex.Pattern; 62 63 /** 64 * The back-end data adapter of a message list. 65 */ 66 public class MessageListAdapter extends CursorAdapter { 67 private static final String TAG = "MessageListAdapter"; 68 private static final boolean DEBUG = false; 69 private static final boolean LOCAL_LOGV = Config.LOGV && DEBUG; 70 71 static final String[] PROJECTION = new String[] { 72 // TODO: should move this symbol into com.android.mms.telephony.Telephony. 73 MmsSms.TYPE_DISCRIMINATOR_COLUMN, 74 BaseColumns._ID, 75 Conversations.THREAD_ID, 76 // For SMS 77 Sms.ADDRESS, 78 Sms.BODY, 79 Sms.DATE, 80 Sms.READ, 81 Sms.TYPE, 82 Sms.STATUS, 83 Sms.LOCKED, 84 Sms.ERROR_CODE, 85 // For MMS 86 Mms.SUBJECT, 87 Mms.SUBJECT_CHARSET, 88 Mms.DATE, 89 Mms.READ, 90 Mms.MESSAGE_TYPE, 91 Mms.MESSAGE_BOX, 92 Mms.DELIVERY_REPORT, 93 Mms.READ_REPORT, 94 PendingMessages.ERROR_TYPE, 95 Mms.LOCKED 96 }; 97 98 // The indexes of the default columns which must be consistent 99 // with above PROJECTION. 100 static final int COLUMN_MSG_TYPE = 0; 101 static final int COLUMN_ID = 1; 102 static final int COLUMN_THREAD_ID = 2; 103 static final int COLUMN_SMS_ADDRESS = 3; 104 static final int COLUMN_SMS_BODY = 4; 105 static final int COLUMN_SMS_DATE = 5; 106 static final int COLUMN_SMS_READ = 6; 107 static final int COLUMN_SMS_TYPE = 7; 108 static final int COLUMN_SMS_STATUS = 8; 109 static final int COLUMN_SMS_LOCKED = 9; 110 static final int COLUMN_SMS_ERROR_CODE = 10; 111 static final int COLUMN_MMS_SUBJECT = 11; 112 static final int COLUMN_MMS_SUBJECT_CHARSET = 12; 113 static final int COLUMN_MMS_DATE = 13; 114 static final int COLUMN_MMS_READ = 14; 115 static final int COLUMN_MMS_MESSAGE_TYPE = 15; 116 static final int COLUMN_MMS_MESSAGE_BOX = 16; 117 static final int COLUMN_MMS_DELIVERY_REPORT = 17; 118 static final int COLUMN_MMS_READ_REPORT = 18; 119 static final int COLUMN_MMS_ERROR_TYPE = 19; 120 static final int COLUMN_MMS_LOCKED = 20; 121 122 private static final int CACHE_SIZE = 50; 123 124 protected LayoutInflater mInflater; 125 private final ListView mListView; 126 private final LinkedHashMap<Long, MessageItem> mMessageItemCache; 127 private final ColumnsMap mColumnsMap; 128 private OnDataSetChangedListener mOnDataSetChangedListener; 129 private Handler mMsgListItemHandler; 130 private Pattern mHighlight; 131 private Context mContext; 132 133 private HashMap<String, HashSet<MessageListItem>> mAddressToMessageListItems 134 = new HashMap<String, HashSet<MessageListItem>>(); 135 136 public MessageListAdapter( 137 Context context, Cursor c, ListView listView, 138 boolean useDefaultColumnsMap, Pattern highlight) { 139 super(context, c, false /* auto-requery */); 140 mContext = context; 141 mHighlight = highlight; 142 143 mInflater = (LayoutInflater) context.getSystemService( 144 Context.LAYOUT_INFLATER_SERVICE); 145 mListView = listView; 146 mMessageItemCache = new LinkedHashMap<Long, MessageItem>( 147 10, 1.0f, true) { 148 @Override 149 protected boolean removeEldestEntry(Map.Entry eldest) { 150 return size() > CACHE_SIZE; 151 } 152 }; 153 154 if (useDefaultColumnsMap) { 155 mColumnsMap = new ColumnsMap(); 156 } else { 157 mColumnsMap = new ColumnsMap(c); 158 } 159 160 mAvatarCache = new AvatarCache(); 161 } 162 163 @Override 164 public void bindView(View view, Context context, Cursor cursor) { 165 if (view instanceof MessageListItem) { 166 String type = cursor.getString(mColumnsMap.mColumnMsgType); 167 long msgId = cursor.getLong(mColumnsMap.mColumnMsgId); 168 169 MessageItem msgItem = getCachedMessageItem(type, msgId, cursor); 170 if (msgItem != null) { 171 MessageListItem mli = (MessageListItem) view; 172 173 // Remove previous item from mapping 174 MessageItem oldMessageItem = mli.getMessageItem(); 175 if (oldMessageItem != null) { 176 String oldAddress = oldMessageItem.mAddress; 177 if (oldAddress != null) { 178 HashSet<MessageListItem> set = mAddressToMessageListItems.get(oldAddress); 179 if (set != null) { 180 set.remove(mli); 181 } 182 } 183 } 184 185 mli.bind(mAvatarCache, msgItem); 186 mli.setMsgListItemHandler(mMsgListItemHandler); 187 188 // Add current item to mapping 189 190 String addr; 191 if (!Sms.isOutgoingFolder(msgItem.mBoxId)) { 192 addr = msgItem.mAddress; 193 } else { 194 addr = MessageUtils.getLocalNumber(); 195 } 196 197 HashSet<MessageListItem> set = mAddressToMessageListItems.get(addr); 198 if (set == null) { 199 set = new HashSet<MessageListItem>(); 200 mAddressToMessageListItems.put(addr, set); 201 } 202 set.add(mli); 203 } 204 } 205 } 206 207 public interface OnDataSetChangedListener { 208 void onDataSetChanged(MessageListAdapter adapter); 209 void onContentChanged(MessageListAdapter adapter); 210 } 211 212 public void setOnDataSetChangedListener(OnDataSetChangedListener l) { 213 mOnDataSetChangedListener = l; 214 } 215 216 public void setMsgListItemHandler(Handler handler) { 217 mMsgListItemHandler = handler; 218 } 219 220 public void notifyImageLoaded(String address) { 221 HashSet<MessageListItem> set = mAddressToMessageListItems.get(address); 222 if (set != null) { 223 for (MessageListItem mli : set) { 224 mli.bind(mAvatarCache, mli.getMessageItem()); 225 } 226 } 227 } 228 229 @Override 230 public void notifyDataSetChanged() { 231 super.notifyDataSetChanged(); 232 if (LOCAL_LOGV) { 233 Log.v(TAG, "MessageListAdapter.notifyDataSetChanged()."); 234 } 235 236 mListView.setSelection(mListView.getCount()); 237 mMessageItemCache.clear(); 238 239 if (mOnDataSetChangedListener != null) { 240 mOnDataSetChangedListener.onDataSetChanged(this); 241 } 242 } 243 244 @Override 245 protected void onContentChanged() { 246 if (getCursor() != null && !getCursor().isClosed()) { 247 if (mOnDataSetChangedListener != null) { 248 mOnDataSetChangedListener.onContentChanged(this); 249 } 250 } 251 } 252 253 @Override 254 public View newView(Context context, Cursor cursor, ViewGroup parent) { 255 return mInflater.inflate(R.layout.message_list_item, parent, false); 256 } 257 258 public MessageItem getCachedMessageItem(String type, long msgId, Cursor c) { 259 MessageItem item = mMessageItemCache.get(getKey(type, msgId)); 260 if (item == null && c != null && isCursorValid(c)) { 261 try { 262 item = new MessageItem(mContext, type, c, mColumnsMap, mHighlight); 263 mMessageItemCache.put(getKey(item.mType, item.mMsgId), item); 264 } catch (MmsException e) { 265 Log.e(TAG, e.getMessage()); 266 } 267 } 268 return item; 269 } 270 271 private boolean isCursorValid(Cursor cursor) { 272 // Check whether the cursor is valid or not. 273 if (cursor.isClosed() || cursor.isBeforeFirst() || cursor.isAfterLast()) { 274 return false; 275 } 276 return true; 277 } 278 279 private static long getKey(String type, long id) { 280 if (type.equals("mms")) { 281 return -id; 282 } else { 283 return id; 284 } 285 } 286 287 public static class ColumnsMap { 288 public int mColumnMsgType; 289 public int mColumnMsgId; 290 public int mColumnSmsAddress; 291 public int mColumnSmsBody; 292 public int mColumnSmsDate; 293 public int mColumnSmsRead; 294 public int mColumnSmsType; 295 public int mColumnSmsStatus; 296 public int mColumnSmsLocked; 297 public int mColumnSmsErrorCode; 298 public int mColumnMmsSubject; 299 public int mColumnMmsSubjectCharset; 300 public int mColumnMmsDate; 301 public int mColumnMmsRead; 302 public int mColumnMmsMessageType; 303 public int mColumnMmsMessageBox; 304 public int mColumnMmsDeliveryReport; 305 public int mColumnMmsReadReport; 306 public int mColumnMmsErrorType; 307 public int mColumnMmsLocked; 308 309 public ColumnsMap() { 310 mColumnMsgType = COLUMN_MSG_TYPE; 311 mColumnMsgId = COLUMN_ID; 312 mColumnSmsAddress = COLUMN_SMS_ADDRESS; 313 mColumnSmsBody = COLUMN_SMS_BODY; 314 mColumnSmsDate = COLUMN_SMS_DATE; 315 mColumnSmsType = COLUMN_SMS_TYPE; 316 mColumnSmsStatus = COLUMN_SMS_STATUS; 317 mColumnSmsLocked = COLUMN_SMS_LOCKED; 318 mColumnSmsErrorCode = COLUMN_SMS_ERROR_CODE; 319 mColumnMmsSubject = COLUMN_MMS_SUBJECT; 320 mColumnMmsSubjectCharset = COLUMN_MMS_SUBJECT_CHARSET; 321 mColumnMmsMessageType = COLUMN_MMS_MESSAGE_TYPE; 322 mColumnMmsMessageBox = COLUMN_MMS_MESSAGE_BOX; 323 mColumnMmsDeliveryReport = COLUMN_MMS_DELIVERY_REPORT; 324 mColumnMmsReadReport = COLUMN_MMS_READ_REPORT; 325 mColumnMmsErrorType = COLUMN_MMS_ERROR_TYPE; 326 mColumnMmsLocked = COLUMN_MMS_LOCKED; 327 } 328 329 public ColumnsMap(Cursor cursor) { 330 // Ignore all 'not found' exceptions since the custom columns 331 // may be just a subset of the default columns. 332 try { 333 mColumnMsgType = cursor.getColumnIndexOrThrow( 334 MmsSms.TYPE_DISCRIMINATOR_COLUMN); 335 } catch (IllegalArgumentException e) { 336 Log.w("colsMap", e.getMessage()); 337 } 338 339 try { 340 mColumnMsgId = cursor.getColumnIndexOrThrow(BaseColumns._ID); 341 } catch (IllegalArgumentException e) { 342 Log.w("colsMap", e.getMessage()); 343 } 344 345 try { 346 mColumnSmsAddress = cursor.getColumnIndexOrThrow(Sms.ADDRESS); 347 } catch (IllegalArgumentException e) { 348 Log.w("colsMap", e.getMessage()); 349 } 350 351 try { 352 mColumnSmsBody = cursor.getColumnIndexOrThrow(Sms.BODY); 353 } catch (IllegalArgumentException e) { 354 Log.w("colsMap", e.getMessage()); 355 } 356 357 try { 358 mColumnSmsDate = cursor.getColumnIndexOrThrow(Sms.DATE); 359 } catch (IllegalArgumentException e) { 360 Log.w("colsMap", e.getMessage()); 361 } 362 363 try { 364 mColumnSmsType = cursor.getColumnIndexOrThrow(Sms.TYPE); 365 } catch (IllegalArgumentException e) { 366 Log.w("colsMap", e.getMessage()); 367 } 368 369 try { 370 mColumnSmsStatus = cursor.getColumnIndexOrThrow(Sms.STATUS); 371 } catch (IllegalArgumentException e) { 372 Log.w("colsMap", e.getMessage()); 373 } 374 375 try { 376 mColumnSmsLocked = cursor.getColumnIndexOrThrow(Sms.LOCKED); 377 } catch (IllegalArgumentException e) { 378 Log.w("colsMap", e.getMessage()); 379 } 380 381 try { 382 mColumnSmsErrorCode = cursor.getColumnIndexOrThrow(Sms.ERROR_CODE); 383 } catch (IllegalArgumentException e) { 384 Log.w("colsMap", e.getMessage()); 385 } 386 387 try { 388 mColumnMmsSubject = cursor.getColumnIndexOrThrow(Mms.SUBJECT); 389 } catch (IllegalArgumentException e) { 390 Log.w("colsMap", e.getMessage()); 391 } 392 393 try { 394 mColumnMmsSubjectCharset = cursor.getColumnIndexOrThrow(Mms.SUBJECT_CHARSET); 395 } catch (IllegalArgumentException e) { 396 Log.w("colsMap", e.getMessage()); 397 } 398 399 try { 400 mColumnMmsMessageType = cursor.getColumnIndexOrThrow(Mms.MESSAGE_TYPE); 401 } catch (IllegalArgumentException e) { 402 Log.w("colsMap", e.getMessage()); 403 } 404 405 try { 406 mColumnMmsMessageBox = cursor.getColumnIndexOrThrow(Mms.MESSAGE_BOX); 407 } catch (IllegalArgumentException e) { 408 Log.w("colsMap", e.getMessage()); 409 } 410 411 try { 412 mColumnMmsDeliveryReport = cursor.getColumnIndexOrThrow(Mms.DELIVERY_REPORT); 413 } catch (IllegalArgumentException e) { 414 Log.w("colsMap", e.getMessage()); 415 } 416 417 try { 418 mColumnMmsReadReport = cursor.getColumnIndexOrThrow(Mms.READ_REPORT); 419 } catch (IllegalArgumentException e) { 420 Log.w("colsMap", e.getMessage()); 421 } 422 423 try { 424 mColumnMmsErrorType = cursor.getColumnIndexOrThrow(PendingMessages.ERROR_TYPE); 425 } catch (IllegalArgumentException e) { 426 Log.w("colsMap", e.getMessage()); 427 } 428 429 try { 430 mColumnMmsLocked = cursor.getColumnIndexOrThrow(Mms.LOCKED); 431 } catch (IllegalArgumentException e) { 432 Log.w("colsMap", e.getMessage()); 433 } 434 } 435 } 436 437 private AvatarCache mAvatarCache; 438 439 /* 440 * Track avatars for each of the members of in the group chat. 441 */ 442 class AvatarCache { 443 private static final int TOKEN_PHONE_LOOKUP = 101; 444 private static final int TOKEN_EMAIL_LOOKUP = 102; 445 private static final int TOKEN_CONTACT_INFO = 201; 446 private static final int TOKEN_PHOTO_DATA = 301; 447 448 //Projection used for the summary info in the header. 449 private final String[] COLUMNS = new String[] { 450 Contacts._ID, 451 Contacts.PHOTO_ID, 452 // Other fields which we might want/need in the future (for example) 453 // Contacts.LOOKUP_KEY, 454 // Contacts.DISPLAY_NAME, 455 // Contacts.STARRED, 456 // Contacts.CONTACT_PRESENCE, 457 // Contacts.CONTACT_STATUS, 458 // Contacts.CONTACT_STATUS_TIMESTAMP, 459 // Contacts.CONTACT_STATUS_RES_PACKAGE, 460 // Contacts.CONTACT_STATUS_LABEL, 461 }; 462 private final int PHOTO_ID = 1; 463 464 private final String[] PHONE_LOOKUP_PROJECTION = new String[] { 465 PhoneLookup._ID, 466 PhoneLookup.LOOKUP_KEY, 467 }; 468 private static final int PHONE_LOOKUP_CONTACT_ID_COLUMN_INDEX = 0; 469 private static final int PHONE_LOOKUP_CONTACT_LOOKUP_KEY_COLUMN_INDEX = 1; 470 471 private final String[] EMAIL_LOOKUP_PROJECTION = new String[] { 472 RawContacts.CONTACT_ID, 473 Contacts.LOOKUP_KEY, 474 }; 475 private static final int EMAIL_LOOKUP_CONTACT_ID_COLUMN_INDEX = 0; 476 private static final int EMAIL_LOOKUP_CONTACT_LOOKUP_KEY_COLUMN_INDEX = 1; 477 478 479 /* 480 * Map from mAddress to a blob of data which contains the contact id 481 * and the avatar. 482 */ 483 HashMap<String, ContactData> mImageCache = new HashMap<String, ContactData>(); 484 485 public class ContactData { 486 private String mAddress; 487 private long mContactId; 488 private Uri mContactUri; 489 private Drawable mPhoto; 490 491 ContactData(String address) { 492 mAddress = address; 493 } 494 495 public Drawable getAvatar() { 496 return mPhoto; 497 } 498 499 public Uri getContactUri() { 500 return mContactUri; 501 } 502 503 private boolean startInitialQuery() { 504 if (Mms.isPhoneNumber(mAddress)) { 505 mQueryHandler.startQuery( 506 TOKEN_PHONE_LOOKUP, 507 this, 508 Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI, Uri.encode(mAddress)), 509 PHONE_LOOKUP_PROJECTION, 510 null, 511 null, 512 null); 513 return true; 514 } else if (Mms.isEmailAddress(mAddress)) { 515 mQueryHandler.startQuery( 516 TOKEN_EMAIL_LOOKUP, 517 this, 518 Uri.withAppendedPath(Email.CONTENT_LOOKUP_URI, Uri.encode(mAddress)), 519 EMAIL_LOOKUP_PROJECTION, 520 null, 521 null, 522 null); 523 return true; 524 } else { 525 return false; 526 } 527 } 528 /* 529 * Once we have the photo data load it into a drawable. 530 */ 531 private boolean onPhotoDataLoaded(Cursor c) { 532 if (c == null || !c.moveToFirst()) return false; 533 534 try { 535 byte[] photoData = c.getBlob(0); 536 Bitmap b = BitmapFactory.decodeByteArray(photoData, 0, photoData.length, null); 537 mPhoto = new BitmapDrawable(mContext.getResources(), b); 538 return true; 539 } catch (Exception ex) { 540 return false; 541 } 542 } 543 544 /* 545 * Once we have the contact info loaded take the photo id and query 546 * for the photo data. 547 */ 548 private boolean onContactInfoLoaded(Cursor c) { 549 if (c == null || !c.moveToFirst()) return false; 550 551 long photoId = c.getLong(PHOTO_ID); 552 Uri contactUri = ContentUris.withAppendedId(Data.CONTENT_URI, photoId); 553 mQueryHandler.startQuery( 554 TOKEN_PHOTO_DATA, 555 this, 556 contactUri, 557 new String[] { Photo.PHOTO }, 558 null, 559 null, 560 null); 561 562 return true; 563 } 564 565 /* 566 * Once we have the contact id loaded start the query for the 567 * contact information (which will give us the photo id). 568 */ 569 private boolean onContactIdLoaded(Cursor c, int contactIdColumn, int lookupKeyColumn) { 570 if (c == null || !c.moveToFirst()) return false; 571 572 mContactId = c.getLong(contactIdColumn); 573 String lookupKey = c.getString(lookupKeyColumn); 574 mContactUri = Contacts.getLookupUri(mContactId, lookupKey); 575 mQueryHandler.startQuery( 576 TOKEN_CONTACT_INFO, 577 this, 578 mContactUri, 579 COLUMNS, 580 null, 581 null, 582 null); 583 return true; 584 } 585 586 /* 587 * If for whatever reason we can't get the photo load teh 588 * default avatar. NOTE that fasttrack tries to get fancy 589 * with various random images (upside down, etc.) we're not 590 * doing that here. 591 */ 592 private void loadDefaultAvatar() { 593 if (mDefaultAvatarDrawable == null) { 594 Bitmap b = BitmapFactory.decodeResource(mContext.getResources(), 595 R.drawable.ic_contact_picture); 596 mDefaultAvatarDrawable = new BitmapDrawable(mContext.getResources(), b); 597 } 598 mPhoto = mDefaultAvatarDrawable; 599 } 600 601 }; 602 603 Drawable mDefaultAvatarDrawable = null; 604 AsyncQueryHandler mQueryHandler = new AsyncQueryHandler(mContext.getContentResolver()) { 605 @Override 606 protected void onQueryComplete(int token, Object cookieObject, Cursor cursor) { 607 super.onQueryComplete(token, cookieObject, cursor); 608 609 ContactData cookie = (ContactData) cookieObject; 610 switch (token) { 611 case TOKEN_PHONE_LOOKUP: { 612 if (!cookie.onContactIdLoaded( 613 cursor, 614 PHONE_LOOKUP_CONTACT_ID_COLUMN_INDEX, 615 PHONE_LOOKUP_CONTACT_LOOKUP_KEY_COLUMN_INDEX)) { 616 cookie.loadDefaultAvatar(); 617 } 618 break; 619 } 620 case TOKEN_EMAIL_LOOKUP: { 621 if (!cookie.onContactIdLoaded( 622 cursor, 623 EMAIL_LOOKUP_CONTACT_ID_COLUMN_INDEX, 624 EMAIL_LOOKUP_CONTACT_LOOKUP_KEY_COLUMN_INDEX)) { 625 cookie.loadDefaultAvatar(); 626 } 627 break; 628 } 629 case TOKEN_CONTACT_INFO: { 630 if (!cookie.onContactInfoLoaded(cursor)) { 631 cookie.loadDefaultAvatar(); 632 } 633 break; 634 } 635 case TOKEN_PHOTO_DATA: { 636 if (!cookie.onPhotoDataLoaded(cursor)) { 637 cookie.loadDefaultAvatar(); 638 } else { 639 MessageListAdapter.this.notifyImageLoaded(cookie.mAddress); 640 } 641 break; 642 } 643 default: 644 break; 645 } 646 } 647 }; 648 649 public ContactData get(final String address) { 650 if (mImageCache.containsKey(address)) { 651 return mImageCache.get(address); 652 } else { 653 // Create the ContactData object and put it into the hashtable 654 // so that any subsequent requests for this same avatar do not kick 655 // off another query. 656 ContactData cookie = new ContactData(address); 657 mImageCache.put(address, cookie); 658 cookie.startInitialQuery(); 659 cookie.loadDefaultAvatar(); 660 return cookie; 661 } 662 } 663 664 public AvatarCache() { 665 } 666 }; 667 668 669 } 670