MailAppProvider.java revision 2388c5d208acd4e4e658aaacbbc5f080ee8e9f7c
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 com.android.mail.providers.Account;
20import com.android.mail.providers.UIProvider.AccountCursorExtraKeys;
21import com.android.mail.providers.protos.boot.AccountReceiver;
22import com.android.mail.utils.MatrixCursorWithExtra;
23
24import android.content.Intent;
25import android.content.Loader;
26import android.content.ContentProvider;
27import android.content.ContentResolver;
28import android.content.ContentValues;
29import android.content.Context;
30import android.content.CursorLoader;
31import android.content.Loader.OnLoadCompleteListener;
32import android.content.SharedPreferences;
33import com.android.mail.utils.LogUtils;
34
35import android.database.Cursor;
36import android.database.MatrixCursor;
37import android.net.Uri;
38import android.os.Bundle;
39import android.provider.BaseColumns;
40import android.text.TextUtils;
41
42import com.google.common.collect.ImmutableSet;
43import com.google.common.collect.Maps;
44import com.google.common.collect.Sets;
45
46import java.lang.IllegalStateException;
47import java.lang.StringBuilder;
48import java.util.Collections;
49import java.util.Map;
50import java.util.Set;
51import java.util.regex.Pattern;
52
53
54
55/**
56 * The Account Cache 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
71    /**
72     * Extra used in the result from the activity launched by the intent specified
73     * by {@link #getNoAccountsIntent} to return the list of accounts.  The data
74     * specified by this extra key should be a ParcelableArray.
75     */
76    public static final String ADD_ACCOUNT_RESULT_ACCOUNTS_EXTRA = "addAccountResultAccounts";
77
78    private final static String LOG_TAG = new LogUtils().getLogTag();
79
80    private final Map<Uri, AccountCacheEntry> mAccountCache = Maps.newHashMap();
81
82    private final Map<Uri, CursorLoader> mCursorLoaderMap = Maps.newHashMap();
83
84    private ContentResolver mResolver;
85    private static String sAuthority;
86    private static MailAppProvider sInstance;
87
88    private volatile boolean mAccountsFullyLoaded = false;
89
90    private SharedPreferences mSharedPrefs;
91
92    /**
93     * Allows the implmenting provider to specify the authority that should be used.
94     */
95    protected abstract String getAuthority();
96
97    /**
98     * Allows the implemnting provider to specify an intent that should be used in a call to
99     * {@link Context#startActivityForResult(android.content.Intent)} when the account provider
100     * doesn't return any accounts.
101     *
102     * The result from the {@link Activity} activity should include the list of accounts in
103     * the returned intent, in the
104
105     * @return Intent or null, if the provider doesn't specify a behavior when no acccounts are
106     * specified.
107     */
108    protected abstract Intent getNoAccountsIntent(Context context);
109
110    /**
111     * The cursor returned from a call to {@link android.content.ContentResolver#query() with this
112     * uri will return a cursor that with columns that are a subset of the columns specified
113     * in {@link UIProvider.ConversationColumns}
114     * The cursor returned by this query can return a {@link android.os.Bundle}
115     * from a call to {@link android.database.Cursor#getExtras()}.  This Bundle may have
116     * values with keys listed in {@link AccountCursorExtraKeys}
117     */
118    public static Uri getAccountsUri() {
119        return Uri.parse("content://" + sAuthority + "/");
120    }
121
122    public static MailAppProvider getInstance() {
123        return sInstance;
124    }
125
126    @Override
127    public boolean onCreate() {
128        sInstance = this;
129        sAuthority = getAuthority();
130        mResolver = getContext().getContentResolver();
131
132        final Intent intent = new Intent(AccountReceiver.ACTION_PROVIDER_CREATED);
133        getContext().sendBroadcast(intent);
134
135        // Load the previously saved account list
136        loadCachedAccountList();
137
138        return true;
139    }
140
141    @Override
142    public void shutdown() {
143        sInstance = null;
144
145        for (CursorLoader loader : mCursorLoaderMap.values()) {
146            loader.stopLoading();
147        }
148        mCursorLoaderMap.clear();
149    }
150
151    @Override
152    public Cursor query(Uri url, String[] projection, String selection, String[] selectionArgs,
153            String sortOrder) {
154        // This content provider currently only supports one query (to return the list of accounts).
155        // No reason to check the uri.  Currently only checking the projections
156
157        // Validates and returns the projection that should be used.
158        final String[] resultProjection = UIProviderValidator.validateAccountProjection(projection);
159        final Bundle extras = new Bundle();
160        extras.putInt(AccountCursorExtraKeys.ACCOUNTS_LOADED, mAccountsFullyLoaded ? 1 : 0);
161
162        // Make a copy of the account cache
163
164        final Set<AccountCacheEntry> accountList;
165        synchronized (mAccountCache) {
166            accountList = ImmutableSet.copyOf(mAccountCache.values());
167        }
168
169        final MatrixCursor cursor =
170                new MatrixCursorWithExtra(resultProjection, accountList.size(), extras);
171
172        for (AccountCacheEntry accountEntry : accountList) {
173            final Account account = accountEntry.mAccount;
174            final MatrixCursor.RowBuilder builder = cursor.newRow();
175
176            for (String column : resultProjection) {
177                if (TextUtils.equals(column, BaseColumns._ID)) {
178                    // TODO(pwestbro): remove this as it isn't used.
179                    builder.add(Integer.valueOf(0));
180                } else if (TextUtils.equals(column, UIProvider.AccountColumns.NAME)) {
181                    builder.add(account.name);
182                } else if (TextUtils.equals(column, UIProvider.AccountColumns.PROVIDER_VERSION)) {
183                    // TODO fix this
184                    builder.add(Integer.valueOf(account.providerVersion));
185                } else if (TextUtils.equals(column, UIProvider.AccountColumns.URI)) {
186                    builder.add(account.uri);
187                } else if (TextUtils.equals(column, UIProvider.AccountColumns.CAPABILITIES)) {
188                    builder.add(Integer.valueOf(account.capabilities));
189                } else if (TextUtils.equals(column, UIProvider.AccountColumns.FOLDER_LIST_URI)) {
190                    builder.add(account.folderListUri);
191                } else if (TextUtils.equals(column, UIProvider.AccountColumns.SEARCH_URI)) {
192                    builder.add(account.searchUri);
193                } else if (TextUtils.equals(column,
194                        UIProvider.AccountColumns.ACCOUNT_FROM_ADDRESSES_URI)) {
195                    builder.add(account.accountFromAddressesUri);
196                } else if (TextUtils.equals(column, UIProvider.AccountColumns.SAVE_DRAFT_URI)) {
197                    builder.add(account.saveDraftUri);
198                } else if (TextUtils.equals(column, UIProvider.AccountColumns.SEND_MAIL_URI)) {
199                    builder.add(account.sendMessageUri);
200                } else if (TextUtils.equals(column,
201                        UIProvider.AccountColumns.EXPUNGE_MESSAGE_URI)) {
202                    builder.add(account.expungeMessageUri);
203                } else if (TextUtils.equals(column, UIProvider.AccountColumns.UNDO_URI)) {
204                    builder.add(account.undoUri);
205                } else if (TextUtils.equals(column,
206                        UIProvider.AccountColumns.SETTINGS_INTENT_URI)) {
207                    builder.add(account.settingsIntentUri);
208                } else if (TextUtils.equals(column,
209                        UIProvider.AccountColumns.SETTINGS_QUERY_URI)) {
210                    builder.add(account.settingsQueryUri);
211                } else if (TextUtils.equals(column,
212                        UIProvider.AccountColumns.HELP_INTENT_URI)) {
213                    builder.add(account.helpIntentUri);
214                } else if (TextUtils.equals(column,
215                        UIProvider.AccountColumns.SEND_FEEDBACK_INTENT_URI)) {
216                    builder.add(account.sendFeedbackIntentUri);
217                } else if (TextUtils.equals(column, UIProvider.AccountColumns.SYNC_STATUS)) {
218                    builder.add(Integer.valueOf(account.syncStatus));
219                } else if (TextUtils.equals(column, UIProvider.AccountColumns.COMPOSE_URI)) {
220                    builder.add(account.composeIntentUri);
221                } else if (TextUtils.equals(column, UIProvider.AccountColumns.MIME_TYPE)) {
222                    builder.add(account.mimeType);
223                } else if (TextUtils.equals(column,
224                        UIProvider.AccountColumns.RECENT_FOLDER_LIST_URI)) {
225                    builder.add(account.recentFolderListUri);
226                } else {
227                    throw new IllegalStateException("Column not found: " + column);
228                }
229            }
230        }
231
232        cursor.setNotificationUri(mResolver, getAccountsUri());
233        return cursor;
234    }
235
236    @Override
237    public Uri insert(Uri url, ContentValues values) {
238        return url;
239    }
240
241    @Override
242    public int update(Uri url, ContentValues values, String selection,
243            String[] selectionArgs) {
244        return 0;
245    }
246
247    @Override
248    public int delete(Uri url, String selection, String[] selectionArgs) {
249        return 0;
250    }
251
252    @Override
253    public String getType(Uri uri) {
254        return null;
255    }
256
257    /**
258     * Asynchronously ads all of the accounts that are specified by the result set returned by
259     * {@link ContentProvider#query()} for the specified uri.  The content provider handling the
260     * query needs to handle the {@link UIProvider.ACCOUNTS_PROJECTION}
261     * Any changes to the underlying provider will automatically be reflected.
262     * @param resolver
263     * @param accountsQueryUri
264     */
265    public static void addAccountsForUriAsync(Uri accountsQueryUri) {
266        getInstance().startAccountsLoader(accountsQueryUri);
267    }
268
269    /**
270     * Returns the intent that should be used in a call to
271     * {@link Context#startActivity(android.content.Intent)} when the account provider doesn't
272     * return any accounts
273     * @return Intent or null, if the provider doesn't specify a behavior when no acccounts are
274     * specified.
275     */
276    public static Intent getNoAccountIntent(Context context) {
277        return getInstance().getNoAccountsIntent(context);
278    }
279
280    private synchronized void startAccountsLoader(Uri accountsQueryUri) {
281        final CursorLoader accountsCursorLoader = new CursorLoader(getContext(), accountsQueryUri,
282                UIProvider.ACCOUNTS_PROJECTION, null, null, null);
283
284        // Listen for the results
285        accountsCursorLoader.registerListener(accountsQueryUri.hashCode(), this);
286        accountsCursorLoader.startLoading();
287
288        // If there is a previous loader for the given uri, stop it
289        final CursorLoader oldLoader = mCursorLoaderMap.get(accountsQueryUri);
290        if (oldLoader != null) {
291            oldLoader.stopLoading();
292        }
293        mCursorLoaderMap.put(accountsQueryUri, accountsCursorLoader);
294    }
295
296    public static void addAccount(Account account, Uri accountsQueryUri) {
297        final MailAppProvider provider = getInstance();
298        if (provider == null) {
299            throw new IllegalStateException("MailAppProvider not intialized");
300        }
301        provider.addAccountImpl(account, accountsQueryUri);
302    }
303
304    private void addAccountImpl(Account account, Uri accountsQueryUri) {
305        synchronized (mAccountCache) {
306            if (account != null) {
307                LogUtils.v(LOG_TAG, "adding account %s", account);
308                mAccountCache.put(account.uri, new AccountCacheEntry(account, accountsQueryUri));
309            }
310        }
311        // Explicitly calling this out of the synchronized block in case any of the observers get
312        // called synchronously.
313        broadcastAccountChange();
314
315        // Cache the updated account list
316        cacheAccountList();
317    }
318
319    public static void removeAccount(Uri accountUri) {
320        final MailAppProvider provider = getInstance();
321        if (provider == null) {
322            throw new IllegalStateException("MailAppProvider not intialized");
323        }
324        provider.removeAccounts(Collections.singleton(accountUri));
325    }
326
327    private void removeAccounts(Set<Uri> uris) {
328        synchronized (mAccountCache) {
329            for (Uri accountUri : uris) {
330                mAccountCache.remove(accountUri);
331            }
332        }
333
334        // Explicitly calling this out of the synchronized block in case any of the observers get
335        // called synchronously.
336        broadcastAccountChange();
337
338        // Cache the updated account list
339        cacheAccountList();
340    }
341
342    private static void broadcastAccountChange() {
343        final MailAppProvider provider = sInstance;
344
345        if (provider != null) {
346            provider.mResolver.notifyChange(getAccountsUri(), null);
347        }
348    }
349
350    /**
351     * Returns the {@link Account#uri} (in String form) of the last viewed account.
352     */
353    public String getLastViewedAccount() {
354        return getPreferences().getString(LAST_VIEWED_ACCOUNT_KEY, null);
355    }
356
357    /**
358     * Persists the {@link Account#uri} (in String form) of the last viewed account.
359     */
360    public void setLastViewedAccount(String accountUriStr) {
361        final SharedPreferences.Editor editor = getPreferences().edit();
362        editor.putString(LAST_VIEWED_ACCOUNT_KEY, accountUriStr);
363        editor.apply();
364    }
365
366    private void loadCachedAccountList() {
367        final SharedPreferences preference = getPreferences();
368
369        final Set<String> accountsStringSet = preference.getStringSet(ACCOUNT_LIST_KEY, null);
370
371        if (accountsStringSet != null) {
372            for (String serializedAccount : accountsStringSet) {
373                try {
374                    final AccountCacheEntry accountEntry =
375                            new AccountCacheEntry(serializedAccount);
376                    addAccount(accountEntry.mAccount, accountEntry.mAccountsQueryUri);
377                } catch (Exception e) {
378                    // Unable to create account object, skip to next
379                    LogUtils.e(LOG_TAG, e,
380                            "Unable to create account object from serialized string '%s'",
381                            serializedAccount);
382                }
383            }
384        }
385    }
386
387    private void cacheAccountList() {
388        final SharedPreferences preference = getPreferences();
389
390        final Set<AccountCacheEntry> accountList;
391        synchronized (mAccountCache) {
392            accountList = ImmutableSet.copyOf(mAccountCache.values());
393        }
394
395        final Set<String> serializedAccounts = Sets.newHashSet();
396        for (AccountCacheEntry accountEntry : accountList) {
397            serializedAccounts.add(accountEntry.serialize());
398        }
399
400        final SharedPreferences.Editor editor = getPreferences().edit();
401        editor.putStringSet(ACCOUNT_LIST_KEY, serializedAccounts);
402        editor.apply();
403    }
404
405    private SharedPreferences getPreferences() {
406        if (mSharedPrefs == null) {
407            mSharedPrefs = getContext().getSharedPreferences(
408                    SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE);
409        }
410        return mSharedPrefs;
411    }
412
413    @Override
414    public void onLoadComplete(Loader<Cursor> loader, Cursor data) {
415        if (data == null) {
416            LogUtils.d(LOG_TAG, "null account cursor returned");
417            return;
418        }
419
420        LogUtils.d(LOG_TAG, "Cursor with %d accounts returned", data.getCount());
421        final CursorLoader cursorLoader = (CursorLoader)loader;
422        final Uri accountsQueryUri = cursorLoader.getUri();
423
424        final Set<AccountCacheEntry> accountList;
425        synchronized (mAccountCache) {
426            accountList = ImmutableSet.copyOf(mAccountCache.values());
427        }
428
429        // Build a set of the account uris that had been associated with that query
430        final Set<Uri> previousQueryUriMap = Sets.newHashSet();
431        for (AccountCacheEntry entry : accountList) {
432            if (accountsQueryUri.equals(entry.mAccountsQueryUri)) {
433                previousQueryUriMap.add(entry.mAccount.uri);
434            }
435        }
436
437        final Set<Uri> newQueryUriMap = Sets.newHashSet();
438        while (data.moveToNext()) {
439            final Account account = new Account(data);
440            final Uri accountUri = account.uri;
441            newQueryUriMap.add(accountUri);
442            addAccount(account, accountsQueryUri);
443        }
444
445        // Update the internal state of this provider if the returned result set
446        // represents all accounts
447        // TODO: determine what should happen with a heterogeneous set of accounts
448        final Bundle extra = data.getExtras();
449        mAccountsFullyLoaded = extra.getInt(AccountCursorExtraKeys.ACCOUNTS_LOADED) != 0;
450
451        if (previousQueryUriMap != null) {
452            // Remove all of the accounts that are in the new result set
453            previousQueryUriMap.removeAll(newQueryUriMap);
454
455            // For all of the entries that had been in the previous result set, and are not
456            // in the new result set, remove them from the cache
457            if (previousQueryUriMap.size() > 0 && mAccountsFullyLoaded) {
458                removeAccounts(previousQueryUriMap);
459            }
460        }
461    }
462
463    /**
464     * Object that allows the Account Cache provider to associate the account with the content
465     * provider uri that originated that account.
466     */
467    private static class AccountCacheEntry {
468        final Account mAccount;
469        final Uri mAccountsQueryUri;
470
471        private static final String ACCOUNT_ENTRY_COMPONENT_SEPARATOR = "^**^";
472        private static final Pattern ACCOUNT_ENTRY_COMPONENT_SEPARATOR_PATTERN =
473                Pattern.compile("\\^\\*\\*\\^");
474
475        private static final int NUMBER_MEMBERS = 2;
476
477        public AccountCacheEntry(Account account, Uri accountQueryUri) {
478            mAccount = account;
479            mAccountsQueryUri = accountQueryUri;
480        }
481
482        /**
483         * Return a serialized String for this AccountCacheEntry.
484         */
485        public synchronized String serialize() {
486            StringBuilder out = new StringBuilder();
487            out.append(mAccount.serialize()).append(ACCOUNT_ENTRY_COMPONENT_SEPARATOR);
488            final String accountQueryUri =
489                    mAccountsQueryUri != null ? mAccountsQueryUri.toString() : "";
490            out.append(accountQueryUri);
491            return out.toString();
492        }
493
494        /**
495         * Create an account cache object from a serialized string previously stored away.
496         * If the serializedString does not parse as a valid account, we throw an
497         * {@link IllegalArgumentException}. The caller is responsible for checking this and
498         * ignoring the newly created object if the exception is thrown.
499         * @param serializedString
500         */
501        public AccountCacheEntry(String serializedString) throws IllegalArgumentException {
502            String[] cacheEntryMembers = TextUtils.split(serializedString,
503                    ACCOUNT_ENTRY_COMPONENT_SEPARATOR_PATTERN);
504            if (cacheEntryMembers.length != NUMBER_MEMBERS) {
505                throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. "
506                        + "Wrong number of members detected. "
507                        + cacheEntryMembers.length + " detected");
508            }
509            mAccount = Account.newinstance(cacheEntryMembers[0]);
510            if (mAccount == null) {
511                throw new IllegalArgumentException("AccountCacheEntry de-serializing failed. "
512                        + "Account object couldn not be created by the serialized string"
513                        + serializedString);
514            }
515            mAccountsQueryUri = !TextUtils.isEmpty(cacheEntryMembers[1]) ?
516                    Uri.parse(cacheEntryMembers[1]) : null;
517        }
518    }
519}
520