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 /** Shows a warning explaining that a Google account is required, then closes the activity. */ 100 private void showNoAccountsDialog() { 101 AlertDialog.Builder builder = new AlertDialog.Builder(this); 102 builder.setMessage(R.string.noaccounts_message); 103 builder.setPositiveButton(R.string.noaccounts_add_account, 104 new DialogInterface.OnClickListener() { 105 @SuppressLint("InlinedApi") 106 @Override 107 public void onClick(DialogInterface dialog, int id) { 108 Intent intent = new Intent(Settings.ACTION_ADD_ACCOUNT); 109 intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES, 110 new String[] { ACCOUNT_TYPE }); 111 if (intent.resolveActivity(getPackageManager()) != null) { 112 startActivity(intent); 113 } 114 finish(); 115 } 116 }); 117 builder.setNegativeButton(R.string.close, new DialogInterface.OnClickListener() { 118 @Override 119 public void onClick(DialogInterface dialog, int id) { 120 finish(); 121 } 122 }); 123 builder.setOnCancelListener(new DialogInterface.OnCancelListener() { 124 @Override 125 public void onCancel(DialogInterface dialog) { 126 finish(); 127 } 128 }); 129 130 AlertDialog dialog = builder.create(); 131 dialog.show(); 132 } 133 134 /** Shows or hides the progress indicator for loading the host list. */ 135 private void setHostListProgressVisible(boolean visible) { 136 mHostListView.setVisibility(visible ? View.GONE : View.VISIBLE); 137 mProgressView.setVisibility(visible ? View.VISIBLE : View.GONE); 138 139 // Hiding the host-list does not automatically hide the empty view, so do that here. 140 if (visible) { 141 mHostListView.getEmptyView().setVisibility(View.GONE); 142 } 143 } 144 145 /** 146 * Called when the activity is first created. Loads the native library and requests an 147 * authentication token from the system. 148 */ 149 @Override 150 public void onCreate(Bundle savedInstanceState) { 151 super.onCreate(savedInstanceState); 152 setContentView(R.layout.main); 153 154 mTriedNewAuthToken = false; 155 mHostListLoader = new HostListLoader(); 156 157 // Get ahold of our view widgets. 158 mHostListView = (ListView)findViewById(R.id.hostList_chooser); 159 mHostListView.setEmptyView(findViewById(R.id.hostList_empty)); 160 mProgressView = findViewById(R.id.hostList_progress); 161 162 findViewById(R.id.host_setup_link_android).setOnClickListener(this); 163 164 // Bring native components online. 165 JniInterface.loadLibrary(this); 166 } 167 168 @Override 169 protected void onNewIntent(Intent intent) { 170 super.onNewIntent(intent); 171 if (mTokenFetcher != null) { 172 if (mTokenFetcher.handleTokenFetched(intent)) { 173 mTokenFetcher = null; 174 } 175 } 176 } 177 /** 178 * Called when the activity becomes visible. This happens on initial launch and whenever the 179 * user switches to the activity, for example, by using the window-switcher or when coming from 180 * the device's lock screen. 181 */ 182 @Override 183 public void onStart() { 184 super.onStart(); 185 186 mAccounts = AccountManager.get(this).getAccountsByType(ACCOUNT_TYPE); 187 if (mAccounts.length == 0) { 188 showNoAccountsDialog(); 189 return; 190 } 191 192 SharedPreferences prefs = getPreferences(MODE_PRIVATE); 193 int index = -1; 194 if (prefs.contains("account_name") && prefs.contains("account_type")) { 195 mAccount = new Account(prefs.getString("account_name", null), 196 prefs.getString("account_type", null)); 197 index = Arrays.asList(mAccounts).indexOf(mAccount); 198 } 199 if (index == -1) { 200 // Preference not loaded, or does not correspond to a valid account, so just pick the 201 // first account arbitrarily. 202 index = 0; 203 mAccount = mAccounts[0]; 204 } 205 206 if (mAccounts.length == 1) { 207 getActionBar().setDisplayShowTitleEnabled(true); 208 getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); 209 getActionBar().setTitle(R.string.mode_me2me); 210 getActionBar().setSubtitle(mAccount.name); 211 } else { 212 mAccountsAdapter = new AccountsAdapter(this, mAccounts); 213 getActionBar().setDisplayShowTitleEnabled(false); 214 getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); 215 getActionBar().setListNavigationCallbacks(mAccountsAdapter, this); 216 getActionBar().setSelectedNavigationItem(index); 217 } 218 219 refreshHostList(); 220 } 221 222 /** Called when the activity is finally finished. */ 223 @Override 224 public void onDestroy() { 225 super.onDestroy(); 226 JniInterface.disconnectFromHost(); 227 } 228 229 /** Called when the display is rotated (as registered in the manifest). */ 230 @Override 231 public void onConfigurationChanged(Configuration newConfig) { 232 super.onConfigurationChanged(newConfig); 233 234 // Reload the spinner resources, since the font sizes are dependent on the screen 235 // orientation. 236 if (mAccounts.length != 1) { 237 mAccountsAdapter.notifyDataSetChanged(); 238 } 239 } 240 241 /** Called to initialize the action bar. */ 242 @Override 243 public boolean onCreateOptionsMenu(Menu menu) { 244 getMenuInflater().inflate(R.menu.chromoting_actionbar, menu); 245 mRefreshButton = menu.findItem(R.id.actionbar_directoryrefresh); 246 247 if (mAccount == null) { 248 // If there is no account, don't allow the user to refresh the listing. 249 mRefreshButton.setEnabled(false); 250 } 251 252 return super.onCreateOptionsMenu(menu); 253 } 254 255 /** Called whenever an action bar button is pressed. */ 256 @Override 257 public boolean onOptionsItemSelected(MenuItem item) { 258 int id = item.getItemId(); 259 if (id == R.id.actionbar_directoryrefresh) { 260 refreshHostList(); 261 return true; 262 } 263 if (id == R.id.actionbar_help) { 264 HelpActivity.launch(this, HELP_URL); 265 return true; 266 } 267 return super.onOptionsItemSelected(item); 268 } 269 270 /** Called when the user touches hyperlinked text. */ 271 @Override 272 public void onClick(View view) { 273 HelpActivity.launch(this, HOST_SETUP_URL); 274 } 275 276 /** Called when the user taps on a host entry. */ 277 public void connectToHost(HostInfo host) { 278 mProgressIndicator = ProgressDialog.show(this, 279 host.name, getString(R.string.footer_connecting), true, true, 280 new DialogInterface.OnCancelListener() { 281 @Override 282 public void onCancel(DialogInterface dialog) { 283 JniInterface.disconnectFromHost(); 284 mTokenFetcher = null; 285 } 286 }); 287 SessionConnector connector = new SessionConnector(this, this, mHostListLoader); 288 assert mTokenFetcher == null; 289 mTokenFetcher = createTokenFetcher(host); 290 connector.connectToHost(mAccount.name, mToken, host); 291 } 292 293 private void refreshHostList() { 294 mTriedNewAuthToken = false; 295 setHostListProgressVisible(true); 296 297 // The refresh button simply makes use of the currently-chosen account. 298 AccountManager.get(this).getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null); 299 } 300 301 @Override 302 public void run(AccountManagerFuture<Bundle> future) { 303 Log.i("auth", "User finished with auth dialogs"); 304 Bundle result = null; 305 String explanation = null; 306 try { 307 // Here comes our auth token from the Android system. 308 result = future.getResult(); 309 String authToken = result.getString(AccountManager.KEY_AUTHTOKEN); 310 Log.i("auth", "Received an auth token from system"); 311 312 mToken = authToken; 313 314 mHostListLoader.retrieveHostList(authToken, this); 315 } catch (OperationCanceledException ex) { 316 // User canceled authentication. No need to report an error. 317 } catch (AuthenticatorException ex) { 318 explanation = getString(R.string.error_unexpected); 319 } catch (IOException ex) { 320 explanation = getString(R.string.error_network_error); 321 } 322 323 if (result == null) { 324 if (explanation != null) { 325 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show(); 326 } 327 return; 328 } 329 330 String authToken = result.getString(AccountManager.KEY_AUTHTOKEN); 331 Log.i("auth", "Received an auth token from system"); 332 333 mToken = authToken; 334 335 mHostListLoader.retrieveHostList(authToken, this); 336 } 337 338 @Override 339 public boolean onNavigationItemSelected(int itemPosition, long itemId) { 340 mAccount = mAccounts[itemPosition]; 341 342 getPreferences(MODE_PRIVATE).edit().putString("account_name", mAccount.name). 343 putString("account_type", mAccount.type).apply(); 344 345 // The current host list is no longer valid for the new account, so clear the list. 346 mHosts = new HostInfo[0]; 347 updateUi(); 348 refreshHostList(); 349 return true; 350 } 351 352 @Override 353 public void onHostListReceived(HostInfo[] hosts) { 354 // Store a copy of the array, so that it can't be mutated by the HostListLoader. HostInfo 355 // is an immutable type, so a shallow copy of the array is sufficient here. 356 mHosts = Arrays.copyOf(hosts, hosts.length); 357 setHostListProgressVisible(false); 358 updateUi(); 359 } 360 361 @Override 362 public void onError(HostListLoader.Error error) { 363 String explanation = null; 364 switch (error) { 365 case AUTH_FAILED: 366 break; 367 case NETWORK_ERROR: 368 explanation = getString(R.string.error_network_error); 369 break; 370 case UNEXPECTED_RESPONSE: 371 case SERVICE_UNAVAILABLE: 372 case UNKNOWN: 373 explanation = getString(R.string.error_unexpected); 374 break; 375 default: 376 // Unreachable. 377 return; 378 } 379 380 if (explanation != null) { 381 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show(); 382 setHostListProgressVisible(false); 383 return; 384 } 385 386 // This is the AUTH_FAILED case. 387 388 if (!mTriedNewAuthToken) { 389 // This was our first connection attempt. 390 391 AccountManager authenticator = AccountManager.get(this); 392 mTriedNewAuthToken = true; 393 394 Log.w("auth", "Requesting renewal of rejected auth token"); 395 authenticator.invalidateAuthToken(mAccount.type, mToken); 396 mToken = null; 397 authenticator.getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null); 398 399 // We're not in an error state *yet*. 400 return; 401 } else { 402 // Authentication truly failed. 403 Log.e("auth", "Fresh auth token was also rejected"); 404 explanation = getString(R.string.error_authentication_failed); 405 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show(); 406 setHostListProgressVisible(false); 407 } 408 } 409 410 /** 411 * Updates the infotext and host list display. 412 */ 413 private void updateUi() { 414 if (mRefreshButton != null) { 415 mRefreshButton.setEnabled(mAccount != null); 416 } 417 ArrayAdapter<HostInfo> displayer = new HostListAdapter(this, R.layout.host, mHosts); 418 Log.i("hostlist", "About to populate host list display"); 419 mHostListView.setAdapter(displayer); 420 } 421 422 @Override 423 public void onConnectionState(JniInterface.ConnectionListener.State state, 424 JniInterface.ConnectionListener.Error error) { 425 boolean dismissProgress = false; 426 switch (state) { 427 case INITIALIZING: 428 case CONNECTING: 429 case AUTHENTICATED: 430 // The connection is still being established. 431 break; 432 433 case CONNECTED: 434 dismissProgress = true; 435 // Display the remote desktop. 436 startActivityForResult(new Intent(this, Desktop.class), 0); 437 break; 438 439 case FAILED: 440 dismissProgress = true; 441 Toast.makeText(this, getString(error.message()), Toast.LENGTH_LONG).show(); 442 // Close the Desktop view, if it is currently running. 443 finishActivity(0); 444 break; 445 446 case CLOSED: 447 // No need to show toast in this case. Either the connection will have failed 448 // because of an error, which will trigger toast already. Or the disconnection will 449 // have been initiated by the user. 450 dismissProgress = true; 451 finishActivity(0); 452 break; 453 454 default: 455 // Unreachable, but required by Google Java style and findbugs. 456 assert false : "Unreached"; 457 } 458 459 if (dismissProgress && mProgressIndicator != null) { 460 mProgressIndicator.dismiss(); 461 mProgressIndicator = null; 462 } 463 } 464 465 private ThirdPartyTokenFetcher createTokenFetcher(HostInfo host) { 466 ThirdPartyTokenFetcher.Callback callback = new ThirdPartyTokenFetcher.Callback() { 467 public void onTokenFetched(String code, String accessToken) { 468 // The native client sends the OAuth authorization code to the host as the token so 469 // that the host can obtain the shared secret from the third party authorization 470 // server. 471 String token = code; 472 473 // The native client uses the OAuth access token as the shared secret to 474 // authenticate itself with the host using spake. 475 String sharedSecret = accessToken; 476 477 JniInterface.nativeOnThirdPartyTokenFetched(token, sharedSecret); 478 } 479 }; 480 return new ThirdPartyTokenFetcher(this, host.getTokenUrlPatterns(), callback); 481 } 482 483 public void fetchThirdPartyToken(String tokenUrl, String clientId, String scope) { 484 assert mTokenFetcher != null; 485 mTokenFetcher.fetchToken(tokenUrl, clientId, scope); 486 } 487 } 488