| // Copyright 2017 The Cobalt Authors. All Rights Reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package dev.cobalt.account; |
| |
| import static android.Manifest.permission.GET_ACCOUNTS; |
| import static dev.cobalt.util.Log.TAG; |
| |
| import android.accounts.Account; |
| import android.accounts.AccountManager; |
| import android.accounts.OnAccountsUpdateListener; |
| import android.app.Activity; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.SharedPreferences; |
| import android.content.pm.PackageManager; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.text.TextUtils; |
| import android.widget.Toast; |
| import androidx.core.app.ActivityCompat; |
| import androidx.core.content.ContextCompat; |
| import com.google.android.gms.auth.GoogleAuthException; |
| import com.google.android.gms.auth.GoogleAuthUtil; |
| import com.google.android.gms.auth.UserRecoverableAuthException; |
| import com.google.android.gms.common.AccountPicker; |
| import com.google.android.gms.common.AccountPicker.AccountChooserOptions; |
| import dev.cobalt.coat.R; |
| import dev.cobalt.util.Holder; |
| import dev.cobalt.util.Log; |
| import dev.cobalt.util.UsedByNative; |
| import java.io.IOException; |
| import java.util.Arrays; |
| |
| /** |
| * Java side implementation for starboard::android::shared::cobalt::AndroidUserAuthorizer. |
| * |
| * This implements the following business logic: |
| * First run... |
| * - if there are no accounts, just be be signed-out |
| * - if there is one account, sign-in without any UI |
| * - if there are more than one accounts, prompt to choose account |
| * Subsequent runs... |
| * - sign-in to the same account last used to sign-in |
| * - if previously signed-out stay signed-out |
| * When user clicks 'sign-in' in the UI... |
| * - if there are no accounts, allow user to add an account |
| * - if there is one account, sign-in without any UI |
| * - if there are more than one accounts, prompt to choose account |
| * If the last signed-in account is deleted... |
| * - kill the app if stopped in the background to prompt next time it starts |
| * - at the next app start, show a toast that the account isn't available |
| * - if there are no accounts left, just be be signed-out |
| * - if there are one or more accounts left, prompt to choose account |
| */ |
| public class UserAuthorizerImpl implements OnAccountsUpdateListener, UserAuthorizer { |
| |
| /** Pseudo account indicating the user chose to be signed-out. */ |
| public static final Account SIGNED_OUT_ACCOUNT = new Account("-", "-"); |
| |
| /** Pseudo account indicating a saved account no longer exists. */ |
| private static final Account MISSING_ACCOUNT = new Account("!", "!"); |
| |
| /** Foreshortened expiry of Google OAuth token, which typically lasts 1 hour. */ |
| private static final long DEFAULT_EXPIRY_SECONDS = 5 * 60; |
| |
| private static final String GOOGLE_ACCOUNT_TYPE = "com.google"; |
| private static final String[] OAUTH_SCOPES = { |
| "https://www.googleapis.com/auth/youtube" |
| }; |
| |
| private static final String SHARED_PREFS_NAME = "user_auth"; |
| private static final String ACCOUNT_NAME_PREF_KEY = "signed_in_account"; |
| |
| /** The thread on which the current request is running, or null if none. */ |
| private volatile Thread requestThread; |
| |
| private final Context appContext; |
| private final Holder<Activity> activityHolder; |
| private final Runnable stopRequester; |
| private final Handler mainHandler; |
| |
| private Account currentAccount = null; |
| private AccessToken currentToken = null; |
| |
| // Result from the account picker UI lands here. |
| private String chosenAccountName; |
| |
| private volatile boolean waitingForPermission; |
| private volatile boolean permissionGranted; |
| |
| public UserAuthorizerImpl( |
| Context appContext, Holder<Activity> activityHolder, Runnable stopRequester) { |
| this.appContext = appContext; |
| this.activityHolder = activityHolder; |
| this.stopRequester = stopRequester; |
| this.mainHandler = new Handler(Looper.getMainLooper()); |
| addOnAccountsUpdatedListener(this); |
| } |
| |
| @Override |
| public void shutdown() { |
| removeOnAccountsUpdatedListener(this); |
| } |
| |
| @Override |
| @SuppressWarnings("unused") |
| @UsedByNative |
| public void interrupt() { |
| Thread t = requestThread; |
| if (t != null) { |
| t.interrupt(); |
| } |
| } |
| |
| @Override |
| @SuppressWarnings("unused") |
| @UsedByNative |
| public AccessToken authorizeUser() { |
| ensureBackgroundThread(); |
| requestThread = Thread.currentThread(); |
| // Let the user choose an account, or add one if there are none to choose. |
| // However, if there's only one account just choose it without any prompt. |
| currentAccount = autoSelectOrAddAccount(); |
| writeAccountPref(currentAccount); |
| AccessToken accessToken = refreshCurrentToken(); |
| requestThread = null; |
| return accessToken; |
| } |
| |
| @Override |
| @SuppressWarnings("unused") |
| @UsedByNative |
| public boolean deauthorizeUser() { |
| ensureBackgroundThread(); |
| requestThread = Thread.currentThread(); |
| currentAccount = SIGNED_OUT_ACCOUNT; |
| writeAccountPref(currentAccount); |
| clearCurrentToken(); |
| requestThread = null; |
| return true; |
| } |
| |
| @Override |
| @SuppressWarnings("unused") |
| @UsedByNative |
| public AccessToken refreshAuthorization() { |
| ensureBackgroundThread(); |
| requestThread = Thread.currentThread(); |
| |
| // If we haven't yet determined which account to use, check preferences for a saved account. |
| if (currentAccount == null) { |
| Account savedAccount = readAccountPref(); |
| if (savedAccount == null) { |
| // No saved account, so this is the first ever run of the app. |
| currentAccount = autoSelectAccount(); |
| } else if (savedAccount.equals(MISSING_ACCOUNT)) { |
| // The saved account got deleted. |
| currentAccount = forceSelectAccount(); |
| } else { |
| // Use the saved account. |
| currentAccount = savedAccount; |
| } |
| writeAccountPref(currentAccount); |
| } |
| |
| AccessToken accessToken = refreshCurrentToken(); |
| requestThread = null; |
| return accessToken; |
| } |
| |
| private static void ensureBackgroundThread() { |
| if (Looper.myLooper() == Looper.getMainLooper()) { |
| throw new UnsupportedOperationException("UserAuthorizer can't be called on main thread"); |
| } |
| } |
| |
| private void showToast(int resId, Object... formatArgs) { |
| final String msg = appContext.getResources().getString(resId, formatArgs); |
| mainHandler.post(new Runnable() { |
| @Override |
| public void run() { |
| Toast.makeText(appContext, msg, Toast.LENGTH_LONG).show(); |
| } |
| }); |
| } |
| |
| private Account readAccountPref() { |
| String savedAccountName = loadSignedInAccountName(); |
| if (TextUtils.isEmpty(savedAccountName)) { |
| return null; |
| } else if (savedAccountName.equals(SIGNED_OUT_ACCOUNT.name)) { |
| // Don't request permissions or look for a device account if we were signed-out. |
| return SIGNED_OUT_ACCOUNT; |
| } else if (!checkPermission()) { |
| // We won't be able to get the account without permission, so warn the user and be signed-out. |
| showToast(R.string.starboard_missing_account, savedAccountName); |
| return SIGNED_OUT_ACCOUNT; |
| } else { |
| // Find the saved account name among all accounts on the device. |
| for (Account account : getAccounts()) { |
| if (account.name.equals(savedAccountName)) { |
| return account; |
| } |
| } |
| showToast(R.string.starboard_missing_account, savedAccountName); |
| return MISSING_ACCOUNT; |
| } |
| } |
| |
| private void writeAccountPref(Account account) { |
| if (account == null) { |
| return; |
| } |
| // Always write the account name, even if it's the signed-out pseudo account. |
| saveSignedInAccountName(account.name); |
| } |
| |
| private void clearCurrentToken() { |
| if (currentToken != null) { |
| clearToken(currentToken.getTokenValue()); |
| currentToken = null; |
| } |
| } |
| |
| private AccessToken refreshCurrentToken() { |
| clearCurrentToken(); |
| if (currentAccount == null || SIGNED_OUT_ACCOUNT.equals(currentAccount)) { |
| return null; |
| } |
| String tokenValue = getToken(currentAccount); |
| if (tokenValue == null) { |
| showToast(R.string.starboard_account_auth_error); |
| tokenValue = ""; |
| } |
| // TODO: Get the token details and use the actual expiry. |
| long expiry = System.currentTimeMillis() / 1000 + DEFAULT_EXPIRY_SECONDS; |
| currentToken = new AccessToken(tokenValue, expiry); |
| return currentToken; |
| } |
| |
| /** |
| * Prompts the user to select an account, or to add an account if there are none. The prompt is |
| * skipped if there is exactly one account to choose from. |
| */ |
| private Account autoSelectOrAddAccount() { |
| if (!checkPermission()) { |
| return SIGNED_OUT_ACCOUNT; |
| } |
| Account[] accounts = getAccounts(); |
| if (accounts.length == 1) { |
| return accounts[0]; |
| } |
| return selectOrAddAccount(); |
| } |
| |
| /** |
| * Prompts the user to select an account. The prompt is skipped if there are zero or one accounts |
| * to choose from. |
| */ |
| private Account autoSelectAccount() { |
| if (!checkPermission()) { |
| return SIGNED_OUT_ACCOUNT; |
| } |
| Account[] accounts = getAccounts(); |
| if (accounts.length == 0) { |
| return SIGNED_OUT_ACCOUNT; |
| } else if (accounts.length == 1) { |
| return accounts[0]; |
| } |
| return selectOrAddAccount(); |
| } |
| |
| /** |
| * Prompts the user to select an account, even if there's only one to choose from. The prompt is |
| * skipped if there are zero accounts to choose from. |
| */ |
| private Account forceSelectAccount() { |
| // We don't check permissions before calling selectOrAddAccount() because if the account is |
| // missing, readAccountPref() must have just checked, and we don't want to show permission |
| // prompt or rationale to the user twice. |
| Account[] accounts = getAccounts(); |
| if (accounts.length == 0) { |
| return SIGNED_OUT_ACCOUNT; |
| } |
| return selectOrAddAccount(); |
| } |
| |
| /** |
| * Prompts the user to select an account, even if there's only one to choose from. If there are |
| * zero accounts to choose from, the user is prompted to add one. |
| * |
| * The caller should ensure permissions are granted before calling this method to avoid showing |
| * a picker with accounts that we can't access. |
| */ |
| private Account selectOrAddAccount() { |
| String accountName = showAccountPicker(); |
| // If user cancelled the picker stay signed-out. |
| if (TextUtils.isEmpty(accountName)) { |
| return SIGNED_OUT_ACCOUNT; |
| } |
| // Get the accounts after the picker in case one was added in the account picker. |
| for (Account account : getAccounts()) { |
| if (account.name.equals(accountName)) { |
| return account; |
| } |
| } |
| // This shouldn't happen, but if it does let the user know we're still signed-out. |
| Log.e(TAG, "Selected account is missing"); |
| showToast(R.string.starboard_missing_account, accountName); |
| return SIGNED_OUT_ACCOUNT; |
| } |
| |
| private synchronized String showAccountPicker() { |
| Activity activity = activityHolder.get(); |
| Intent chooseAccountIntent = newChooseAccountIntent(currentAccount); |
| if (activity == null || chooseAccountIntent == null) { |
| return ""; |
| } |
| chosenAccountName = null; |
| activity.startActivityForResult(chooseAccountIntent, R.id.rc_choose_account); |
| |
| // Block until the account picker activity returns its result. |
| while (chosenAccountName == null) { |
| try { |
| wait(); |
| } catch (InterruptedException e) { |
| Log.e(TAG, "Account picker interrupted"); |
| // Return empty string, as if the picker was cancelled. |
| return ""; |
| } |
| } |
| return chosenAccountName; |
| } |
| |
| @Override |
| public void onActivityResult(int requestCode, int resultCode, Intent data) { |
| if (requestCode == R.id.rc_choose_account) { |
| String accountName = null; |
| if (resultCode == Activity.RESULT_OK) { |
| accountName = data.getStringExtra(AccountManager.KEY_ACCOUNT_NAME); |
| } else if (resultCode != Activity.RESULT_CANCELED) { |
| Log.e(TAG, "Account picker error " + resultCode); |
| showToast(R.string.starboard_account_picker_error); |
| } |
| |
| // Notify showAccountPicker() which account was chosen. |
| synchronized (this) { |
| // Return empty string if the picker is cancelled or there's an unexpected result. |
| chosenAccountName = (accountName == null) ? "" : accountName; |
| notifyAll(); |
| } |
| } |
| } |
| |
| @Override |
| public void onAccountsUpdated(Account[] unused) { |
| if (currentAccount == null || SIGNED_OUT_ACCOUNT.equals(currentAccount)) { |
| // We're not signed-in; the update doesn't affect us. |
| return; |
| } |
| // Call getAccounts() since the param may not match the accounts we can access. |
| for (Account account : getAccounts()) { |
| if (account.name.equals(currentAccount.name)) { |
| // The current account is still there; the update doesn't affect us. |
| return; |
| } |
| } |
| // The current account is gone; leave the app so we prompt for sign-in next time. |
| // This should only happen while stopped in the background since we don't delete accounts. |
| stopRequester.run(); |
| } |
| |
| /** |
| * Calls framework AccountManager.addOnAccountsUpdatedListener(). |
| */ |
| private void addOnAccountsUpdatedListener(OnAccountsUpdateListener listener) { |
| AccountManager.get(appContext).addOnAccountsUpdatedListener(listener, null, false); |
| } |
| |
| /** |
| * Calls framework AccountManager.removeOnAccountsUpdatedListener(). |
| */ |
| private void removeOnAccountsUpdatedListener(OnAccountsUpdateListener listener) { |
| AccountManager.get(appContext).removeOnAccountsUpdatedListener(listener); |
| } |
| |
| /** |
| * Calls framework AccountManager.getAccountsByType() for Google accounts. |
| */ |
| private Account[] getAccounts() { |
| return AccountManager.get(appContext).getAccountsByType(GOOGLE_ACCOUNT_TYPE); |
| } |
| |
| /** |
| * Calls GMS AccountPicker.newChooseAccountIntent(). |
| * |
| * Returns an Intent that when started will always show the account picker even if there's just |
| * one account on the device. If there are no accounts on the device it shows the UI to add one. |
| */ |
| private Intent newChooseAccountIntent(Account defaultAccount) { |
| AccountChooserOptions chooserOptions = new AccountChooserOptions.Builder() |
| .setSelectedAccount(defaultAccount) |
| .setAllowableAccountsTypes(Arrays.asList(GOOGLE_ACCOUNT_TYPE)) |
| .setAlwaysShowAccountPicker(true) |
| .build(); |
| return AccountPicker.newChooseAccountIntent(chooserOptions); |
| } |
| |
| /** |
| * Calls GMS GoogleAuthUtil.getToken(), without throwing any exceptions. |
| * |
| * Returns an empty string if no token is available for the account. |
| * Returns null if there was an error getting the token. |
| */ |
| private String getToken(Account account) { |
| String joinedScopes = "oauth2:" + TextUtils.join(" ", OAUTH_SCOPES); |
| try { |
| return GoogleAuthUtil.getToken(appContext, account, joinedScopes); |
| } catch (UserRecoverableAuthException e) { |
| Log.w(TAG, "Recoverable error getting OAuth token", e); |
| Intent intent = e.getIntent(); |
| Activity activity = activityHolder.get(); |
| if (intent != null && activity != null) { |
| activity.startActivity(intent); |
| } else { |
| Log.e(TAG, "Failed to recover OAuth token", e); |
| } |
| return null; |
| |
| } catch (IOException | GoogleAuthException e) { |
| Log.e(TAG, "Error getting auth token", e); |
| return null; |
| } |
| } |
| |
| /** |
| * Calls GMS GoogleAuthUtil.clearToken(), without throwing any exceptions. |
| */ |
| private void clearToken(String tokenValue) { |
| try { |
| GoogleAuthUtil.clearToken(appContext, tokenValue); |
| } catch (GoogleAuthException | IOException e) { |
| Log.e(TAG, "Error clearing auth token", e); |
| } |
| } |
| |
| /** |
| * Checks whether the app has necessary permissions, asking for them if needed. |
| * |
| * This blocks until permissions are granted/declined, and should not be called on the UI thread. |
| * |
| * Returns true if permissions are granted. |
| */ |
| private synchronized boolean checkPermission() { |
| if (ContextCompat.checkSelfPermission(appContext, GET_ACCOUNTS) |
| == PackageManager.PERMISSION_GRANTED) { |
| return true; |
| } |
| |
| final Activity activity = activityHolder.get(); |
| if (activity == null) { |
| return false; |
| } |
| |
| // Check if we have previously been denied permission. |
| if (ActivityCompat.shouldShowRequestPermissionRationale(activity, GET_ACCOUNTS)) { |
| activity.runOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| Toast.makeText(activity, R.string.starboard_accounts_permission, Toast.LENGTH_LONG) |
| .show(); |
| } |
| }); |
| return false; |
| } |
| |
| // Request permission. |
| waitingForPermission = true; |
| permissionGranted = false; |
| ActivityCompat.requestPermissions( |
| activity, new String[]{GET_ACCOUNTS}, R.id.rc_get_accounts_permission); |
| try { |
| while (waitingForPermission) { |
| wait(); |
| } |
| } catch (InterruptedException e) { |
| return false; |
| } |
| return permissionGranted; |
| } |
| |
| /** |
| * Callback pass-thru from the Activity with the result from requesting permissions. |
| */ |
| @Override |
| public void onRequestPermissionsResult( |
| int requestCode, String[] permissions, int[] grantResults) { |
| if (requestCode == R.id.rc_get_accounts_permission) { |
| synchronized (this) { |
| permissionGranted = grantResults.length > 0 |
| && grantResults[0] == PackageManager.PERMISSION_GRANTED; |
| waitingForPermission = false; |
| notifyAll(); |
| } |
| } |
| } |
| |
| /** |
| * Remember the name of the signed-in account. |
| */ |
| private void saveSignedInAccountName(String accountName) { |
| getPreferences().edit().putString(ACCOUNT_NAME_PREF_KEY, accountName).commit(); |
| } |
| |
| /** |
| * Returns the remembered name of the signed-in account. |
| */ |
| private String loadSignedInAccountName() { |
| return getPreferences().getString(ACCOUNT_NAME_PREF_KEY, ""); |
| } |
| |
| private SharedPreferences getPreferences() { |
| return appContext.getSharedPreferences(SHARED_PREFS_NAME, 0); |
| } |
| } |