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