• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.google.common.collect.ImmutableList;
42 import com.google.common.collect.Maps;
43 import com.google.common.collect.Sets;
44 
45 import org.json.JSONArray;
46 import org.json.JSONException;
47 import org.json.JSONObject;
48 
49 import java.util.LinkedHashMap;
50 import java.util.List;
51 import java.util.Map;
52 import 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  */
64 public 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      */
getAuthority()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      */
getSuggestionAuthority()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      */
getNoAccountsIntent(Context context)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      */
getAccountsUri()128     public static Uri getAccountsUri() {
129         return Uri.parse("content://" + sAuthority + "/");
130     }
131 
getInstance()132     public static MailAppProvider getInstance() {
133         return sInstance;
134     }
135 
136     /** Default constructor */
MailAppProvider()137     protected MailAppProvider() {
138     }
139 
140     @Override
onCreate()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
shutdown()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
query(Uri url, String[] projection, String selection, String[] selectionArgs, String sortOrder)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
insert(Uri url, ContentValues values)210     public Uri insert(Uri url, ContentValues values) {
211         return url;
212     }
213 
214     @Override
update(Uri url, ContentValues values, String selection, String[] selectionArgs)215     public int update(Uri url, ContentValues values, String selection,
216             String[] selectionArgs) {
217         return 0;
218     }
219 
220     @Override
delete(Uri url, String selection, String[] selectionArgs)221     public int delete(Uri url, String selection, String[] selectionArgs) {
222         return 0;
223     }
224 
225     @Override
getType(Uri uri)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      */
addAccountsForUriAsync(Uri accountsQueryUri)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      */
getNoAccountIntent(Context context)248     public static Intent getNoAccountIntent(Context context) {
249         return getInstance().getNoAccountsIntent(context);
250     }
251 
startAccountsLoader(Uri accountsQueryUri)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 
addAccountImpl(Account account, Uri accountsQueryUri, boolean notify)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 
addAccountImpl(Uri key, AccountCacheEntry accountEntry)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 
broadcastAccountChange()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      */
getLastViewedAccount()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      */
setLastViewedAccount(String accountUriStr)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      */
getLastSentFromAccount()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      */
setLastSentFromAccount(String accountUriStr)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 
loadCachedAccountList()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 
cacheAccountList()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 
getPreferences()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 
getAccountFromAccountUri(Uri accountUri)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
onLoadComplete(Loader<Cursor> loader, Cursor data)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 
AccountCacheEntry(Account account, Uri accountQueryUri)497         public AccountCacheEntry(Account account, Uri accountQueryUri) {
498             mAccount = account;
499             mAccountsQueryUri = accountQueryUri;
500         }
501 
AccountCacheEntry(JSONObject o)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 
toJSONObject()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