1/*
2 * Copyright (C) 2011 The Android Open Source Project
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.contacts.editor;
18
19import android.accounts.Account;
20import android.accounts.AccountManager;
21import android.app.Activity;
22import android.content.Context;
23import android.content.Intent;
24import android.content.SharedPreferences;
25import android.preference.PreferenceManager;
26import android.text.TextUtils;
27import android.util.Log;
28
29import com.android.contacts.common.testing.NeededForTesting;
30import com.android.contacts.common.model.AccountTypeManager;
31import com.android.contacts.common.model.account.AccountType;
32import com.android.contacts.common.model.account.AccountWithDataSet;
33import com.google.common.annotations.VisibleForTesting;
34import com.google.common.collect.ImmutableList;
35import com.google.common.collect.Sets;
36
37import java.util.ArrayList;
38import java.util.List;
39import java.util.Set;
40
41/**
42 * Utility methods for the "account changed" notification in the new contact creation flow.
43 */
44public class ContactEditorUtils {
45    private static final String TAG = "ContactEditorUtils";
46
47    private static final String KEY_DEFAULT_ACCOUNT = "ContactEditorUtils_default_account";
48    private static final String KEY_KNOWN_ACCOUNTS = "ContactEditorUtils_known_accounts";
49    // Key to tell the first time launch.
50    private static final String KEY_ANYTHING_SAVED = "ContactEditorUtils_anything_saved";
51
52    private static final List<AccountWithDataSet> EMPTY_ACCOUNTS = ImmutableList.of();
53
54    private static ContactEditorUtils sInstance;
55
56    private final Context mContext;
57    private final SharedPreferences mPrefs;
58    private final AccountTypeManager mAccountTypes;
59
60    private ContactEditorUtils(Context context) {
61        this(context, AccountTypeManager.getInstance(context));
62    }
63
64    @VisibleForTesting
65    ContactEditorUtils(Context context, AccountTypeManager accountTypes) {
66        mContext = context.getApplicationContext();
67        mPrefs = PreferenceManager.getDefaultSharedPreferences(mContext);
68        mAccountTypes = accountTypes;
69    }
70
71    public static synchronized ContactEditorUtils getInstance(Context context) {
72        if (sInstance == null) {
73            sInstance = new ContactEditorUtils(context.getApplicationContext());
74        }
75        return sInstance;
76    }
77
78    @NeededForTesting
79    void cleanupForTest() {
80        mPrefs.edit().remove(KEY_DEFAULT_ACCOUNT).remove(KEY_KNOWN_ACCOUNTS)
81                .remove(KEY_ANYTHING_SAVED).apply();
82    }
83
84    @NeededForTesting
85    void removeDefaultAccountForTest() {
86        mPrefs.edit().remove(KEY_DEFAULT_ACCOUNT).apply();
87    }
88
89    /**
90     * Sets the {@link #KEY_KNOWN_ACCOUNTS} and {@link #KEY_DEFAULT_ACCOUNT} preference values to
91     * empty strings to reset the state of the preferences file.
92     */
93    private void resetPreferenceValues() {
94        mPrefs.edit().putString(KEY_KNOWN_ACCOUNTS, "").putString(KEY_DEFAULT_ACCOUNT, "").apply();
95    }
96
97    private List<AccountWithDataSet> getWritableAccounts() {
98        return mAccountTypes.getAccounts(true);
99    }
100
101    /**
102     * @return true if it's the first launch and {@link #saveDefaultAndAllAccounts} has never
103     *     been called.
104     */
105    private boolean isFirstLaunch() {
106        return !mPrefs.getBoolean(KEY_ANYTHING_SAVED, false);
107    }
108
109    /**
110     * Saves all writable accounts and the default account, which can later be obtained
111     * with {@link #getDefaultAccount}.
112     *
113     * This should be called when saving a newly created contact.
114     *
115     * @param defaultAccount the account used to save a newly created contact.  Or pass {@code null}
116     *     If the user selected "local only".
117     */
118    public void saveDefaultAndAllAccounts(AccountWithDataSet defaultAccount) {
119        final SharedPreferences.Editor editor = mPrefs.edit()
120                .putBoolean(KEY_ANYTHING_SAVED, true);
121
122        if (defaultAccount == null || defaultAccount.isLocalAccount()) {
123            // If the default is "local only", there should be no writable accounts.
124            // This should always be the case with our spec, but because we load the account list
125            // asynchronously using a worker thread, it is possible that there are accounts at this
126            // point. So if the default is null always clear the account list.
127            editor.putString(KEY_KNOWN_ACCOUNTS, "");
128            editor.putString(KEY_DEFAULT_ACCOUNT, "");
129        } else {
130            editor.putString(KEY_KNOWN_ACCOUNTS,
131                    AccountWithDataSet.stringifyList(getWritableAccounts()));
132            editor.putString(KEY_DEFAULT_ACCOUNT, defaultAccount.stringify());
133        }
134        editor.apply();
135    }
136
137    /**
138     * @return the default account saved with {@link #saveDefaultAndAllAccounts}.
139     *
140     * Note the {@code null} return value can mean either {@link #saveDefaultAndAllAccounts} has
141     * never been called, or {@code null} was passed to {@link #saveDefaultAndAllAccounts} --
142     * i.e. the user selected "local only".
143     *
144     * Also note that the returned account may have been removed already.
145     */
146    public AccountWithDataSet getDefaultAccount() {
147        final String saved = mPrefs.getString(KEY_DEFAULT_ACCOUNT, null);
148        if (TextUtils.isEmpty(saved)) {
149            return null;
150        }
151        try {
152            return AccountWithDataSet.unstringify(saved);
153        } catch (IllegalArgumentException exception) {
154            Log.e(TAG, "Error with retrieving default account " + exception.toString());
155            // unstringify()can throw an exception if the string is not in an expected format.
156            // Hence, if the preferences file is corrupt, just reset the preference values
157            resetPreferenceValues();
158            return null;
159        }
160    }
161
162    /**
163     * @return true if an account still exists.  {@code null} is considered "local only" here,
164     *    so it's valid too.
165     */
166    @VisibleForTesting
167    boolean isValidAccount(AccountWithDataSet account) {
168        if (account == null || account.isLocalAccount()) {
169            return true; // It's "local only" account, which is valid.
170        }
171        return getWritableAccounts().contains(account);
172    }
173
174    /**
175     * @return saved known accounts, or an empty list if none has been saved yet.
176     */
177    @VisibleForTesting
178    List<AccountWithDataSet> getSavedAccounts() {
179        final String saved = mPrefs.getString(KEY_KNOWN_ACCOUNTS, null);
180        if (TextUtils.isEmpty(saved)) {
181            return EMPTY_ACCOUNTS;
182        }
183        try {
184            return AccountWithDataSet.unstringifyList(saved);
185        } catch (IllegalArgumentException exception) {
186            Log.e(TAG, "Error with retrieving saved accounts " + exception.toString());
187            // unstringifyList()can throw an exception if the string is not in an expected format.
188            // Hence, if the preferences file is corrupt, just reset the preference values
189            resetPreferenceValues();
190            return EMPTY_ACCOUNTS;
191        }
192    }
193
194    /**
195     * @return true if the contact editor should show the "accounts changed" notification, that is:
196     * - If it's the first launch.
197     * - Or, if an account has been added.
198     * - Or, if the default account has been removed.
199     * (And some extra sanity check)
200     *
201     * Note if this method returns {@code false}, the caller can safely assume that
202     * {@link #getDefaultAccount} will return a valid account.  (Either an account which still
203     * exists, or {@code null} which should be interpreted as "local only".)
204     */
205    public boolean shouldShowAccountChangedNotification() {
206        if (isFirstLaunch()) {
207            return true;
208        }
209
210        // Account added?
211        final List<AccountWithDataSet> savedAccounts = getSavedAccounts();
212        final List<AccountWithDataSet> currentWritableAccounts = getWritableAccounts();
213        for (AccountWithDataSet account : currentWritableAccounts) {
214            if (!savedAccounts.contains(account)) {
215                return true; // New account found.
216            }
217        }
218
219        final AccountWithDataSet defaultAccount = getDefaultAccount();
220
221        // Does default account still exist?
222        if (!isValidAccount(defaultAccount)) {
223            return true;
224        }
225
226        // If there is an inconsistent state in the preferences file - default account is null
227        // ("local" account) while there are multiple accounts, then show the notification dialog.
228        // This shouldn't ever happen, but this should allow the user can get back into a normal
229        // state after they respond to the notification.
230        if ((defaultAccount == null || defaultAccount.isLocalAccount())
231                && currentWritableAccounts.size() > 0) {
232            Log.e(TAG, "Preferences file in an inconsistent state, request that the default account"
233                    + " and current writable accounts be saved again");
234            return true;
235        }
236
237        // All good.
238        return false;
239    }
240
241    @VisibleForTesting
242    String[] getWritableAccountTypeStrings() {
243        final Set<String> types = Sets.newHashSet();
244        for (AccountType type : mAccountTypes.getAccountTypes(true)) {
245            types.add(type.accountType);
246        }
247        return types.toArray(new String[types.size()]);
248    }
249
250    /**
251     * Create an {@link Intent} to start "add new account" setup wizard.  Selectable account
252     * types will be limited to ones that supports editing contacts.
253     *
254     * Use {@link Activity#startActivityForResult} or
255     * {@link android.app.Fragment#startActivityForResult} to start the wizard, and
256     * {@link Activity#onActivityResult} or {@link android.app.Fragment#onActivityResult} to
257     * get the result.
258     */
259    public Intent createAddWritableAccountIntent() {
260        return AccountManager.newChooseAccountIntent(
261                null, // selectedAccount
262                new ArrayList<Account>(), // allowableAccounts
263                getWritableAccountTypeStrings(), // allowableAccountTypes
264                false, // alwaysPromptForAccount
265                null, // descriptionOverrideText
266                null, // addAccountAuthTokenType
267                null, // addAccountRequiredFeatures
268                null // addAccountOptions
269                );
270    }
271
272    /**
273     * Parses a result from {@link #createAddWritableAccountIntent} and returns the created
274     * {@link Account}, or null if the user has canceled the wizard.  Pass the {@code resultCode}
275     * and {@code data} parameters passed to {@link Activity#onActivityResult} or
276     * {@link android.app.Fragment#onActivityResult}.
277     *
278     * Note although the return type is {@link AccountWithDataSet}, return values from this method
279     * will never have {@link AccountWithDataSet#dataSet} set, as there's no way to create an
280     * extension package account from setup wizard.
281     */
282    public AccountWithDataSet getCreatedAccount(int resultCode, Intent resultData) {
283        // Javadoc doesn't say anything about resultCode but that the data intent will be non null
284        // on success.
285        if (resultData == null) return null;
286
287        final String accountType = resultData.getStringExtra(AccountManager.KEY_ACCOUNT_TYPE);
288        final String accountName = resultData.getStringExtra(AccountManager.KEY_ACCOUNT_NAME);
289
290        // Just in case
291        if (TextUtils.isEmpty(accountType) || TextUtils.isEmpty(accountName)) return null;
292
293        return new AccountWithDataSet(accountName, accountType, null);
294    }
295}
296