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