Home | History | Annotate | Download | only in providers
      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      */
    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      */
    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      */
    136     public static Uri getAccountsUri() {
    137         return Uri.parse("content://" + sAuthority + "/");
    138     }
    139 
    140     public static MailAppProvider getInstance() {
    141         return sInstance;
    142     }
    143 
    144     /** Default constructor */
    145     protected MailAppProvider() {
    146     }
    147 
    148     @Override
    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
    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
    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
    236     public Uri insert(Uri url, ContentValues values) {
    237         return url;
    238     }
    239 
    240     @Override
    241     public int update(Uri url, ContentValues values, String selection,
    242             String[] selectionArgs) {
    243         return 0;
    244     }
    245 
    246     @Override
    247     public int delete(Uri url, String selection, String[] selectionArgs) {
    248         return 0;
    249     }
    250 
    251     @Override
    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      */
    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      */
    274     public static Intent getNoAccountIntent(Context context) {
    275         return getInstance().getNoAccountsIntent(context);
    276     }
    277 
    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 
    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 
    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 
    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      */
    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      */
    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      */
    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      */
    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 
    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 
    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 
    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 
    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
    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 
    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 
    534         public AccountCacheEntry(Account account, Uri accountQueryUri) {
    535             mAccount = account;
    536             mAccountsQueryUri = accountQueryUri;
    537         }
    538 
    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 
    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