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