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