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