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