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