Home | History | Annotate | Download | only in chromoting
      1 // Copyright 2013 The Chromium Authors. All rights reserved.
      2 // Use of this source code is governed by a BSD-style license that can be
      3 // found in the LICENSE file.
      4 
      5 package org.chromium.chromoting;
      6 
      7 import android.accounts.Account;
      8 import android.accounts.AccountManager;
      9 import android.accounts.AccountManagerCallback;
     10 import android.accounts.AccountManagerFuture;
     11 import android.accounts.AuthenticatorException;
     12 import android.accounts.OperationCanceledException;
     13 import android.annotation.SuppressLint;
     14 import android.app.ActionBar;
     15 import android.app.Activity;
     16 import android.app.AlertDialog;
     17 import android.app.ProgressDialog;
     18 import android.content.DialogInterface;
     19 import android.content.Intent;
     20 import android.content.SharedPreferences;
     21 import android.content.res.Configuration;
     22 import android.os.Bundle;
     23 import android.provider.Settings;
     24 import android.util.Log;
     25 import android.view.Menu;
     26 import android.view.MenuItem;
     27 import android.view.View;
     28 import android.widget.ArrayAdapter;
     29 import android.widget.ListView;
     30 import android.widget.Toast;
     31 
     32 import org.chromium.chromoting.jni.JniInterface;
     33 
     34 import java.io.IOException;
     35 import java.util.Arrays;
     36 
     37 /**
     38  * The user interface for querying and displaying a user's host list from the directory server. It
     39  * also requests and renews authentication tokens using the system account manager.
     40  */
     41 public class Chromoting extends Activity implements JniInterface.ConnectionListener,
     42         AccountManagerCallback<Bundle>, ActionBar.OnNavigationListener, HostListLoader.Callback,
     43         View.OnClickListener {
     44     /** Only accounts of this type will be selectable for authentication. */
     45     private static final String ACCOUNT_TYPE = "com.google";
     46 
     47     /** Scopes at which the authentication token we request will be valid. */
     48     private static final String TOKEN_SCOPE = "oauth2:https://www.googleapis.com/auth/chromoting " +
     49             "https://www.googleapis.com/auth/googletalk";
     50 
     51     /** Web page to be displayed in the Help screen when launched from this activity. */
     52     private static final String HELP_URL =
     53             "http://support.google.com/chrome/?p=mobile_crd_hostslist";
     54 
     55     /** Web page to be displayed when user triggers the hyperlink for setting up hosts. */
     56     private static final String HOST_SETUP_URL =
     57             "https://support.google.com/chrome/answer/1649523";
     58 
     59     /** User's account details. */
     60     private Account mAccount;
     61 
     62     /** List of accounts on the system. */
     63     private Account[] mAccounts;
     64 
     65     /** SpinnerAdapter used in the action bar for selecting accounts. */
     66     private AccountsAdapter mAccountsAdapter;
     67 
     68     /** Account auth token. */
     69     private String mToken;
     70 
     71     /** Helper for fetching the host list. */
     72     private HostListLoader mHostListLoader;
     73 
     74     /** List of hosts. */
     75     private HostInfo[] mHosts = new HostInfo[0];
     76 
     77     /** Refresh button. */
     78     private MenuItem mRefreshButton;
     79 
     80     /** Host list as it appears to the user. */
     81     private ListView mHostListView;
     82 
     83     /** Progress view shown instead of the host list when the host list is loading. */
     84     private View mProgressView;
     85 
     86     /** Dialog for reporting connection progress. */
     87     private ProgressDialog mProgressIndicator;
     88 
     89     /** Object for fetching OAuth2 access tokens from third party authorization servers. */
     90     private ThirdPartyTokenFetcher mTokenFetcher;
     91 
     92     /**
     93      * This is set when receiving an authentication error from the HostListLoader. If that occurs,
     94      * this flag is set and a fresh authentication token is fetched from the AccountsService, and
     95      * used to request the host list a second time.
     96      */
     97     boolean mTriedNewAuthToken;
     98 
     99     /**
    100      * Flag to track whether a call to AccountManager.getAuthToken() is currently pending.
    101      * This avoids infinitely-nested calls in case onStart() gets triggered a second time
    102      * while a token is being fetched.
    103      */
    104     private boolean mWaitingForAuthToken = false;
    105 
    106     /** Shows a warning explaining that a Google account is required, then closes the activity. */
    107     private void showNoAccountsDialog() {
    108         AlertDialog.Builder builder = new AlertDialog.Builder(this);
    109         builder.setMessage(R.string.noaccounts_message);
    110         builder.setPositiveButton(R.string.noaccounts_add_account,
    111                 new DialogInterface.OnClickListener() {
    112                     @SuppressLint("InlinedApi")
    113                     @Override
    114                     public void onClick(DialogInterface dialog, int id) {
    115                         Intent intent = new Intent(Settings.ACTION_ADD_ACCOUNT);
    116                         intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES,
    117                                 new String[] { ACCOUNT_TYPE });
    118                         if (intent.resolveActivity(getPackageManager()) != null) {
    119                             startActivity(intent);
    120                         }
    121                         finish();
    122                     }
    123                 });
    124         builder.setNegativeButton(R.string.close, new DialogInterface.OnClickListener() {
    125                 @Override
    126                 public void onClick(DialogInterface dialog, int id) {
    127                     finish();
    128                 }
    129             });
    130         builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
    131                 @Override
    132                 public void onCancel(DialogInterface dialog) {
    133                     finish();
    134                 }
    135             });
    136 
    137         AlertDialog dialog = builder.create();
    138         dialog.show();
    139     }
    140 
    141     /** Shows or hides the progress indicator for loading the host list. */
    142     private void setHostListProgressVisible(boolean visible) {
    143         mHostListView.setVisibility(visible ? View.GONE : View.VISIBLE);
    144         mProgressView.setVisibility(visible ? View.VISIBLE : View.GONE);
    145 
    146         // Hiding the host-list does not automatically hide the empty view, so do that here.
    147         if (visible) {
    148             mHostListView.getEmptyView().setVisibility(View.GONE);
    149         }
    150     }
    151 
    152     /**
    153      * Called when the activity is first created. Loads the native library and requests an
    154      * authentication token from the system.
    155      */
    156     @Override
    157     public void onCreate(Bundle savedInstanceState) {
    158         super.onCreate(savedInstanceState);
    159         setContentView(R.layout.main);
    160 
    161         mTriedNewAuthToken = false;
    162         mHostListLoader = new HostListLoader();
    163 
    164         // Get ahold of our view widgets.
    165         mHostListView = (ListView) findViewById(R.id.hostList_chooser);
    166         mHostListView.setEmptyView(findViewById(R.id.hostList_empty));
    167         mProgressView = findViewById(R.id.hostList_progress);
    168 
    169         findViewById(R.id.host_setup_link_android).setOnClickListener(this);
    170 
    171         // Bring native components online.
    172         JniInterface.loadLibrary(this);
    173     }
    174 
    175     @Override
    176     protected void onNewIntent(Intent intent) {
    177         super.onNewIntent(intent);
    178         if (mTokenFetcher != null) {
    179             if (mTokenFetcher.handleTokenFetched(intent)) {
    180                 mTokenFetcher = null;
    181             }
    182         }
    183     }
    184 
    185     /**
    186      * Called when the activity becomes visible. This happens on initial launch and whenever the
    187      * user switches to the activity, for example, by using the window-switcher or when coming from
    188      * the device's lock screen.
    189      */
    190     @Override
    191     public void onStart() {
    192         super.onStart();
    193 
    194         mAccounts = AccountManager.get(this).getAccountsByType(ACCOUNT_TYPE);
    195         if (mAccounts.length == 0) {
    196             showNoAccountsDialog();
    197             return;
    198         }
    199 
    200         SharedPreferences prefs = getPreferences(MODE_PRIVATE);
    201         int index = -1;
    202         if (prefs.contains("account_name") && prefs.contains("account_type")) {
    203             mAccount = new Account(prefs.getString("account_name", null),
    204                     prefs.getString("account_type", null));
    205             index = Arrays.asList(mAccounts).indexOf(mAccount);
    206         }
    207         if (index == -1) {
    208             // Preference not loaded, or does not correspond to a valid account, so just pick the
    209             // first account arbitrarily.
    210             index = 0;
    211             mAccount = mAccounts[0];
    212         }
    213 
    214         if (mAccounts.length == 1) {
    215             getActionBar().setDisplayShowTitleEnabled(true);
    216             getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
    217             getActionBar().setTitle(R.string.mode_me2me);
    218             getActionBar().setSubtitle(mAccount.name);
    219         } else {
    220             mAccountsAdapter = new AccountsAdapter(this, mAccounts);
    221             getActionBar().setDisplayShowTitleEnabled(false);
    222             getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
    223             getActionBar().setListNavigationCallbacks(mAccountsAdapter, this);
    224             getActionBar().setSelectedNavigationItem(index);
    225         }
    226 
    227         refreshHostList();
    228     }
    229 
    230     /** Called when the activity is finally finished. */
    231     @Override
    232     public void onDestroy() {
    233         super.onDestroy();
    234         JniInterface.disconnectFromHost();
    235     }
    236 
    237     /** Called when the display is rotated (as registered in the manifest). */
    238     @Override
    239     public void onConfigurationChanged(Configuration newConfig) {
    240         super.onConfigurationChanged(newConfig);
    241 
    242         // Reload the spinner resources, since the font sizes are dependent on the screen
    243         // orientation.
    244         if (mAccounts.length != 1) {
    245             mAccountsAdapter.notifyDataSetChanged();
    246         }
    247     }
    248 
    249     /** Called to initialize the action bar. */
    250     @Override
    251     public boolean onCreateOptionsMenu(Menu menu) {
    252         getMenuInflater().inflate(R.menu.chromoting_actionbar, menu);
    253         mRefreshButton = menu.findItem(R.id.actionbar_directoryrefresh);
    254 
    255         if (mAccount == null) {
    256             // If there is no account, don't allow the user to refresh the listing.
    257             mRefreshButton.setEnabled(false);
    258         }
    259 
    260         return super.onCreateOptionsMenu(menu);
    261     }
    262 
    263     /** Called whenever an action bar button is pressed. */
    264     @Override
    265     public boolean onOptionsItemSelected(MenuItem item) {
    266         int id = item.getItemId();
    267         if (id == R.id.actionbar_directoryrefresh) {
    268             refreshHostList();
    269             return true;
    270         }
    271         if (id == R.id.actionbar_help) {
    272             HelpActivity.launch(this, HELP_URL);
    273             return true;
    274         }
    275         return super.onOptionsItemSelected(item);
    276     }
    277 
    278     /** Called when the user touches hyperlinked text. */
    279     @Override
    280     public void onClick(View view) {
    281         HelpActivity.launch(this, HOST_SETUP_URL);
    282     }
    283 
    284     /** Called when the user taps on a host entry. */
    285     public void connectToHost(HostInfo host) {
    286         mProgressIndicator = ProgressDialog.show(this,
    287               host.name, getString(R.string.footer_connecting), true, true,
    288               new DialogInterface.OnCancelListener() {
    289                   @Override
    290                   public void onCancel(DialogInterface dialog) {
    291                       JniInterface.disconnectFromHost();
    292                       mTokenFetcher = null;
    293                   }
    294               });
    295         SessionConnector connector = new SessionConnector(this, this, mHostListLoader);
    296         assert mTokenFetcher == null;
    297         mTokenFetcher = createTokenFetcher(host);
    298         connector.connectToHost(mAccount.name, mToken, host);
    299     }
    300 
    301     private void refreshHostList() {
    302         if (mWaitingForAuthToken) {
    303             return;
    304         }
    305 
    306         mTriedNewAuthToken = false;
    307         setHostListProgressVisible(true);
    308 
    309         // The refresh button simply makes use of the currently-chosen account.
    310         requestAuthToken();
    311     }
    312 
    313     private void requestAuthToken() {
    314         AccountManager.get(this).getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null);
    315         mWaitingForAuthToken = true;
    316     }
    317 
    318     @Override
    319     public void run(AccountManagerFuture<Bundle> future) {
    320         Log.i("auth", "User finished with auth dialogs");
    321         mWaitingForAuthToken = false;
    322 
    323         Bundle result = null;
    324         String explanation = null;
    325         try {
    326             // Here comes our auth token from the Android system.
    327             result = future.getResult();
    328         } catch (OperationCanceledException ex) {
    329             // User canceled authentication. No need to report an error.
    330         } catch (AuthenticatorException ex) {
    331             explanation = getString(R.string.error_unexpected);
    332         } catch (IOException ex) {
    333             explanation = getString(R.string.error_network_error);
    334         }
    335 
    336         if (result == null) {
    337             setHostListProgressVisible(false);
    338             if (explanation != null) {
    339                 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
    340             }
    341             return;
    342         }
    343 
    344         mToken = result.getString(AccountManager.KEY_AUTHTOKEN);
    345         Log.i("auth", "Received an auth token from system");
    346 
    347         mHostListLoader.retrieveHostList(mToken, this);
    348     }
    349 
    350     @Override
    351     public boolean onNavigationItemSelected(int itemPosition, long itemId) {
    352         mAccount = mAccounts[itemPosition];
    353 
    354         getPreferences(MODE_PRIVATE).edit().putString("account_name", mAccount.name).
    355                     putString("account_type", mAccount.type).apply();
    356 
    357         // The current host list is no longer valid for the new account, so clear the list.
    358         mHosts = new HostInfo[0];
    359         updateUi();
    360         refreshHostList();
    361         return true;
    362     }
    363 
    364     @Override
    365     public void onHostListReceived(HostInfo[] hosts) {
    366         // Store a copy of the array, so that it can't be mutated by the HostListLoader. HostInfo
    367         // is an immutable type, so a shallow copy of the array is sufficient here.
    368         mHosts = Arrays.copyOf(hosts, hosts.length);
    369         setHostListProgressVisible(false);
    370         updateUi();
    371     }
    372 
    373     @Override
    374     public void onError(HostListLoader.Error error) {
    375         String explanation = null;
    376         switch (error) {
    377             case AUTH_FAILED:
    378                 break;
    379             case NETWORK_ERROR:
    380                 explanation = getString(R.string.error_network_error);
    381                 break;
    382             case UNEXPECTED_RESPONSE:
    383             case SERVICE_UNAVAILABLE:
    384             case UNKNOWN:
    385                 explanation = getString(R.string.error_unexpected);
    386                 break;
    387             default:
    388                 // Unreachable.
    389                 return;
    390         }
    391 
    392         if (explanation != null) {
    393             Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
    394             setHostListProgressVisible(false);
    395             return;
    396         }
    397 
    398         // This is the AUTH_FAILED case.
    399 
    400         if (!mTriedNewAuthToken) {
    401             // This was our first connection attempt.
    402 
    403             AccountManager authenticator = AccountManager.get(this);
    404             mTriedNewAuthToken = true;
    405 
    406             Log.w("auth", "Requesting renewal of rejected auth token");
    407             authenticator.invalidateAuthToken(mAccount.type, mToken);
    408             mToken = null;
    409             requestAuthToken();
    410 
    411             // We're not in an error state *yet*.
    412             return;
    413         } else {
    414             // Authentication truly failed.
    415             Log.e("auth", "Fresh auth token was also rejected");
    416             explanation = getString(R.string.error_authentication_failed);
    417             Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
    418             setHostListProgressVisible(false);
    419         }
    420     }
    421 
    422     /**
    423      * Updates the infotext and host list display.
    424      */
    425     private void updateUi() {
    426         if (mRefreshButton != null) {
    427             mRefreshButton.setEnabled(mAccount != null);
    428         }
    429         ArrayAdapter<HostInfo> displayer = new HostListAdapter(this, R.layout.host, mHosts);
    430         Log.i("hostlist", "About to populate host list display");
    431         mHostListView.setAdapter(displayer);
    432     }
    433 
    434     @Override
    435     public void onConnectionState(JniInterface.ConnectionListener.State state,
    436             JniInterface.ConnectionListener.Error error) {
    437         boolean dismissProgress = false;
    438         switch (state) {
    439             case INITIALIZING:
    440             case CONNECTING:
    441             case AUTHENTICATED:
    442                 // The connection is still being established.
    443                 break;
    444 
    445             case CONNECTED:
    446                 dismissProgress = true;
    447                 // Display the remote desktop.
    448                 startActivityForResult(new Intent(this, Desktop.class), 0);
    449                 break;
    450 
    451             case FAILED:
    452                 dismissProgress = true;
    453                 Toast.makeText(this, getString(error.message()), Toast.LENGTH_LONG).show();
    454                 // Close the Desktop view, if it is currently running.
    455                 finishActivity(0);
    456                 break;
    457 
    458             case CLOSED:
    459                 // No need to show toast in this case. Either the connection will have failed
    460                 // because of an error, which will trigger toast already. Or the disconnection will
    461                 // have been initiated by the user.
    462                 dismissProgress = true;
    463                 finishActivity(0);
    464                 break;
    465 
    466             default:
    467                 // Unreachable, but required by Google Java style and findbugs.
    468                 assert false : "Unreached";
    469         }
    470 
    471         if (dismissProgress && mProgressIndicator != null) {
    472             mProgressIndicator.dismiss();
    473             mProgressIndicator = null;
    474         }
    475     }
    476 
    477     private ThirdPartyTokenFetcher createTokenFetcher(HostInfo host) {
    478         ThirdPartyTokenFetcher.Callback callback = new ThirdPartyTokenFetcher.Callback() {
    479             @Override
    480             public void onTokenFetched(String code, String accessToken) {
    481                 // The native client sends the OAuth authorization code to the host as the token so
    482                 // that the host can obtain the shared secret from the third party authorization
    483                 // server.
    484                 String token = code;
    485 
    486                 // The native client uses the OAuth access token as the shared secret to
    487                 // authenticate itself with the host using spake.
    488                 String sharedSecret = accessToken;
    489 
    490                 JniInterface.onThirdPartyTokenFetched(token, sharedSecret);
    491             }
    492         };
    493         return new ThirdPartyTokenFetcher(this, host.getTokenUrlPatterns(), callback);
    494     }
    495 
    496     public void fetchThirdPartyToken(String tokenUrl, String clientId, String scope) {
    497         assert mTokenFetcher != null;
    498         mTokenFetcher.fetchToken(tokenUrl, clientId, scope);
    499     }
    500 }
    501