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