| /** |
| * Copyright (c) 2011, Google Inc. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.mail.providers; |
| |
| import android.app.Activity; |
| import android.content.ContentProvider; |
| import android.content.ContentProviderClient; |
| import android.content.ContentResolver; |
| import android.content.ContentValues; |
| import android.content.Context; |
| import android.content.CursorLoader; |
| import android.content.Intent; |
| import android.content.Loader; |
| import android.content.Loader.OnLoadCompleteListener; |
| import android.content.SharedPreferences; |
| import android.content.res.Resources; |
| import android.database.Cursor; |
| import android.database.MatrixCursor; |
| import android.net.Uri; |
| import android.os.Bundle; |
| |
| import com.android.mail.R; |
| import com.android.mail.providers.UIProvider.AccountCursorExtraKeys; |
| import com.android.mail.utils.LogTag; |
| import com.android.mail.utils.LogUtils; |
| import com.android.mail.utils.MatrixCursorWithExtra; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.Maps; |
| import com.google.common.collect.Sets; |
| |
| import org.json.JSONArray; |
| import org.json.JSONException; |
| import org.json.JSONObject; |
| |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| |
| |
| /** |
| * The Mail App provider allows email providers to register "accounts" and the UI has a single |
| * place to query for the list of accounts. |
| * |
| * During development this will allow new account types to be added, and allow them to be shown in |
| * the application. For example, the mock accounts can be enabled/disabled. |
| * In the future, once other processes can add new accounts, this could allow other "mail" |
| * applications have their content appear within the application |
| */ |
| public abstract class MailAppProvider extends ContentProvider |
| implements OnLoadCompleteListener<Cursor>{ |
| |
| private static final String SHARED_PREFERENCES_NAME = "MailAppProvider"; |
| private static final String ACCOUNT_LIST_KEY = "accountList"; |
| private static final String LAST_VIEWED_ACCOUNT_KEY = "lastViewedAccount"; |
| private static final String LAST_SENT_FROM_ACCOUNT_KEY = "lastSendFromAccount"; |
| |
| /** |
| * Extra used in the result from the activity launched by the intent specified |
| * by {@link #getNoAccountsIntent} to return the list of accounts. The data |
| * specified by this extra key should be a ParcelableArray. |
| */ |
| public static final String ADD_ACCOUNT_RESULT_ACCOUNTS_EXTRA = "addAccountResultAccounts"; |
| |
| private final static String LOG_TAG = LogTag.getLogTag(); |
| |
| private final LinkedHashMap<Uri, AccountCacheEntry> mAccountCache = |
| new LinkedHashMap<Uri, AccountCacheEntry>(); |
| |
| private final Map<Uri, CursorLoader> mCursorLoaderMap = Maps.newHashMap(); |
| |
| private ContentResolver mResolver; |
| private static String sAuthority; |
| private static MailAppProvider sInstance; |
| |
| private volatile boolean mAccountsFullyLoaded = false; |
| |
| private SharedPreferences mSharedPrefs; |
| |
| /** |
| * Allows the implementing provider to specify the authority for this provider. Email and Gmail |
| * must specify different authorities. |
| */ |
| protected abstract String getAuthority(); |
| |
| /** |
| * Authority for the suggestions provider. Email and Gmail must specify different authorities, |
| * much like the implementation of {@link #getAuthority()}. |
| * @return the suggestion authority associated with this provider. |
| */ |
| public abstract String getSuggestionAuthority(); |
| |
| /** |
| * Allows the implementing provider to specify an intent that should be used in a call to |
| * {@link Context#startActivityForResult(android.content.Intent)} when the account provider |
| * doesn't return any accounts. |
| * |
| * The result from the {@link Activity} activity should include the list of accounts in |
| * the returned intent, in the |
| |
| * @return Intent or null, if the provider doesn't specify a behavior when no accounts are |
| * specified. |
| */ |
| protected abstract Intent getNoAccountsIntent(Context context); |
| |
| /** |
| * The cursor returned from a call to {@link android.content.ContentResolver#query()} with this |
| * uri will return a cursor that with columns that are a subset of the columns specified |
| * in {@link UIProvider.ConversationColumns} |
| * The cursor returned by this query can return a {@link android.os.Bundle} |
| * from a call to {@link android.database.Cursor#getExtras()}. This Bundle may have |
| * values with keys listed in {@link AccountCursorExtraKeys} |
| */ |
| public static Uri getAccountsUri() { |
| return Uri.parse("content://" + sAuthority + "/"); |
| } |
| |
| public static MailAppProvider getInstance() { |
| return sInstance; |
| } |
| |
| /** Default constructor */ |
| protected MailAppProvider() { |
| } |
| |
| @Override |
| public boolean onCreate() { |
| sAuthority = getAuthority(); |
| sInstance = this; |
| mResolver = getContext().getContentResolver(); |
| |
| // Load the previously saved account list |
| loadCachedAccountList(); |
| |
| final Resources res = getContext().getResources(); |
| // Load the uris for the account list |
| final String[] accountQueryUris = res.getStringArray(R.array.account_providers); |
| |
| for (String accountQueryUri : accountQueryUris) { |
| final Uri uri = Uri.parse(accountQueryUri); |
| addAccountsForUriAsync(uri); |
| } |
| |
| return true; |
| } |
| |
| @Override |
| public void shutdown() { |
| sInstance = null; |
| |
| for (CursorLoader loader : mCursorLoaderMap.values()) { |
| loader.stopLoading(); |
| } |
| mCursorLoaderMap.clear(); |
| } |
| |
| @Override |
| public Cursor query(Uri url, String[] projection, String selection, String[] selectionArgs, |
| String sortOrder) { |
| // This content provider currently only supports one query (to return the list of accounts). |
| // No reason to check the uri. Currently only checking the projections |
| |
| // Validates and returns the projection that should be used. |
| final String[] resultProjection = UIProviderValidator.validateAccountProjection(projection); |
| final Bundle extras = new Bundle(); |
| extras.putInt(AccountCursorExtraKeys.ACCOUNTS_LOADED, mAccountsFullyLoaded ? 1 : 0); |
| |
| // Make a copy of the account cache |
| final List<AccountCacheEntry> accountList; |
| synchronized (mAccountCache) { |
| accountList = ImmutableList.copyOf(mAccountCache.values()); |
| } |
| |
| final MatrixCursor cursor = |
| new MatrixCursorWithExtra(resultProjection, accountList.size(), extras); |
| |
| for (AccountCacheEntry accountEntry : accountList) { |
| final Account account = accountEntry.mAccount; |
| final MatrixCursor.RowBuilder builder = cursor.newRow(); |
| final Map<String, Object> accountValues = account.getValueMap(); |
| |
| for (final String columnName : resultProjection) { |
| if (accountValues.containsKey(columnName)) { |
| builder.add(accountValues.get(columnName)); |
| } else { |
| throw new IllegalStateException("Unexpected column: " + columnName); |
| } |
| } |
| } |
| |
| cursor.setNotificationUri(mResolver, getAccountsUri()); |
| return cursor; |
| } |
| |
| @Override |
| public Uri insert(Uri url, ContentValues values) { |
| return url; |
| } |
| |
| @Override |
| public int update(Uri url, ContentValues values, String selection, |
| String[] selectionArgs) { |
| return 0; |
| } |
| |
| @Override |
| public int delete(Uri url, String selection, String[] selectionArgs) { |
| return 0; |
| } |
| |
| @Override |
| public String getType(Uri uri) { |
| return null; |
| } |
| |
| /** |
| * Asynchronously adds all of the accounts that are specified by the result set returned by |
| * {@link ContentProvider#query()} for the specified uri. The content provider handling the |
| * query needs to handle the {@link UIProvider.ACCOUNTS_PROJECTION} |
| * Any changes to the underlying provider will automatically be reflected. |
| * @param accountsQueryUri |
| */ |
| private void addAccountsForUriAsync(Uri accountsQueryUri) { |
| startAccountsLoader(accountsQueryUri); |
| } |
| |
| /** |
| * Returns the intent that should be used in a call to |
| * {@link Context#startActivity(android.content.Intent)} when the account provider doesn't |
| * return any accounts |
| * @return Intent or null, if the provider doesn't specify a behavior when no acccounts are |
| * specified. |
| */ |
| public static Intent getNoAccountIntent(Context context) { |
| return getInstance().getNoAccountsIntent(context); |
| } |
| |
| private synchronized void startAccountsLoader(Uri accountsQueryUri) { |
| final CursorLoader accountsCursorLoader = new CursorLoader(getContext(), accountsQueryUri, |
| UIProvider.ACCOUNTS_PROJECTION, null, null, null); |
| |
| // Listen for the results |
| accountsCursorLoader.registerListener(accountsQueryUri.hashCode(), this); |
| accountsCursorLoader.startLoading(); |
| |
| // If there is a previous loader for the given uri, stop it |
| final CursorLoader oldLoader = mCursorLoaderMap.get(accountsQueryUri); |
| if (oldLoader != null) { |
| oldLoader.stopLoading(); |
| } |
| mCursorLoaderMap.put(accountsQueryUri, accountsCursorLoader); |
| } |
| |
| private void addAccountImpl(Account account, Uri accountsQueryUri, boolean notify) { |
| addAccountImpl(account.uri, new AccountCacheEntry(account, accountsQueryUri)); |
| |
| // Explicitly calling this out of the synchronized block in case any of the observers get |
| // called synchronously. |
| if (notify) { |
| broadcastAccountChange(); |
| } |
| } |
| |
| private void addAccountImpl(Uri key, AccountCacheEntry accountEntry) { |
| synchronized (mAccountCache) { |
| LogUtils.v(LOG_TAG, "adding account %s", accountEntry.mAccount); |
| // LinkedHashMap will not change the iteration order when re-inserting a key |
| mAccountCache.put(key, accountEntry); |
| } |
| } |
| |
| private static void broadcastAccountChange() { |
| final MailAppProvider provider = sInstance; |
| |
| if (provider != null) { |
| provider.mResolver.notifyChange(getAccountsUri(), null); |
| } |
| } |
| |
| /** |
| * Returns the {@link Account#uri} (in String form) of the last viewed account. |
| */ |
| public String getLastViewedAccount() { |
| return getPreferences().getString(LAST_VIEWED_ACCOUNT_KEY, null); |
| } |
| |
| /** |
| * Persists the {@link Account#uri} (in String form) of the last viewed account. |
| */ |
| public void setLastViewedAccount(String accountUriStr) { |
| final SharedPreferences.Editor editor = getPreferences().edit(); |
| editor.putString(LAST_VIEWED_ACCOUNT_KEY, accountUriStr); |
| editor.apply(); |
| } |
| |
| /** |
| * Returns the {@link Account#uri} (in String form) of the last account the |
| * user compose a message from. |
| */ |
| public String getLastSentFromAccount() { |
| return getPreferences().getString(LAST_SENT_FROM_ACCOUNT_KEY, null); |
| } |
| |
| /** |
| * Persists the {@link Account#uri} (in String form) of the last account the |
| * user compose a message from. |
| */ |
| public void setLastSentFromAccount(String accountUriStr) { |
| final SharedPreferences.Editor editor = getPreferences().edit(); |
| editor.putString(LAST_SENT_FROM_ACCOUNT_KEY, accountUriStr); |
| editor.apply(); |
| } |
| |
| private void loadCachedAccountList() { |
| JSONArray accounts = null; |
| try { |
| final String accountsJson = getPreferences().getString(ACCOUNT_LIST_KEY, null); |
| if (accountsJson != null) { |
| accounts = new JSONArray(accountsJson); |
| } |
| } catch (Exception e) { |
| LogUtils.e(LOG_TAG, e, "ignoring unparsable accounts cache"); |
| } |
| |
| if (accounts == null) { |
| return; |
| } |
| |
| for (int i = 0; i < accounts.length(); i++) { |
| try { |
| final AccountCacheEntry accountEntry = new AccountCacheEntry( |
| accounts.getJSONObject(i)); |
| |
| if (accountEntry.mAccount.settings == null) { |
| LogUtils.e(LOG_TAG, "Dropping account that doesn't specify settings"); |
| continue; |
| } |
| |
| Account account = accountEntry.mAccount; |
| ContentProviderClient client = |
| mResolver.acquireContentProviderClient(account.uri); |
| if (client != null) { |
| client.release(); |
| addAccountImpl(account.uri, accountEntry); |
| } else { |
| LogUtils.e(LOG_TAG, "Dropping account without provider: %s", |
| account.getEmailAddress()); |
| } |
| |
| } catch (Exception e) { |
| // Unable to create account object, skip to next |
| LogUtils.e(LOG_TAG, e, |
| "Unable to create account object from serialized form"); |
| } |
| } |
| broadcastAccountChange(); |
| } |
| |
| private void cacheAccountList() { |
| final List<AccountCacheEntry> accountList; |
| |
| synchronized (mAccountCache) { |
| accountList = ImmutableList.copyOf(mAccountCache.values()); |
| } |
| |
| final JSONArray arr = new JSONArray(); |
| for (AccountCacheEntry accountEntry : accountList) { |
| arr.put(accountEntry.toJSONObject()); |
| } |
| |
| final SharedPreferences.Editor editor = getPreferences().edit(); |
| editor.putString(ACCOUNT_LIST_KEY, arr.toString()); |
| editor.apply(); |
| } |
| |
| private SharedPreferences getPreferences() { |
| if (mSharedPrefs == null) { |
| mSharedPrefs = getContext().getSharedPreferences( |
| SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE); |
| } |
| return mSharedPrefs; |
| } |
| |
| static public Account getAccountFromAccountUri(Uri accountUri) { |
| MailAppProvider provider = getInstance(); |
| if (provider != null && provider.mAccountsFullyLoaded) { |
| synchronized(provider.mAccountCache) { |
| AccountCacheEntry entry = provider.mAccountCache.get(accountUri); |
| if (entry != null) { |
| return entry.mAccount; |
| } |
| } |
| } |
| return null; |
| } |
| |
| @Override |
| public void onLoadComplete(Loader<Cursor> loader, Cursor data) { |
| if (data == null) { |
| LogUtils.d(LOG_TAG, "null account cursor returned"); |
| return; |
| } |
| |
| LogUtils.d(LOG_TAG, "Cursor with %d accounts returned", data.getCount()); |
| final CursorLoader cursorLoader = (CursorLoader)loader; |
| final Uri accountsQueryUri = cursorLoader.getUri(); |
| |
| // preserve ordering on partial updates |
| // also preserve ordering on complete updates for any that existed previously |
| |
| |
| final List<AccountCacheEntry> accountList; |
| synchronized (mAccountCache) { |
| accountList = ImmutableList.copyOf(mAccountCache.values()); |
| } |
| |
| // Build a set of the account uris that had been associated with that query |
| final Set<Uri> previousQueryUriSet = Sets.newHashSet(); |
| for (AccountCacheEntry entry : accountList) { |
| if (accountsQueryUri.equals(entry.mAccountsQueryUri)) { |
| previousQueryUriSet.add(entry.mAccount.uri); |
| } |
| } |
| |
| // Update the internal state of this provider if the returned result set |
| // represents all accounts |
| // TODO: determine what should happen with a heterogeneous set of accounts |
| final Bundle extra = data.getExtras(); |
| mAccountsFullyLoaded = extra.getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0; |
| |
| final Set<Uri> newQueryUriMap = Sets.newHashSet(); |
| |
| // We are relying on the fact that all accounts are added in the order specified in the |
| // cursor. Initially assume that we insert these items to at the end of the list |
| while (data.moveToNext()) { |
| final Account account = new Account(data); |
| final Uri accountUri = account.uri; |
| newQueryUriMap.add(accountUri); |
| // preserve existing order if already present and this is a partial update, |
| // otherwise add to the end |
| // |
| // N.B. this ordering policy means the order in which providers respond will affect |
| // the order of accounts. |
| if (mAccountsFullyLoaded) { |
| synchronized (mAccountCache) { |
| // removing the existing item will prevent LinkedHashMap from preserving the |
| // original insertion order |
| mAccountCache.remove(accountUri); |
| } |
| } |
| addAccountImpl(account, accountsQueryUri, false /* don't notify */); |
| } |
| // Remove all of the accounts that are in the new result set |
| previousQueryUriSet.removeAll(newQueryUriMap); |
| |
| // For all of the entries that had been in the previous result set, and are not |
| // in the new result set, remove them from the cache |
| if (previousQueryUriSet.size() > 0 && mAccountsFullyLoaded) { |
| synchronized (mAccountCache) { |
| for (Uri accountUri : previousQueryUriSet) { |
| LogUtils.d(LOG_TAG, "Removing account %s", accountUri); |
| mAccountCache.remove(accountUri); |
| } |
| } |
| } |
| broadcastAccountChange(); |
| |
| // Cache the updated account list |
| cacheAccountList(); |
| } |
| |
| /** |
| * Object that allows the Account Cache provider to associate the account with the content |
| * provider uri that originated that account. |
| */ |
| private static class AccountCacheEntry { |
| final Account mAccount; |
| final Uri mAccountsQueryUri; |
| |
| private static final String KEY_ACCOUNT = "acct"; |
| private static final String KEY_QUERY_URI = "queryUri"; |
| |
| public AccountCacheEntry(Account account, Uri accountQueryUri) { |
| mAccount = account; |
| mAccountsQueryUri = accountQueryUri; |
| } |
| |
| public AccountCacheEntry(JSONObject o) throws JSONException { |
| mAccount = Account.newInstance(o.getString(KEY_ACCOUNT)); |
| if (mAccount == null) { |
| throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. " |
| + "Account object could not be created from the JSONObject: " |
| + o); |
| } |
| if (mAccount.settings == Settings.EMPTY_SETTINGS) { |
| throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. " |
| + "Settings could not be created from the JSONObject: " + o); |
| } |
| final String uriStr = o.optString(KEY_QUERY_URI, null); |
| if (uriStr != null) { |
| mAccountsQueryUri = Uri.parse(uriStr); |
| } else { |
| mAccountsQueryUri = null; |
| } |
| } |
| |
| public JSONObject toJSONObject() { |
| try { |
| return new JSONObject() |
| .put(KEY_ACCOUNT, mAccount.serialize()) |
| .putOpt(KEY_QUERY_URI, mAccountsQueryUri); |
| } catch (JSONException e) { |
| // shouldn't happen |
| throw new IllegalArgumentException(e); |
| } |
| } |
| |
| } |
| } |