/* * Copyright (C) 2014 Murray Cumming * * This file is part of android-galaxyzoo * * android-galaxyzoo is free software: you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as published by the * Free Software Foundation, either version 3 of the License, or (at your * option) any later version. * * android-galaxyzoo is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License * for more details. * * You should have received a copy of the GNU Lesser General Public License * along with android-galaxyzoo. If not, see <http://www.gnu.org/licenses/>. */ package com.murrayc.galaxyzoo.app; import android.accounts.Account; import android.accounts.AccountManager; import android.accounts.AccountManagerFuture; import android.accounts.AuthenticatorException; import android.accounts.OperationCanceledException; import android.app.Activity; import android.content.ContentResolver; import android.content.Context; import android.content.SharedPreferences; import android.os.AsyncTask; import android.os.Build; import android.os.Bundle; import android.support.annotation.Nullable; import android.support.v4.app.FragmentActivity; import android.text.TextUtils; import android.util.JsonReader; import com.murrayc.galaxyzoo.app.provider.Item; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.ref.WeakReference; import java.util.Map; /** * Created by murrayc on 10/5/14. */ public final class LoginUtils { // An account type, in the form of a domain name // This must match the android:accountType in authenticator.xml public static final String ACCOUNT_TYPE = "galaxyzoo.com"; //This is an arbitrary string, because Accountmanager.setAuthToken() needs something non-null public static final String ACCOUNT_AUTHTOKEN_TYPE = "authApiKey"; //Because the Account must have a name. //TODO: Stop this string from appearing in the general Settings->Accounts UI, //or at least show a translatable "Anonymous" there instead. private static final String ACCOUNT_NAME_ANONYMOUS = "anonymous"; /** * Returns true if we have a real account that has logged into the server, * or false if we are using the anonymous account. * * Don't call this from the main thread - use an AsyncTask, for instance. * * @param context * @return */ public static boolean getLoggedIn(final Context context) { final LoginDetails loginDetails = getAccountLoginDetails(context); return getLoggedIn(loginDetails); } /** * This is a just a utility method that examines the LoginDetails. * Unlike getLoggedIn(Context), this can be called from any thread. * * @param loginDetails * @return */ public static boolean getLoggedIn(final LoginDetails loginDetails) { if (loginDetails == null) { return false; } return !(TextUtils.isEmpty(loginDetails.authApiKey)); } public static LoginResult parseLoginResponseContent(final InputStream content) throws IOException { //A failure by default. LoginResult result = new LoginResult(false, null, null); final InputStreamReader streamReader = new InputStreamReader(content, Utils.STRING_ENCODING); final JsonReader reader = new JsonReader(streamReader); reader.beginObject(); boolean success = false; String apiKey = null; String userName = null; String message = null; while (reader.hasNext()) { final String name = reader.nextName(); switch (name) { case "success": success = reader.nextBoolean(); break; case "api_key": apiKey = reader.nextString(); break; case "name": userName = reader.nextString(); break; case "message": message = reader.nextString(); break; default: reader.skipValue(); } } if (success) { result = new LoginResult(true, userName, apiKey); } else { Log.info("Login failed."); Log.info("Login failure message: " + message); } reader.endObject(); reader.close(); streamReader.close(); return result; } /** * This returns null if there is no account (not even an anonymous account). * Don't call this from the main thread - use an AsyncTask, for instance. * * @param context * @return */ @Nullable public static LoginDetails getAccountLoginDetails(final Context context) { final AccountManager mgr = AccountManager.get(context); if (mgr == null) { Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed because AccountManager.get() returned null."); return null; } final Account account = getAccount(mgr); if (account == null) { Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed because getAccount() returned null. "); return null; } //Make sure that this has not been unset somehow: setAutomaticAccountSync(context, account); final LoginDetails result = new LoginDetails(); //Avoid showing our anonymous account name in the UI. //Also, an anonymous account never has an auth_api_key. result.isAnonymous = TextUtils.equals(account.name, ACCOUNT_NAME_ANONYMOUS); if (result.isAnonymous) { return result; //Return a mostly-empty empty (but not null) LoginDetails. } result.name = account.name; //Note that this requires the USE_CREDENTIALS permission on //SDK <=22. final AccountManagerFuture<Bundle> response = mgr.getAuthToken(account, ACCOUNT_AUTHTOKEN_TYPE, null, null, null, null); try { final Bundle bundle = response.getResult(); if (bundle == null) { //TODO: Let the caller catch this? Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed because getAuthToken() returned a null response result bundle."); return null; } result.authApiKey = bundle.getString(AccountManager.KEY_AUTHTOKEN); return result; } catch (final OperationCanceledException e) { //TODO: Let the caller catch this? Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed", e); return null; } catch (final AuthenticatorException e) { //TODO: Let the caller catch this? Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed", e); return null; } catch (final IOException e) { //TODO: Let the caller catch this? Log.error("getAccountLoginDetails(): getAccountLoginDetails() failed", e); return null; } } public static void logOut(final ZooFragment fragment) { final Activity activity = fragment.getActivity(); final AccountRemoveTask task = new AccountRemoveTask(activity) { @Override protected void onPostExecute(final Void result) { super.onPostExecute(result); //Make sure that the currently-shown menu will update: ZooFragment.setCachedLoggedIn(false); //TODO: This doesn't actually seem to cause the (various) child fragments' //onPrepareOptionsMenu() methods to be called. Maybe it doesn't work with //nested child fragments. if (activity instanceof FragmentActivity) { final FragmentActivity fragmentActivity = (FragmentActivity) activity; fragmentActivity.supportInvalidateOptionsMenu(); } else { activity.invalidateOptionsMenu(); } } }; task.execute(); } /** * Add the anonymous Account. * * Don't call this from the main thread - use an AsyncTask, for instance. * @param context */ private static void addAnonymousAccount(final Context context) { final AccountManager accountManager = AccountManager.get(context); final Account account = new Account(ACCOUNT_NAME_ANONYMOUS, LoginUtils.ACCOUNT_TYPE); //Note that this requires the AUTHENTICATE_ACCOUNTS permission on //SDK <=22: accountManager.addAccountExplicitly(account, null, null); //In case it has not been called yet. //This has no effect the second time. Utils.initDefaultPrefs(context); //Give the new account the existing (probably default) preferences, //so the SyncAdapter can use them. //See SettingsFragment.onSharedPreferenceChanged(). copyPrefsToAccount(context, accountManager, account); //Tell the SyncAdapter to sync whenever the network is reconnected: setAutomaticAccountSync(context, account); } static void setAutomaticAccountSync(final Context context, final Account account) { final ContentResolver resolver = context.getContentResolver(); if (resolver == null) { return; } ContentResolver.setSyncAutomatically(account, Item.AUTHORITY, true); } /** Don't call this from the main UI thread. * * @param context */ public static void removeAnonymousAccount(final Context context) { removeAccount(context, ACCOUNT_NAME_ANONYMOUS); } /** Don't call this from the main UI thread. * * @param context */ public static void removeAccount(final Context context, final String accountName) { final AccountManager accountManager = AccountManager.get(context); final Account account = new Account(accountName, LoginUtils.ACCOUNT_TYPE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { //Trying to call this on an older Android version results in a //NoSuchMethodError exception. //There is no AppCompat version of the AccountManager API to //avoid the need for this version check at runtime. accountManager.removeAccount(account, null, null, null); } else { //noinspection deprecation //Note that this needs the MANAGE_ACCOUNT permission on //SDK <=22. //noinspection deprecation accountManager.removeAccount(account, null, null); } } /** * Get a preference from the Account. * * Don't call this from the main thread - use an AsyncTask, for instance. * * @param context * @param prefKeyResId * @return */ private static boolean getBooleanPref(final Context context, final int prefKeyResId) { final String value = getStringPref(context, prefKeyResId); if (value == null) { return false; } return Boolean.parseBoolean(value); } /** * Get a preference from the Account. * * Don't call this from the main thread - use an AsyncTask, for instance. * * @param context * @param prefKeyResId * @return */ public static int getIntPref(final Context context, final int prefKeyResId) { final String value = getStringPref(context, prefKeyResId); if (value == null) { return 0; } try { return Integer.parseInt(value); } catch (final NumberFormatException e) { //NumberFormatException is an unchecked exception but //it would not be a programmer error to try to parse //an input string (the stored preference in this case) //as an Integer, as long as there's no way for us //to check its validity before calling Integer.parseInt(). //Therefore we catch it. return 0; } } /** * Get a preference from the Account. * * Don't call this from the main thread - use an AsyncTask, for instance. * * @param context * @param prefKeyResId * @return */ @Nullable private static String getStringPref(final Context context, final int prefKeyResId) { final AccountManager mgr = AccountManager.get(context); final Account account = getAccount(context); if (account == null) { return null; } //Note that this requires the AUTHENTICATE_ACCOUNTS permission on //SDK <=22. return mgr.getUserData(account, context.getString(prefKeyResId)); } /** * Get the Account. * * Don't call this from the main thread - use an AsyncTask, for instance. * * @param mgr * @return */ @Nullable private static Account getAccount(final AccountManager mgr) { //Note this needs the GET_ACCOUNTS permission on //SDK <=22 // Ignore android-lint warnings about this: https://code.google.com/p/android/issues/detail?id=223244 final Account[] accts = mgr.getAccountsByType(ACCOUNT_TYPE); if((accts == null) || (accts.length < 1)) { //Log.error("getAccountLoginDetails(): getAccountsByType() returned no account."); return null; } return accts[0]; } /** * Get the Account. * * Don't call this from the main thread - use an AsyncTask, for instance. * * @param context * @return */ private static Account getAccount(final Context context) { final AccountManager mgr = AccountManager.get(context); return getAccount(mgr); } static void copyPrefToAccount(final Context context, final String key, final String value) { //Copy the preference to the Account: final AccountManager mgr = AccountManager.get(context); final Account account = getAccount(context); if (account == null) { return; } copyPrefToAccount(mgr, account, key, value); } private static void copyPrefToAccount(final AccountManager mgr, final Account account, final String key, final String value) { //Note that this requires the AUTHENTICATE_ACCOUNTS permission on //SDK <=22. mgr.setUserData(account, key, value); } static void copyPrefsToAccount(final Context context, final AccountManager accountManager, final Account account) { //Copy the preferences into the account. //See also SettingsFragment.onSharedPreferenceChanged() final SharedPreferences prefs = Utils.getPreferences(context); final Map<String, ?> keys = prefs.getAll(); for(final Map.Entry<String, ?> entry : keys.entrySet()) { final Object value = entry.getValue(); if (value instanceof String) { copyPrefToAccount(accountManager, account, entry.getKey(), (String) value); } else if (value instanceof Integer) { copyPrefToAccount(accountManager, account, entry.getKey(), Integer.toString((Integer) value)); } else if (value instanceof Boolean) { copyPrefToAccount(accountManager, account, entry.getKey(), Boolean.toString((Boolean) value)); } } } /** * Get the "use-wifi only" setting from the account. * * Don't call this from the main thread - use an AsyncTask, for instance. * Or use Utils.getUseWifiOnlyFromSharedPrefs(). * * @param context * @return */ public static boolean getUseWifiOnly(final Context context) { return getBooleanPref(context, R.string.pref_key_wifi_only); } public static class LoginDetails { public String name = null; public String authApiKey = null; public boolean isAnonymous = false; } /** * Represents an asynchronous login/registration task used to authenticate * the user. */ public static class GetExistingLogin extends AsyncTask<Void, Void, LoginDetails> { private final WeakReference<Context> mContextReference; Exception mException = null; GetExistingLogin(final Context context) { mContextReference = new WeakReference<>(context); } @Override protected LoginDetails doInBackground(final Void... params) { if (mContextReference == null) { return null; } final Context context = mContextReference.get(); if (context == null) { return null; } if (isCancelled()) { return null; } LoginDetails result = null; try { result = getAccountLoginDetails(context); if (result == null) { //Add an anonymous Account, //because our SyncAdapter will not run if there is no associated Account, //and we want it to run to get the items to classify, and to upload //anonymous classifications. LoginUtils.addAnonymousAccount(context); return getAccountLoginDetails(context); } } catch (final SecurityException ex) { mException = ex; } return result; } @Override protected void onCancelled() { } } /** Run this to log out. */ private static class AccountRemoveTask extends AsyncTask<Void, Void, Void> { private final WeakReference<Context> contextReference; AccountRemoveTask(final Context context) { this.contextReference = new WeakReference<>(context); } @Override protected Void doInBackground(final Void... params) { if (contextReference == null) { return null; } final Context context = contextReference.get(); if (context == null) { return null; } if (isCancelled()) { return null; } final LoginUtils.LoginDetails loginDetails = LoginUtils.getAccountLoginDetails(context); if(!LoginUtils.getLoggedIn(loginDetails)) { return null; } final String accountName = loginDetails.name; if (TextUtils.isEmpty(accountName)) { return null; } if (isCancelled()) { return null; } LoginUtils.removeAccount(context, accountName); LoginUtils.addAnonymousAccount(context); return null; } } public static class LoginResult { private final boolean success; private final String name; private final String apiKey; public LoginResult(final boolean success, final String name, final String apiKey) { this.success = success; this.name = name; this.apiKey = apiKey; } public String getApiKey() { return apiKey; } public boolean getSuccess() { return success; } public String getName() { return name; } } }