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