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