MailAppProvider.java revision a6b671dd9f5ba358a05888b3ab3bf1c5cb5cf493
1/** 2 * Copyright (c) 2011, Google Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.mail.providers; 18 19import android.app.Activity; 20import android.content.ContentProvider; 21import android.content.ContentResolver; 22import android.content.ContentValues; 23import android.content.Context; 24import android.content.CursorLoader; 25import android.content.Intent; 26import android.content.Loader; 27import android.content.Loader.OnLoadCompleteListener; 28import android.content.SharedPreferences; 29import android.database.Cursor; 30import android.database.MatrixCursor; 31import android.net.Uri; 32import android.os.Bundle; 33import android.provider.BaseColumns; 34import android.text.TextUtils; 35 36import com.android.mail.providers.UIProvider.AccountCursorExtraKeys; 37import com.android.mail.providers.protos.boot.AccountReceiver; 38import com.android.mail.utils.LogUtils; 39import com.android.mail.utils.MatrixCursorWithExtra; 40import com.google.common.collect.ImmutableSet; 41import com.google.common.collect.Maps; 42import com.google.common.collect.Sets; 43 44import java.util.Collections; 45import java.util.Map; 46import java.util.Set; 47import java.util.regex.Pattern; 48 49 50 51/** 52 * The Account Cache provider allows email providers to register "accounts" and the UI has a single 53 * place to query for the list of accounts. 54 * 55 * During development this will allow new account types to be added, and allow them to be shown in 56 * the application. For example, the mock accounts can be enabled/disabled. 57 * In the future, once other processes can add new accounts, this could allow other "mail" 58 * applications have their content appear within the application 59 */ 60public abstract class MailAppProvider extends ContentProvider 61 implements OnLoadCompleteListener<Cursor>{ 62 63 private static final String SHARED_PREFERENCES_NAME = "MailAppProvider"; 64 private static final String ACCOUNT_LIST_KEY = "accountList"; 65 private static final String LAST_VIEWED_ACCOUNT_KEY = "lastViewedAccount"; 66 67 /** 68 * Extra used in the result from the activity launched by the intent specified 69 * by {@link #getNoAccountsIntent} to return the list of accounts. The data 70 * specified by this extra key should be a ParcelableArray. 71 */ 72 public static final String ADD_ACCOUNT_RESULT_ACCOUNTS_EXTRA = "addAccountResultAccounts"; 73 74 private final static String LOG_TAG = new LogUtils().getLogTag(); 75 76 private final Map<Uri, AccountCacheEntry> mAccountCache = Maps.newHashMap(); 77 78 private final Map<Uri, CursorLoader> mCursorLoaderMap = Maps.newHashMap(); 79 80 private ContentResolver mResolver; 81 private static String sAuthority; 82 private static MailAppProvider sInstance; 83 84 private volatile boolean mAccountsFullyLoaded = false; 85 86 private SharedPreferences mSharedPrefs; 87 88 /** 89 * Allows the implementing provider to specify the authority for this provider. Email and Gmail 90 * must specify different authorities. 91 */ 92 protected abstract String getAuthority(); 93 94 /** 95 * Authority for the suggestions provider. Email and Gmail must specify different authorities, 96 * much like the implementation of {@link #getAuthority()}. 97 * @return the suggestion authority associated with this provider. 98 */ 99 public abstract String getSuggestionAuthority(); 100 101 /** 102 * Allows the implementing provider to specify an intent that should be used in a call to 103 * {@link Context#startActivityForResult(android.content.Intent)} when the account provider 104 * doesn't return any accounts. 105 * 106 * The result from the {@link Activity} activity should include the list of accounts in 107 * the returned intent, in the 108 109 * @return Intent or null, if the provider doesn't specify a behavior when no accounts are 110 * specified. 111 */ 112 protected abstract Intent getNoAccountsIntent(Context context); 113 114 /** 115 * The cursor returned from a call to {@link android.content.ContentResolver#query() with this 116 * uri will return a cursor that with columns that are a subset of the columns specified 117 * in {@link UIProvider.ConversationColumns} 118 * The cursor returned by this query can return a {@link android.os.Bundle} 119 * from a call to {@link android.database.Cursor#getExtras()}. This Bundle may have 120 * values with keys listed in {@link AccountCursorExtraKeys} 121 */ 122 public static Uri getAccountsUri() { 123 return Uri.parse("content://" + sAuthority + "/"); 124 } 125 126 public static MailAppProvider getInstance() { 127 return sInstance; 128 } 129 130 /** Default constructor */ 131 public MailAppProvider() { 132 sInstance = this; 133 } 134 135 @Override 136 public boolean onCreate() { 137 sAuthority = getAuthority(); 138 mResolver = getContext().getContentResolver(); 139 140 final Intent intent = new Intent(AccountReceiver.ACTION_PROVIDER_CREATED); 141 getContext().sendBroadcast(intent); 142 143 // Load the previously saved account list 144 loadCachedAccountList(); 145 146 return true; 147 } 148 149 @Override 150 public void shutdown() { 151 sInstance = null; 152 153 for (CursorLoader loader : mCursorLoaderMap.values()) { 154 loader.stopLoading(); 155 } 156 mCursorLoaderMap.clear(); 157 } 158 159 @Override 160 public Cursor query(Uri url, String[] projection, String selection, String[] selectionArgs, 161 String sortOrder) { 162 // This content provider currently only supports one query (to return the list of accounts). 163 // No reason to check the uri. Currently only checking the projections 164 165 // Validates and returns the projection that should be used. 166 final String[] resultProjection = UIProviderValidator.validateAccountProjection(projection); 167 final Bundle extras = new Bundle(); 168 extras.putInt(AccountCursorExtraKeys.ACCOUNTS_LOADED, mAccountsFullyLoaded ? 1 : 0); 169 170 // Make a copy of the account cache 171 172 final Set<AccountCacheEntry> accountList; 173 synchronized (mAccountCache) { 174 accountList = ImmutableSet.copyOf(mAccountCache.values()); 175 } 176 177 final MatrixCursor cursor = 178 new MatrixCursorWithExtra(resultProjection, accountList.size(), extras); 179 180 for (AccountCacheEntry accountEntry : accountList) { 181 final Account account = accountEntry.mAccount; 182 final MatrixCursor.RowBuilder builder = cursor.newRow(); 183 184 for (String column : resultProjection) { 185 if (TextUtils.equals(column, BaseColumns._ID)) { 186 // TODO(pwestbro): remove this as it isn't used. 187 builder.add(Integer.valueOf(0)); 188 } else if (TextUtils.equals(column, UIProvider.AccountColumns.NAME)) { 189 builder.add(account.name); 190 } else if (TextUtils.equals(column, UIProvider.AccountColumns.PROVIDER_VERSION)) { 191 // TODO fix this 192 builder.add(Integer.valueOf(account.providerVersion)); 193 } else if (TextUtils.equals(column, UIProvider.AccountColumns.URI)) { 194 builder.add(account.uri); 195 } else if (TextUtils.equals(column, UIProvider.AccountColumns.CAPABILITIES)) { 196 builder.add(Integer.valueOf(account.capabilities)); 197 } else if (TextUtils.equals(column, UIProvider.AccountColumns.FOLDER_LIST_URI)) { 198 builder.add(account.folderListUri); 199 } else if (TextUtils.equals(column, UIProvider.AccountColumns.SEARCH_URI)) { 200 builder.add(account.searchUri); 201 } else if (TextUtils.equals(column, 202 UIProvider.AccountColumns.ACCOUNT_FROM_ADDRESSES)) { 203 builder.add(account.accountFromAddresses); 204 } else if (TextUtils.equals(column, UIProvider.AccountColumns.SAVE_DRAFT_URI)) { 205 builder.add(account.saveDraftUri); 206 } else if (TextUtils.equals(column, UIProvider.AccountColumns.SEND_MAIL_URI)) { 207 builder.add(account.sendMessageUri); 208 } else if (TextUtils.equals(column, 209 UIProvider.AccountColumns.EXPUNGE_MESSAGE_URI)) { 210 builder.add(account.expungeMessageUri); 211 } else if (TextUtils.equals(column, UIProvider.AccountColumns.UNDO_URI)) { 212 builder.add(account.undoUri); 213 } else if (TextUtils.equals(column, 214 UIProvider.AccountColumns.SETTINGS_INTENT_URI)) { 215 builder.add(account.settingsIntentUri); 216 } else if (TextUtils.equals(column, 217 UIProvider.AccountColumns.HELP_INTENT_URI)) { 218 builder.add(account.helpIntentUri); 219 } else if (TextUtils.equals(column, 220 UIProvider.AccountColumns.SEND_FEEDBACK_INTENT_URI)) { 221 builder.add(account.sendFeedbackIntentUri); 222 } else if (TextUtils.equals(column, UIProvider.AccountColumns.SYNC_STATUS)) { 223 builder.add(Integer.valueOf(account.syncStatus)); 224 } else if (TextUtils.equals(column, UIProvider.AccountColumns.COMPOSE_URI)) { 225 builder.add(account.composeIntentUri); 226 } else if (TextUtils.equals(column, UIProvider.AccountColumns.MIME_TYPE)) { 227 builder.add(account.mimeType); 228 } else if (TextUtils.equals(column, 229 UIProvider.AccountColumns.RECENT_FOLDER_LIST_URI)) { 230 builder.add(account.recentFolderListUri); 231 } else if (TextUtils.equals(column, UIProvider.AccountColumns.COLOR)) { 232 builder.add(account.color); 233 } else if (TextUtils.equals(column, 234 UIProvider.AccountColumns.SettingsColumns.SIGNATURE)) { 235 builder.add(account.settings.signature); 236 } else if (TextUtils.equals(column, 237 UIProvider.AccountColumns.SettingsColumns.AUTO_ADVANCE)) { 238 builder.add(Integer.valueOf(account.settings.autoAdvance)); 239 } else if (TextUtils.equals(column, 240 UIProvider.AccountColumns.SettingsColumns.MESSAGE_TEXT_SIZE)) { 241 builder.add(Integer.valueOf(account.settings.messageTextSize)); 242 } else if (TextUtils.equals(column, 243 UIProvider.AccountColumns.SettingsColumns.REPLY_BEHAVIOR)) { 244 builder.add(Integer.valueOf(account.settings.replyBehavior)); 245 } else if (TextUtils.equals(column, 246 UIProvider.AccountColumns.SettingsColumns.HIDE_CHECKBOXES)) { 247 builder.add(Integer.valueOf(account.settings.hideCheckboxes ? 1 : 0)); 248 } else if (TextUtils.equals(column, 249 UIProvider.AccountColumns.SettingsColumns.CONFIRM_DELETE)) { 250 builder.add(Integer.valueOf(account.settings.confirmDelete ? 1 : 0)); 251 } else if (TextUtils.equals(column, 252 UIProvider.AccountColumns.SettingsColumns.CONFIRM_ARCHIVE)) { 253 builder.add(Integer.valueOf(account.settings.confirmArchive ? 1 : 0)); 254 } else if (TextUtils.equals(column, 255 UIProvider.AccountColumns.SettingsColumns.CONFIRM_SEND)) { 256 builder.add(Integer.valueOf(account.settings.confirmSend ? 1 : 0)); 257 } else if (TextUtils.equals(column, 258 UIProvider.AccountColumns.SettingsColumns.DEFAULT_INBOX)) { 259 builder.add(account.settings.defaultInbox); 260 } else if (TextUtils.equals(column, 261 UIProvider.AccountColumns.SettingsColumns.SNAP_HEADERS)) { 262 builder.add(Integer.valueOf(account.settings.snapHeaders)); 263 } else if (TextUtils.equals(column, 264 UIProvider.AccountColumns.SettingsColumns.FORCE_REPLY_FROM_DEFAULT)) { 265 builder.add(Integer.valueOf(account.settings.forceReplyFromDefault ? 1 : 0)); 266 } else { 267 throw new IllegalStateException("Column not found: " + column); 268 } 269 } 270 } 271 272 cursor.setNotificationUri(mResolver, getAccountsUri()); 273 return cursor; 274 } 275 276 @Override 277 public Uri insert(Uri url, ContentValues values) { 278 return url; 279 } 280 281 @Override 282 public int update(Uri url, ContentValues values, String selection, 283 String[] selectionArgs) { 284 return 0; 285 } 286 287 @Override 288 public int delete(Uri url, String selection, String[] selectionArgs) { 289 return 0; 290 } 291 292 @Override 293 public String getType(Uri uri) { 294 return null; 295 } 296 297 /** 298 * Asynchronously ads all of the accounts that are specified by the result set returned by 299 * {@link ContentProvider#query()} for the specified uri. The content provider handling the 300 * query needs to handle the {@link UIProvider.ACCOUNTS_PROJECTION} 301 * Any changes to the underlying provider will automatically be reflected. 302 * @param resolver 303 * @param accountsQueryUri 304 */ 305 public static void addAccountsForUriAsync(Uri accountsQueryUri) { 306 getInstance().startAccountsLoader(accountsQueryUri); 307 } 308 309 /** 310 * Returns the intent that should be used in a call to 311 * {@link Context#startActivity(android.content.Intent)} when the account provider doesn't 312 * return any accounts 313 * @return Intent or null, if the provider doesn't specify a behavior when no acccounts are 314 * specified. 315 */ 316 public static Intent getNoAccountIntent(Context context) { 317 return getInstance().getNoAccountsIntent(context); 318 } 319 320 private synchronized void startAccountsLoader(Uri accountsQueryUri) { 321 final CursorLoader accountsCursorLoader = new CursorLoader(getContext(), accountsQueryUri, 322 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 323 324 // Listen for the results 325 accountsCursorLoader.registerListener(accountsQueryUri.hashCode(), this); 326 accountsCursorLoader.startLoading(); 327 328 // If there is a previous loader for the given uri, stop it 329 final CursorLoader oldLoader = mCursorLoaderMap.get(accountsQueryUri); 330 if (oldLoader != null) { 331 oldLoader.stopLoading(); 332 } 333 mCursorLoaderMap.put(accountsQueryUri, accountsCursorLoader); 334 } 335 336 public static void addAccount(Account account, Uri accountsQueryUri) { 337 final MailAppProvider provider = getInstance(); 338 if (provider == null) { 339 throw new IllegalStateException("MailAppProvider not intialized"); 340 } 341 provider.addAccountImpl(account, accountsQueryUri, true /* notify */); 342 } 343 344 private void addAccountImpl(Account account, Uri accountsQueryUri, boolean notify) { 345 synchronized (mAccountCache) { 346 if (account != null) { 347 LogUtils.v(LOG_TAG, "adding account %s", account); 348 mAccountCache.put(account.uri, new AccountCacheEntry(account, accountsQueryUri)); 349 } 350 } 351 // Explicitly calling this out of the synchronized block in case any of the observers get 352 // called synchronously. 353 if (notify) { 354 broadcastAccountChange(); 355 } 356 357 // Cache the updated account list 358 cacheAccountList(); 359 } 360 361 public static void removeAccount(Uri accountUri) { 362 final MailAppProvider provider = getInstance(); 363 if (provider == null) { 364 throw new IllegalStateException("MailAppProvider not intialized"); 365 } 366 provider.removeAccounts(Collections.singleton(accountUri), true /* notify */); 367 } 368 369 private void removeAccounts(Set<Uri> uris, boolean notify) { 370 synchronized (mAccountCache) { 371 for (Uri accountUri : uris) { 372 mAccountCache.remove(accountUri); 373 } 374 } 375 376 // Explicitly calling this out of the synchronized block in case any of the observers get 377 // called synchronously. 378 if (notify) { 379 broadcastAccountChange(); 380 } 381 382 // Cache the updated account list 383 cacheAccountList(); 384 } 385 386 private static void broadcastAccountChange() { 387 final MailAppProvider provider = sInstance; 388 389 if (provider != null) { 390 provider.mResolver.notifyChange(getAccountsUri(), null); 391 } 392 } 393 394 /** 395 * Returns the {@link Account#uri} (in String form) of the last viewed account. 396 */ 397 public String getLastViewedAccount() { 398 return getPreferences().getString(LAST_VIEWED_ACCOUNT_KEY, null); 399 } 400 401 /** 402 * Persists the {@link Account#uri} (in String form) of the last viewed account. 403 */ 404 public void setLastViewedAccount(String accountUriStr) { 405 final SharedPreferences.Editor editor = getPreferences().edit(); 406 editor.putString(LAST_VIEWED_ACCOUNT_KEY, accountUriStr); 407 editor.apply(); 408 } 409 410 private void loadCachedAccountList() { 411 final SharedPreferences preference = getPreferences(); 412 413 final Set<String> accountsStringSet = preference.getStringSet(ACCOUNT_LIST_KEY, null); 414 415 if (accountsStringSet != null) { 416 for (String serializedAccount : accountsStringSet) { 417 try { 418 final AccountCacheEntry accountEntry = 419 new AccountCacheEntry(serializedAccount); 420 if (accountEntry.mAccount.settings != null) { 421 addAccountImpl(accountEntry.mAccount, accountEntry.mAccountsQueryUri, 422 false /* don't notify */); 423 } else { 424 LogUtils.e(LOG_TAG, "Dropping account that doesn't specify settings"); 425 } 426 } catch (Exception e) { 427 // Unable to create account object, skip to next 428 LogUtils.e(LOG_TAG, e, 429 "Unable to create account object from serialized string '%s'", 430 serializedAccount); 431 } 432 } 433 broadcastAccountChange(); 434 } 435 } 436 437 private void cacheAccountList() { 438 final SharedPreferences preference = getPreferences(); 439 440 final Set<AccountCacheEntry> accountList; 441 synchronized (mAccountCache) { 442 accountList = ImmutableSet.copyOf(mAccountCache.values()); 443 } 444 445 final Set<String> serializedAccounts = Sets.newHashSet(); 446 for (AccountCacheEntry accountEntry : accountList) { 447 serializedAccounts.add(accountEntry.serialize()); 448 } 449 450 final SharedPreferences.Editor editor = getPreferences().edit(); 451 editor.putStringSet(ACCOUNT_LIST_KEY, serializedAccounts); 452 editor.apply(); 453 } 454 455 private SharedPreferences getPreferences() { 456 if (mSharedPrefs == null) { 457 mSharedPrefs = getContext().getSharedPreferences( 458 SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); 459 } 460 return mSharedPrefs; 461 } 462 463 static public Account getAccountFromAccountUri(Uri accountUri) { 464 MailAppProvider provider = getInstance(); 465 if (provider != null && provider.mAccountsFullyLoaded) { 466 synchronized(provider.mAccountCache) { 467 AccountCacheEntry entry = provider.mAccountCache.get(accountUri); 468 if (entry != null) { 469 return entry.mAccount; 470 } 471 } 472 } 473 return null; 474 } 475 476 @Override 477 public void onLoadComplete(Loader<Cursor> loader, Cursor data) { 478 if (data == null) { 479 LogUtils.d(LOG_TAG, "null account cursor returned"); 480 return; 481 } 482 483 LogUtils.d(LOG_TAG, "Cursor with %d accounts returned", data.getCount()); 484 final CursorLoader cursorLoader = (CursorLoader)loader; 485 final Uri accountsQueryUri = cursorLoader.getUri(); 486 487 final Set<AccountCacheEntry> accountList; 488 synchronized (mAccountCache) { 489 accountList = ImmutableSet.copyOf(mAccountCache.values()); 490 } 491 492 // Build a set of the account uris that had been associated with that query 493 final Set<Uri> previousQueryUriMap = Sets.newHashSet(); 494 for (AccountCacheEntry entry : accountList) { 495 if (accountsQueryUri.equals(entry.mAccountsQueryUri)) { 496 previousQueryUriMap.add(entry.mAccount.uri); 497 } 498 } 499 500 // Update the internal state of this provider if the returned result set 501 // represents all accounts 502 // TODO: determine what should happen with a heterogeneous set of accounts 503 final Bundle extra = data.getExtras(); 504 mAccountsFullyLoaded = extra.getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0; 505 506 final Set<Uri> newQueryUriMap = Sets.newHashSet(); 507 while (data.moveToNext()) { 508 final Account account = new Account(data); 509 final Uri accountUri = account.uri; 510 newQueryUriMap.add(accountUri); 511 addAccountImpl(account, accountsQueryUri, false /* don't notify */); 512 } 513 514 if (previousQueryUriMap != null) { 515 // Remove all of the accounts that are in the new result set 516 previousQueryUriMap.removeAll(newQueryUriMap); 517 518 // For all of the entries that had been in the previous result set, and are not 519 // in the new result set, remove them from the cache 520 if (previousQueryUriMap.size() > 0 && mAccountsFullyLoaded) { 521 removeAccounts(previousQueryUriMap, false /* don't notify */); 522 } 523 } 524 broadcastAccountChange(); 525 } 526 527 /** 528 * Object that allows the Account Cache provider to associate the account with the content 529 * provider uri that originated that account. 530 */ 531 private static class AccountCacheEntry { 532 final Account mAccount; 533 final Uri mAccountsQueryUri; 534 535 private static final String ACCOUNT_ENTRY_COMPONENT_SEPARATOR = "^**^"; 536 private static final Pattern ACCOUNT_ENTRY_COMPONENT_SEPARATOR_PATTERN = 537 Pattern.compile("\\^\\*\\*\\^"); 538 539 private static final int NUMBER_MEMBERS = 2; 540 541 public AccountCacheEntry(Account account, Uri accountQueryUri) { 542 mAccount = account; 543 mAccountsQueryUri = accountQueryUri; 544 } 545 546 /** 547 * Return a serialized String for this AccountCacheEntry. 548 */ 549 public synchronized String serialize() { 550 StringBuilder out = new StringBuilder(); 551 out.append(mAccount.serialize()).append(ACCOUNT_ENTRY_COMPONENT_SEPARATOR); 552 final String accountQueryUri = 553 mAccountsQueryUri != null ? mAccountsQueryUri.toString() : ""; 554 out.append(accountQueryUri); 555 return out.toString(); 556 } 557 558 /** 559 * Create an account cache object from a serialized string previously stored away. 560 * If the serializedString does not parse as a valid account, we throw an 561 * {@link IllegalArgumentException}. The caller is responsible for checking this and 562 * ignoring the newly created object if the exception is thrown. 563 * @param serializedString 564 */ 565 public AccountCacheEntry(String serializedString) throws IllegalArgumentException { 566 String[] cacheEntryMembers = TextUtils.split(serializedString, 567 ACCOUNT_ENTRY_COMPONENT_SEPARATOR_PATTERN); 568 if (cacheEntryMembers.length != NUMBER_MEMBERS) { 569 throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. " 570 + "Wrong number of members detected. " 571 + cacheEntryMembers.length + " detected"); 572 } 573 mAccount = Account.newinstance(cacheEntryMembers[0]); 574 if (mAccount == null) { 575 throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. " 576 + "Account object couldn not be created by the serialized string" 577 + serializedString); 578 } 579 mAccountsQueryUri = !TextUtils.isEmpty(cacheEntryMembers[1]) ? 580 Uri.parse(cacheEntryMembers[1]) : null; 581 } 582 } 583} 584