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 17 package com.android.mail.providers; 18 19 import android.app.Activity; 20 import android.content.ContentProvider; 21 import android.content.ContentProviderClient; 22 import android.content.ContentResolver; 23 import android.content.ContentValues; 24 import android.content.Context; 25 import android.content.CursorLoader; 26 import android.content.Intent; 27 import android.content.Loader; 28 import android.content.Loader.OnLoadCompleteListener; 29 import android.content.SharedPreferences; 30 import android.content.res.Resources; 31 import android.database.Cursor; 32 import android.database.MatrixCursor; 33 import android.net.Uri; 34 import android.os.Bundle; 35 36 import com.android.mail.R; 37 import com.android.mail.providers.UIProvider.AccountCursorExtraKeys; 38 import com.android.mail.utils.LogTag; 39 import com.android.mail.utils.LogUtils; 40 import com.android.mail.utils.MatrixCursorWithExtra; 41 import com.android.mail.utils.RankedComparator; 42 import com.google.android.mail.common.base.Function; 43 import com.google.common.collect.ImmutableList; 44 import com.google.common.collect.Lists; 45 import com.google.common.collect.Maps; 46 import com.google.common.collect.Sets; 47 48 import org.json.JSONArray; 49 import org.json.JSONException; 50 import org.json.JSONObject; 51 52 import java.util.Collections; 53 import java.util.Comparator; 54 import java.util.LinkedHashMap; 55 import java.util.List; 56 import java.util.Map; 57 import java.util.Set; 58 59 60 /** 61 * The Mail App provider allows email providers to register "accounts" and the UI has a single 62 * place to query for the list of accounts. 63 * 64 * During development this will allow new account types to be added, and allow them to be shown in 65 * the application. For example, the mock accounts can be enabled/disabled. 66 * In the future, once other processes can add new accounts, this could allow other "mail" 67 * applications have their content appear within the application 68 */ 69 public abstract class MailAppProvider extends ContentProvider 70 implements OnLoadCompleteListener<Cursor>{ 71 72 private static final String SHARED_PREFERENCES_NAME = "MailAppProvider"; 73 private static final String ACCOUNT_LIST_KEY = "accountList"; 74 private static final String LAST_VIEWED_ACCOUNT_KEY = "lastViewedAccount"; 75 private static final String LAST_SENT_FROM_ACCOUNT_KEY = "lastSendFromAccount"; 76 77 /** 78 * Extra used in the result from the activity launched by the intent specified 79 * by {@link #getNoAccountsIntent} to return the list of accounts. The data 80 * specified by this extra key should be a ParcelableArray. 81 */ 82 public static final String ADD_ACCOUNT_RESULT_ACCOUNTS_EXTRA = "addAccountResultAccounts"; 83 84 private final static String LOG_TAG = LogTag.getLogTag(); 85 86 private final LinkedHashMap<Uri, AccountCacheEntry> mAccountCache = 87 new LinkedHashMap<Uri, AccountCacheEntry>(); 88 89 private final Map<Uri, CursorLoader> mCursorLoaderMap = Maps.newHashMap(); 90 /** 91 * When there is more than one {@link CursorLoader} we are considered finished only when all 92 * loaders finish. 93 */ 94 private final Map<CursorLoader, Boolean> mAccountsLoaded = Maps.newHashMap(); 95 96 private ContentResolver mResolver; 97 98 /** 99 * Compares {@link AccountCacheEntry} based on the position of the 100 * {@link AccountCacheEntry#mAccountsQueryUri} in {@code R.array.account_providers}. 101 */ 102 private Comparator<AccountCacheEntry> mAccountComparator; 103 104 private static String sAuthority; 105 private static MailAppProvider sInstance; 106 107 private SharedPreferences mSharedPrefs; 108 109 /** 110 * Allows the implementing provider to specify the authority for this provider. Email and Gmail 111 * must specify different authorities. 112 */ getAuthority()113 protected abstract String getAuthority(); 114 115 /** 116 * Allows the implementing provider to specify an intent that should be used in a call to 117 * {@link Context#startActivityForResult(android.content.Intent)} when the account provider 118 * doesn't return any accounts. 119 * 120 * The result from the {@link Activity} activity should include the list of accounts in 121 * the returned intent, in the 122 123 * @return Intent or null, if the provider doesn't specify a behavior when no accounts are 124 * specified. 125 */ getNoAccountsIntent(Context context)126 protected abstract Intent getNoAccountsIntent(Context context); 127 128 /** 129 * The cursor returned from a call to {@link android.content.ContentResolver#query()} with this 130 * uri will return a cursor that with columns that are a subset of the columns specified 131 * in {@link UIProvider.ConversationColumns} 132 * The cursor returned by this query can return a {@link android.os.Bundle} 133 * from a call to {@link android.database.Cursor#getExtras()}. This Bundle may have 134 * values with keys listed in {@link AccountCursorExtraKeys} 135 */ getAccountsUri()136 public static Uri getAccountsUri() { 137 return Uri.parse("content://" + sAuthority + "/"); 138 } 139 getInstance()140 public static MailAppProvider getInstance() { 141 return sInstance; 142 } 143 144 /** Default constructor */ MailAppProvider()145 protected MailAppProvider() { 146 } 147 148 @Override onCreate()149 public boolean onCreate() { 150 sAuthority = getAuthority(); 151 sInstance = this; 152 mResolver = getContext().getContentResolver(); 153 154 // Load the previously saved account list 155 loadCachedAccountList(); 156 157 final Resources res = getContext().getResources(); 158 // Load the uris for the account list 159 final String[] accountQueryUris = res.getStringArray(R.array.account_providers); 160 161 final Function<AccountCacheEntry, String> accountQueryUriExtractor = 162 new Function<AccountCacheEntry, String>() { 163 @Override 164 public String apply(AccountCacheEntry accountCacheEntry) { 165 if (accountCacheEntry == null) { 166 return null; 167 } 168 return accountCacheEntry.mAccountsQueryUri.toString(); 169 } 170 }; 171 mAccountComparator = new RankedComparator<AccountCacheEntry, String>( 172 accountQueryUris, accountQueryUriExtractor); 173 174 for (String accountQueryUri : accountQueryUris) { 175 final Uri uri = Uri.parse(accountQueryUri); 176 addAccountsForUriAsync(uri); 177 } 178 179 return true; 180 } 181 182 @Override shutdown()183 public void shutdown() { 184 sInstance = null; 185 186 for (CursorLoader loader : mCursorLoaderMap.values()) { 187 loader.stopLoading(); 188 } 189 mCursorLoaderMap.clear(); 190 mAccountsLoaded.clear(); 191 } 192 193 @Override query(Uri url, String[] projection, String selection, String[] selectionArgs, String sortOrder)194 public Cursor query(Uri url, String[] projection, String selection, String[] selectionArgs, 195 String sortOrder) { 196 // This content provider currently only supports one query (to return the list of accounts). 197 // No reason to check the uri. Currently only checking the projections 198 199 // Validates and returns the projection that should be used. 200 final String[] resultProjection = UIProviderValidator.validateAccountProjection(projection); 201 final Bundle extras = new Bundle(); 202 extras.putInt(AccountCursorExtraKeys.ACCOUNTS_LOADED, allAccountsLoaded() ? 1 : 0); 203 204 final List<AccountCacheEntry> accountList; 205 synchronized (mAccountCache) { 206 accountList = Lists.newArrayList(mAccountCache.values()); 207 } 208 209 // The order in which providers respond will affect the order of accounts. Because 210 // mAccountComparator only compares mAccountsQueryUri it will ensure that they are always 211 // sorted first based on that and later based on order returned by each provider. 212 Collections.sort(accountList, mAccountComparator); 213 214 final MatrixCursor cursor = 215 new MatrixCursorWithExtra(resultProjection, accountList.size(), extras); 216 217 for (AccountCacheEntry accountEntry : accountList) { 218 final Account account = accountEntry.mAccount; 219 final MatrixCursor.RowBuilder builder = cursor.newRow(); 220 final Map<String, Object> accountValues = account.getValueMap(); 221 222 for (final String columnName : resultProjection) { 223 if (accountValues.containsKey(columnName)) { 224 builder.add(accountValues.get(columnName)); 225 } else { 226 throw new IllegalStateException("Unexpected column: " + columnName); 227 } 228 } 229 } 230 231 cursor.setNotificationUri(mResolver, getAccountsUri()); 232 return cursor; 233 } 234 235 @Override insert(Uri url, ContentValues values)236 public Uri insert(Uri url, ContentValues values) { 237 return url; 238 } 239 240 @Override update(Uri url, ContentValues values, String selection, String[] selectionArgs)241 public int update(Uri url, ContentValues values, String selection, 242 String[] selectionArgs) { 243 return 0; 244 } 245 246 @Override delete(Uri url, String selection, String[] selectionArgs)247 public int delete(Uri url, String selection, String[] selectionArgs) { 248 return 0; 249 } 250 251 @Override getType(Uri uri)252 public String getType(Uri uri) { 253 return null; 254 } 255 256 /** 257 * Asynchronously adds all of the accounts that are specified by the result set returned by 258 * {@link ContentProvider#query()} for the specified uri. The content provider handling the 259 * query needs to handle the {@link UIProvider.ACCOUNTS_PROJECTION} 260 * Any changes to the underlying provider will automatically be reflected. 261 * @param accountsQueryUri 262 */ addAccountsForUriAsync(Uri accountsQueryUri)263 private void addAccountsForUriAsync(Uri accountsQueryUri) { 264 startAccountsLoader(accountsQueryUri); 265 } 266 267 /** 268 * Returns the intent that should be used in a call to 269 * {@link Context#startActivity(android.content.Intent)} when the account provider doesn't 270 * return any accounts 271 * @return Intent or null, if the provider doesn't specify a behavior when no acccounts are 272 * specified. 273 */ getNoAccountIntent(Context context)274 public static Intent getNoAccountIntent(Context context) { 275 return getInstance().getNoAccountsIntent(context); 276 } 277 startAccountsLoader(Uri accountsQueryUri)278 private synchronized void startAccountsLoader(Uri accountsQueryUri) { 279 final CursorLoader accountsCursorLoader = new CursorLoader(getContext(), accountsQueryUri, 280 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 281 282 // Listen for the results 283 accountsCursorLoader.registerListener(accountsQueryUri.hashCode(), this); 284 accountsCursorLoader.startLoading(); 285 286 // If there is a previous loader for the given uri, stop it 287 final CursorLoader oldLoader = mCursorLoaderMap.get(accountsQueryUri); 288 if (oldLoader != null) { 289 oldLoader.stopLoading(); 290 } 291 mCursorLoaderMap.put(accountsQueryUri, accountsCursorLoader); 292 mAccountsLoaded.put(accountsCursorLoader, false); 293 } 294 addAccountImpl(Account account, Uri accountsQueryUri, boolean notify)295 private void addAccountImpl(Account account, Uri accountsQueryUri, boolean notify) { 296 addAccountImpl(account.uri, new AccountCacheEntry(account, accountsQueryUri)); 297 298 // Explicitly calling this out of the synchronized block in case any of the observers get 299 // called synchronously. 300 if (notify) { 301 broadcastAccountChange(); 302 } 303 } 304 addAccountImpl(Uri key, AccountCacheEntry accountEntry)305 private void addAccountImpl(Uri key, AccountCacheEntry accountEntry) { 306 synchronized (mAccountCache) { 307 LogUtils.v(LOG_TAG, "adding account %s", accountEntry.mAccount); 308 // LinkedHashMap will not change the iteration order when re-inserting a key 309 mAccountCache.put(key, accountEntry); 310 } 311 } 312 broadcastAccountChange()313 private static void broadcastAccountChange() { 314 final MailAppProvider provider = sInstance; 315 316 if (provider != null) { 317 provider.mResolver.notifyChange(getAccountsUri(), null); 318 } 319 } 320 321 /** 322 * Returns the {@link Account#uri} (in String form) of the last viewed account. 323 */ getLastViewedAccount()324 public String getLastViewedAccount() { 325 return getPreferences().getString(LAST_VIEWED_ACCOUNT_KEY, null); 326 } 327 328 /** 329 * Persists the {@link Account#uri} (in String form) of the last viewed account. 330 */ setLastViewedAccount(String accountUriStr)331 public void setLastViewedAccount(String accountUriStr) { 332 final SharedPreferences.Editor editor = getPreferences().edit(); 333 editor.putString(LAST_VIEWED_ACCOUNT_KEY, accountUriStr); 334 editor.apply(); 335 } 336 337 /** 338 * Returns the {@link Account#uri} (in String form) of the last account the 339 * user compose a message from. 340 */ getLastSentFromAccount()341 public String getLastSentFromAccount() { 342 return getPreferences().getString(LAST_SENT_FROM_ACCOUNT_KEY, null); 343 } 344 345 /** 346 * Persists the {@link Account#uri} (in String form) of the last account the 347 * user compose a message from. 348 */ setLastSentFromAccount(String accountUriStr)349 public void setLastSentFromAccount(String accountUriStr) { 350 final SharedPreferences.Editor editor = getPreferences().edit(); 351 editor.putString(LAST_SENT_FROM_ACCOUNT_KEY, accountUriStr); 352 editor.apply(); 353 } 354 loadCachedAccountList()355 private void loadCachedAccountList() { 356 JSONArray accounts = null; 357 try { 358 final String accountsJson = getPreferences().getString(ACCOUNT_LIST_KEY, null); 359 if (accountsJson != null) { 360 accounts = new JSONArray(accountsJson); 361 } 362 } catch (Exception e) { 363 LogUtils.e(LOG_TAG, e, "ignoring unparsable accounts cache"); 364 } 365 366 if (accounts == null) { 367 return; 368 } 369 370 for (int i = 0; i < accounts.length(); i++) { 371 try { 372 final AccountCacheEntry accountEntry = new AccountCacheEntry( 373 accounts.getJSONObject(i)); 374 375 if (accountEntry.mAccount.settings == null) { 376 LogUtils.e(LOG_TAG, "Dropping account that doesn't specify settings"); 377 continue; 378 } 379 380 Account account = accountEntry.mAccount; 381 ContentProviderClient client = 382 mResolver.acquireContentProviderClient(account.uri); 383 if (client != null) { 384 client.release(); 385 addAccountImpl(account.uri, accountEntry); 386 } else { 387 LogUtils.e(LOG_TAG, "Dropping account without provider: %s", 388 account.getEmailAddress()); 389 } 390 391 } catch (Exception e) { 392 // Unable to create account object, skip to next 393 LogUtils.e(LOG_TAG, e, 394 "Unable to create account object from serialized form"); 395 } 396 } 397 broadcastAccountChange(); 398 } 399 cacheAccountList()400 private void cacheAccountList() { 401 final List<AccountCacheEntry> accountList; 402 403 synchronized (mAccountCache) { 404 accountList = ImmutableList.copyOf(mAccountCache.values()); 405 } 406 407 final JSONArray arr = new JSONArray(); 408 for (AccountCacheEntry accountEntry : accountList) { 409 arr.put(accountEntry.toJSONObject()); 410 } 411 412 final SharedPreferences.Editor editor = getPreferences().edit(); 413 editor.putString(ACCOUNT_LIST_KEY, arr.toString()); 414 editor.apply(); 415 } 416 getPreferences()417 private SharedPreferences getPreferences() { 418 if (mSharedPrefs == null) { 419 mSharedPrefs = getContext().getSharedPreferences( 420 SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); 421 } 422 return mSharedPrefs; 423 } 424 getAccountFromAccountUri(Uri accountUri)425 static public Account getAccountFromAccountUri(Uri accountUri) { 426 MailAppProvider provider = getInstance(); 427 if (provider != null && provider.allAccountsLoaded()) { 428 synchronized(provider.mAccountCache) { 429 AccountCacheEntry entry = provider.mAccountCache.get(accountUri); 430 if (entry != null) { 431 return entry.mAccount; 432 } 433 } 434 } 435 return null; 436 } 437 438 @Override onLoadComplete(Loader<Cursor> loader, Cursor data)439 public void onLoadComplete(Loader<Cursor> loader, Cursor data) { 440 if (data == null) { 441 LogUtils.d(LOG_TAG, "null account cursor returned"); 442 return; 443 } 444 445 LogUtils.d(LOG_TAG, "Cursor with %d accounts returned", data.getCount()); 446 final CursorLoader cursorLoader = (CursorLoader)loader; 447 final Uri accountsQueryUri = cursorLoader.getUri(); 448 449 // preserve ordering on partial updates 450 // also preserve ordering on complete updates for any that existed previously 451 452 453 final List<AccountCacheEntry> accountList; 454 synchronized (mAccountCache) { 455 accountList = ImmutableList.copyOf(mAccountCache.values()); 456 } 457 458 // Build a set of the account uris that had been associated with that query 459 final Set<Uri> previousQueryUriSet = Sets.newHashSet(); 460 for (AccountCacheEntry entry : accountList) { 461 if (accountsQueryUri.equals(entry.mAccountsQueryUri)) { 462 previousQueryUriSet.add(entry.mAccount.uri); 463 } 464 } 465 466 // Update the internal state of this provider if the returned result set 467 // represents all accounts 468 final boolean accountsFullyLoaded = 469 data.getExtras().getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0; 470 mAccountsLoaded.put(cursorLoader, accountsFullyLoaded); 471 472 final Set<Uri> newQueryUriSet = Sets.newHashSet(); 473 474 // We are relying on the fact that all accounts are added in the order specified in the 475 // cursor. Initially assume that we insert these items to at the end of the list 476 while (data.moveToNext()) { 477 final Account account = Account.builder().buildFrom(data); 478 final Uri accountUri = account.uri; 479 newQueryUriSet.add(accountUri); 480 481 // preserve existing order if already present and this is a partial update, 482 // otherwise add to the end 483 // 484 // N.B. this ordering policy means the order in which providers respond will affect 485 // the order of accounts. 486 if (accountsFullyLoaded) { 487 synchronized (mAccountCache) { 488 // removing the existing item will prevent LinkedHashMap from preserving the 489 // original insertion order 490 mAccountCache.remove(accountUri); 491 } 492 } 493 addAccountImpl(account, accountsQueryUri, false /* don't notify */); 494 } 495 // Remove all of the accounts that are in the new result set 496 previousQueryUriSet.removeAll(newQueryUriSet); 497 498 // For all of the entries that had been in the previous result set, and are not 499 // in the new result set, remove them from the cache 500 if (previousQueryUriSet.size() > 0 && accountsFullyLoaded) { 501 synchronized (mAccountCache) { 502 for (Uri accountUri : previousQueryUriSet) { 503 LogUtils.d(LOG_TAG, "Removing account %s", accountUri); 504 mAccountCache.remove(accountUri); 505 } 506 } 507 } 508 broadcastAccountChange(); 509 510 // Cache the updated account list 511 cacheAccountList(); 512 } 513 allAccountsLoaded()514 private boolean allAccountsLoaded() { 515 for (Boolean loaded : mAccountsLoaded.values()) { 516 if (!loaded) { 517 return false; 518 } 519 } 520 return true; 521 } 522 523 /** 524 * Object that allows the Account Cache provider to associate the account with the content 525 * provider uri that originated that account. 526 */ 527 private static class AccountCacheEntry { 528 final Account mAccount; 529 final Uri mAccountsQueryUri; 530 531 private static final String KEY_ACCOUNT = "acct"; 532 private static final String KEY_QUERY_URI = "queryUri"; 533 AccountCacheEntry(Account account, Uri accountQueryUri)534 public AccountCacheEntry(Account account, Uri accountQueryUri) { 535 mAccount = account; 536 mAccountsQueryUri = accountQueryUri; 537 } 538 AccountCacheEntry(JSONObject o)539 public AccountCacheEntry(JSONObject o) throws JSONException { 540 mAccount = Account.newInstance(o.getString(KEY_ACCOUNT)); 541 if (mAccount == null) { 542 throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. " 543 + "Account object could not be created from the JSONObject: " 544 + o); 545 } 546 if (mAccount.settings == Settings.EMPTY_SETTINGS) { 547 throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. " 548 + "Settings could not be created from the JSONObject: " + o); 549 } 550 final String uriStr = o.optString(KEY_QUERY_URI, null); 551 if (uriStr != null) { 552 mAccountsQueryUri = Uri.parse(uriStr); 553 } else { 554 mAccountsQueryUri = null; 555 } 556 } 557 toJSONObject()558 public JSONObject toJSONObject() { 559 try { 560 return new JSONObject() 561 .put(KEY_ACCOUNT, mAccount.serialize()) 562 .putOpt(KEY_QUERY_URI, mAccountsQueryUri); 563 } catch (JSONException e) { 564 // shouldn't happen 565 throw new IllegalArgumentException(e); 566 } 567 } 568 } 569 } 570