| // Copyright 2013 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.chromoting; |
| |
| import android.accounts.Account; |
| import android.accounts.AccountManager; |
| import android.accounts.AccountManagerCallback; |
| import android.accounts.AccountManagerFuture; |
| import android.accounts.AuthenticatorException; |
| import android.accounts.OperationCanceledException; |
| import android.annotation.SuppressLint; |
| import android.app.ActionBar; |
| import android.app.Activity; |
| import android.app.AlertDialog; |
| import android.app.ProgressDialog; |
| import android.content.DialogInterface; |
| import android.content.Intent; |
| import android.content.SharedPreferences; |
| import android.content.res.Configuration; |
| import android.os.Bundle; |
| import android.provider.Settings; |
| import android.util.Log; |
| import android.view.Menu; |
| import android.view.MenuItem; |
| import android.view.View; |
| import android.widget.ArrayAdapter; |
| import android.widget.ListView; |
| import android.widget.Toast; |
| |
| import org.chromium.chromoting.jni.JniInterface; |
| |
| import java.io.IOException; |
| import java.util.Arrays; |
| |
| /** |
| * The user interface for querying and displaying a user's host list from the directory server. It |
| * also requests and renews authentication tokens using the system account manager. |
| */ |
| public class Chromoting extends Activity implements JniInterface.ConnectionListener, |
| AccountManagerCallback<Bundle>, ActionBar.OnNavigationListener, HostListLoader.Callback, |
| View.OnClickListener { |
| /** Only accounts of this type will be selectable for authentication. */ |
| private static final String ACCOUNT_TYPE = "com.google"; |
| |
| /** Scopes at which the authentication token we request will be valid. */ |
| private static final String TOKEN_SCOPE = "oauth2:https://www.googleapis.com/auth/chromoting " + |
| "https://www.googleapis.com/auth/googletalk"; |
| |
| /** Web page to be displayed in the Help screen when launched from this activity. */ |
| private static final String HELP_URL = |
| "http://support.google.com/chrome/?p=mobile_crd_hostslist"; |
| |
| /** Web page to be displayed when user triggers the hyperlink for setting up hosts. */ |
| private static final String HOST_SETUP_URL = |
| "https://support.google.com/chrome/answer/1649523"; |
| |
| /** User's account details. */ |
| private Account mAccount; |
| |
| /** List of accounts on the system. */ |
| private Account[] mAccounts; |
| |
| /** SpinnerAdapter used in the action bar for selecting accounts. */ |
| private AccountsAdapter mAccountsAdapter; |
| |
| /** Account auth token. */ |
| private String mToken; |
| |
| /** Helper for fetching the host list. */ |
| private HostListLoader mHostListLoader; |
| |
| /** List of hosts. */ |
| private HostInfo[] mHosts = new HostInfo[0]; |
| |
| /** Refresh button. */ |
| private MenuItem mRefreshButton; |
| |
| /** Host list as it appears to the user. */ |
| private ListView mHostListView; |
| |
| /** Progress view shown instead of the host list when the host list is loading. */ |
| private View mProgressView; |
| |
| /** Dialog for reporting connection progress. */ |
| private ProgressDialog mProgressIndicator; |
| |
| /** Object for fetching OAuth2 access tokens from third party authorization servers. */ |
| private ThirdPartyTokenFetcher mTokenFetcher; |
| |
| /** |
| * This is set when receiving an authentication error from the HostListLoader. If that occurs, |
| * this flag is set and a fresh authentication token is fetched from the AccountsService, and |
| * used to request the host list a second time. |
| */ |
| boolean mTriedNewAuthToken; |
| |
| /** Shows a warning explaining that a Google account is required, then closes the activity. */ |
| private void showNoAccountsDialog() { |
| AlertDialog.Builder builder = new AlertDialog.Builder(this); |
| builder.setMessage(R.string.noaccounts_message); |
| builder.setPositiveButton(R.string.noaccounts_add_account, |
| new DialogInterface.OnClickListener() { |
| @SuppressLint("InlinedApi") |
| @Override |
| public void onClick(DialogInterface dialog, int id) { |
| Intent intent = new Intent(Settings.ACTION_ADD_ACCOUNT); |
| intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, |
| new String[] { ACCOUNT_TYPE }); |
| if (intent.resolveActivity(getPackageManager()) != null) { |
| startActivity(intent); |
| } |
| finish(); |
| } |
| }); |
| builder.setNegativeButton(R.string.close, new DialogInterface.OnClickListener() { |
| @Override |
| public void onClick(DialogInterface dialog, int id) { |
| finish(); |
| } |
| }); |
| builder.setOnCancelListener(new DialogInterface.OnCancelListener() { |
| @Override |
| public void onCancel(DialogInterface dialog) { |
| finish(); |
| } |
| }); |
| |
| AlertDialog dialog = builder.create(); |
| dialog.show(); |
| } |
| |
| /** Shows or hides the progress indicator for loading the host list. */ |
| private void setHostListProgressVisible(boolean visible) { |
| mHostListView.setVisibility(visible ? View.GONE : View.VISIBLE); |
| mProgressView.setVisibility(visible ? View.VISIBLE : View.GONE); |
| |
| // Hiding the host-list does not automatically hide the empty view, so do that here. |
| if (visible) { |
| mHostListView.getEmptyView().setVisibility(View.GONE); |
| } |
| } |
| |
| /** |
| * Called when the activity is first created. Loads the native library and requests an |
| * authentication token from the system. |
| */ |
| @Override |
| public void onCreate(Bundle savedInstanceState) { |
| super.onCreate(savedInstanceState); |
| setContentView(R.layout.main); |
| |
| mTriedNewAuthToken = false; |
| mHostListLoader = new HostListLoader(); |
| |
| // Get ahold of our view widgets. |
| mHostListView = (ListView)findViewById(R.id.hostList_chooser); |
| mHostListView.setEmptyView(findViewById(R.id.hostList_empty)); |
| mProgressView = findViewById(R.id.hostList_progress); |
| |
| findViewById(R.id.host_setup_link_android).setOnClickListener(this); |
| |
| // Bring native components online. |
| JniInterface.loadLibrary(this); |
| } |
| |
| @Override |
| protected void onNewIntent(Intent intent) { |
| super.onNewIntent(intent); |
| if (mTokenFetcher != null) { |
| if (mTokenFetcher.handleTokenFetched(intent)) { |
| mTokenFetcher = null; |
| } |
| } |
| } |
| /** |
| * Called when the activity becomes visible. This happens on initial launch and whenever the |
| * user switches to the activity, for example, by using the window-switcher or when coming from |
| * the device's lock screen. |
| */ |
| @Override |
| public void onStart() { |
| super.onStart(); |
| |
| mAccounts = AccountManager.get(this).getAccountsByType(ACCOUNT_TYPE); |
| if (mAccounts.length == 0) { |
| showNoAccountsDialog(); |
| return; |
| } |
| |
| SharedPreferences prefs = getPreferences(MODE_PRIVATE); |
| int index = -1; |
| if (prefs.contains("account_name") && prefs.contains("account_type")) { |
| mAccount = new Account(prefs.getString("account_name", null), |
| prefs.getString("account_type", null)); |
| index = Arrays.asList(mAccounts).indexOf(mAccount); |
| } |
| if (index == -1) { |
| // Preference not loaded, or does not correspond to a valid account, so just pick the |
| // first account arbitrarily. |
| index = 0; |
| mAccount = mAccounts[0]; |
| } |
| |
| if (mAccounts.length == 1) { |
| getActionBar().setDisplayShowTitleEnabled(true); |
| getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); |
| getActionBar().setTitle(R.string.mode_me2me); |
| getActionBar().setSubtitle(mAccount.name); |
| } else { |
| mAccountsAdapter = new AccountsAdapter(this, mAccounts); |
| getActionBar().setDisplayShowTitleEnabled(false); |
| getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); |
| getActionBar().setListNavigationCallbacks(mAccountsAdapter, this); |
| getActionBar().setSelectedNavigationItem(index); |
| } |
| |
| refreshHostList(); |
| } |
| |
| /** Called when the activity is finally finished. */ |
| @Override |
| public void onDestroy() { |
| super.onDestroy(); |
| JniInterface.disconnectFromHost(); |
| } |
| |
| /** Called when the display is rotated (as registered in the manifest). */ |
| @Override |
| public void onConfigurationChanged(Configuration newConfig) { |
| super.onConfigurationChanged(newConfig); |
| |
| // Reload the spinner resources, since the font sizes are dependent on the screen |
| // orientation. |
| if (mAccounts.length != 1) { |
| mAccountsAdapter.notifyDataSetChanged(); |
| } |
| } |
| |
| /** Called to initialize the action bar. */ |
| @Override |
| public boolean onCreateOptionsMenu(Menu menu) { |
| getMenuInflater().inflate(R.menu.chromoting_actionbar, menu); |
| mRefreshButton = menu.findItem(R.id.actionbar_directoryrefresh); |
| |
| if (mAccount == null) { |
| // If there is no account, don't allow the user to refresh the listing. |
| mRefreshButton.setEnabled(false); |
| } |
| |
| return super.onCreateOptionsMenu(menu); |
| } |
| |
| /** Called whenever an action bar button is pressed. */ |
| @Override |
| public boolean onOptionsItemSelected(MenuItem item) { |
| int id = item.getItemId(); |
| if (id == R.id.actionbar_directoryrefresh) { |
| refreshHostList(); |
| return true; |
| } |
| if (id == R.id.actionbar_help) { |
| HelpActivity.launch(this, HELP_URL); |
| return true; |
| } |
| return super.onOptionsItemSelected(item); |
| } |
| |
| /** Called when the user touches hyperlinked text. */ |
| @Override |
| public void onClick(View view) { |
| HelpActivity.launch(this, HOST_SETUP_URL); |
| } |
| |
| /** Called when the user taps on a host entry. */ |
| public void connectToHost(HostInfo host) { |
| mProgressIndicator = ProgressDialog.show(this, |
| host.name, getString(R.string.footer_connecting), true, true, |
| new DialogInterface.OnCancelListener() { |
| @Override |
| public void onCancel(DialogInterface dialog) { |
| JniInterface.disconnectFromHost(); |
| mTokenFetcher = null; |
| } |
| }); |
| SessionConnector connector = new SessionConnector(this, this, mHostListLoader); |
| assert mTokenFetcher == null; |
| mTokenFetcher = createTokenFetcher(host); |
| connector.connectToHost(mAccount.name, mToken, host); |
| } |
| |
| private void refreshHostList() { |
| mTriedNewAuthToken = false; |
| setHostListProgressVisible(true); |
| |
| // The refresh button simply makes use of the currently-chosen account. |
| AccountManager.get(this).getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null); |
| } |
| |
| @Override |
| public void run(AccountManagerFuture<Bundle> future) { |
| Log.i("auth", "User finished with auth dialogs"); |
| Bundle result = null; |
| String explanation = null; |
| try { |
| // Here comes our auth token from the Android system. |
| result = future.getResult(); |
| String authToken = result.getString(AccountManager.KEY_AUTHTOKEN); |
| Log.i("auth", "Received an auth token from system"); |
| |
| mToken = authToken; |
| |
| mHostListLoader.retrieveHostList(authToken, this); |
| } catch (OperationCanceledException ex) { |
| // User canceled authentication. No need to report an error. |
| } catch (AuthenticatorException ex) { |
| explanation = getString(R.string.error_unexpected); |
| } catch (IOException ex) { |
| explanation = getString(R.string.error_network_error); |
| } |
| |
| if (result == null) { |
| if (explanation != null) { |
| Toast.makeText(this, explanation, Toast.LENGTH_LONG).show(); |
| } |
| return; |
| } |
| |
| String authToken = result.getString(AccountManager.KEY_AUTHTOKEN); |
| Log.i("auth", "Received an auth token from system"); |
| |
| mToken = authToken; |
| |
| mHostListLoader.retrieveHostList(authToken, this); |
| } |
| |
| @Override |
| public boolean onNavigationItemSelected(int itemPosition, long itemId) { |
| mAccount = mAccounts[itemPosition]; |
| |
| getPreferences(MODE_PRIVATE).edit().putString("account_name", mAccount.name). |
| putString("account_type", mAccount.type).apply(); |
| |
| // The current host list is no longer valid for the new account, so clear the list. |
| mHosts = new HostInfo[0]; |
| updateUi(); |
| refreshHostList(); |
| return true; |
| } |
| |
| @Override |
| public void onHostListReceived(HostInfo[] hosts) { |
| // Store a copy of the array, so that it can't be mutated by the HostListLoader. HostInfo |
| // is an immutable type, so a shallow copy of the array is sufficient here. |
| mHosts = Arrays.copyOf(hosts, hosts.length); |
| setHostListProgressVisible(false); |
| updateUi(); |
| } |
| |
| @Override |
| public void onError(HostListLoader.Error error) { |
| String explanation = null; |
| switch (error) { |
| case AUTH_FAILED: |
| break; |
| case NETWORK_ERROR: |
| explanation = getString(R.string.error_network_error); |
| break; |
| case UNEXPECTED_RESPONSE: |
| case SERVICE_UNAVAILABLE: |
| case UNKNOWN: |
| explanation = getString(R.string.error_unexpected); |
| break; |
| default: |
| // Unreachable. |
| return; |
| } |
| |
| if (explanation != null) { |
| Toast.makeText(this, explanation, Toast.LENGTH_LONG).show(); |
| setHostListProgressVisible(false); |
| return; |
| } |
| |
| // This is the AUTH_FAILED case. |
| |
| if (!mTriedNewAuthToken) { |
| // This was our first connection attempt. |
| |
| AccountManager authenticator = AccountManager.get(this); |
| mTriedNewAuthToken = true; |
| |
| Log.w("auth", "Requesting renewal of rejected auth token"); |
| authenticator.invalidateAuthToken(mAccount.type, mToken); |
| mToken = null; |
| authenticator.getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null); |
| |
| // We're not in an error state *yet*. |
| return; |
| } else { |
| // Authentication truly failed. |
| Log.e("auth", "Fresh auth token was also rejected"); |
| explanation = getString(R.string.error_authentication_failed); |
| Toast.makeText(this, explanation, Toast.LENGTH_LONG).show(); |
| setHostListProgressVisible(false); |
| } |
| } |
| |
| /** |
| * Updates the infotext and host list display. |
| */ |
| private void updateUi() { |
| if (mRefreshButton != null) { |
| mRefreshButton.setEnabled(mAccount != null); |
| } |
| ArrayAdapter<HostInfo> displayer = new HostListAdapter(this, R.layout.host, mHosts); |
| Log.i("hostlist", "About to populate host list display"); |
| mHostListView.setAdapter(displayer); |
| } |
| |
| @Override |
| public void onConnectionState(JniInterface.ConnectionListener.State state, |
| JniInterface.ConnectionListener.Error error) { |
| boolean dismissProgress = false; |
| switch (state) { |
| case INITIALIZING: |
| case CONNECTING: |
| case AUTHENTICATED: |
| // The connection is still being established. |
| break; |
| |
| case CONNECTED: |
| dismissProgress = true; |
| // Display the remote desktop. |
| startActivityForResult(new Intent(this, Desktop.class), 0); |
| break; |
| |
| case FAILED: |
| dismissProgress = true; |
| Toast.makeText(this, getString(error.message()), Toast.LENGTH_LONG).show(); |
| // Close the Desktop view, if it is currently running. |
| finishActivity(0); |
| break; |
| |
| case CLOSED: |
| // No need to show toast in this case. Either the connection will have failed |
| // because of an error, which will trigger toast already. Or the disconnection will |
| // have been initiated by the user. |
| dismissProgress = true; |
| finishActivity(0); |
| break; |
| |
| default: |
| // Unreachable, but required by Google Java style and findbugs. |
| assert false : "Unreached"; |
| } |
| |
| if (dismissProgress && mProgressIndicator != null) { |
| mProgressIndicator.dismiss(); |
| mProgressIndicator = null; |
| } |
| } |
| |
| private ThirdPartyTokenFetcher createTokenFetcher(HostInfo host) { |
| ThirdPartyTokenFetcher.Callback callback = new ThirdPartyTokenFetcher.Callback() { |
| public void onTokenFetched(String code, String accessToken) { |
| // The native client sends the OAuth authorization code to the host as the token so |
| // that the host can obtain the shared secret from the third party authorization |
| // server. |
| String token = code; |
| |
| // The native client uses the OAuth access token as the shared secret to |
| // authenticate itself with the host using spake. |
| String sharedSecret = accessToken; |
| |
| JniInterface.nativeOnThirdPartyTokenFetched(token, sharedSecret); |
| } |
| }; |
| return new ThirdPartyTokenFetcher(this, host.getTokenUrlPatterns(), callback); |
| } |
| |
| public void fetchThirdPartyToken(String tokenUrl, String clientId, String scope) { |
| assert mTokenFetcher != null; |
| mTokenFetcher.fetchToken(tokenUrl, clientId, scope); |
| } |
| } |