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.
5 package org.chromium.chromoting;
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.AdapterView;
29 import android.widget.ArrayAdapter;
30 import android.widget.ListView;
31 import android.widget.Spinner;
32 import android.widget.Toast;
34 import org.chromium.chromoting.jni.JniInterface;
36 import java.io.IOException;
37 import java.util.Arrays;
40 * The user interface for querying and displaying a user's host list from the directory server. It
41 * also requests and renews authentication tokens using the system account manager.
43 public class Chromoting extends Activity implements JniInterface.ConnectionListener,
44 AccountManagerCallback<Bundle>, AdapterView.OnItemSelectedListener, HostListLoader.Callback,
45 View.OnClickListener {
46 /** Only accounts of this type will be selectable for authentication. */
47 private static final String ACCOUNT_TYPE = "com.google";
49 /** Scopes at which the authentication token we request will be valid. */
50 private static final String TOKEN_SCOPE = "oauth2:https://www.googleapis.com/auth/chromoting "
51 + "https://www.googleapis.com/auth/googletalk";
53 /** Web page to be displayed in the Help screen when launched from this activity. */
54 private static final String HELP_URL =
55 "http://support.google.com/chrome/?p=mobile_crd_hostslist";
57 /** Web page to be displayed when user triggers the hyperlink for setting up hosts. */
58 private static final String HOST_SETUP_URL =
59 "https://support.google.com/chrome/answer/1649523";
61 /** User's account details. */
62 private Account mAccount;
64 /** List of accounts on the system. */
65 private Account[] mAccounts;
67 /** SpinnerAdapter used in the action bar for selecting accounts. */
68 private AccountsAdapter mAccountsAdapter;
70 /** Account auth token. */
71 private String mToken;
73 /** Helper for fetching the host list. */
74 private HostListLoader mHostListLoader;
77 private HostInfo[] mHosts = new HostInfo[0];
79 /** Refresh button. */
80 private MenuItem mRefreshButton;
82 /** Host list as it appears to the user. */
83 private ListView mHostListView;
85 /** Progress view shown instead of the host list when the host list is loading. */
86 private View mProgressView;
88 /** Dialog for reporting connection progress. */
89 private ProgressDialog mProgressIndicator;
91 /** Object for fetching OAuth2 access tokens from third party authorization servers. */
92 private ThirdPartyTokenFetcher mTokenFetcher;
95 * This is set when receiving an authentication error from the HostListLoader. If that occurs,
96 * this flag is set and a fresh authentication token is fetched from the AccountsService, and
97 * used to request the host list a second time.
99 boolean mTriedNewAuthToken;
102 * Flag to track whether a call to AccountManager.getAuthToken() is currently pending.
103 * This avoids infinitely-nested calls in case onStart() gets triggered a second time
104 * while a token is being fetched.
106 private boolean mWaitingForAuthToken = false;
108 /** Shows a warning explaining that a Google account is required, then closes the activity. */
109 private void showNoAccountsDialog() {
110 AlertDialog.Builder builder = new AlertDialog.Builder(this);
111 builder.setMessage(R.string.noaccounts_message);
112 builder.setPositiveButton(R.string.noaccounts_add_account,
113 new DialogInterface.OnClickListener() {
114 @SuppressLint("InlinedApi")
116 public void onClick(DialogInterface dialog, int id) {
117 Intent intent = new Intent(Settings.ACTION_ADD_ACCOUNT);
118 intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES,
119 new String[] { ACCOUNT_TYPE });
120 if (intent.resolveActivity(getPackageManager()) != null) {
121 startActivity(intent);
126 builder.setNegativeButton(R.string.close, new DialogInterface.OnClickListener() {
128 public void onClick(DialogInterface dialog, int id) {
132 builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
134 public void onCancel(DialogInterface dialog) {
139 AlertDialog dialog = builder.create();
143 /** Shows or hides the progress indicator for loading the host list. */
144 private void setHostListProgressVisible(boolean visible) {
145 mHostListView.setVisibility(visible ? View.GONE : View.VISIBLE);
146 mProgressView.setVisibility(visible ? View.VISIBLE : View.GONE);
148 // Hiding the host-list does not automatically hide the empty view, so do that here.
150 mHostListView.getEmptyView().setVisibility(View.GONE);
155 * Called when the activity is first created. Loads the native library and requests an
156 * authentication token from the system.
159 public void onCreate(Bundle savedInstanceState) {
160 super.onCreate(savedInstanceState);
161 setContentView(R.layout.main);
163 mTriedNewAuthToken = false;
164 mHostListLoader = new HostListLoader();
166 // Get ahold of our view widgets.
167 mHostListView = (ListView) findViewById(R.id.hostList_chooser);
168 mHostListView.setEmptyView(findViewById(R.id.hostList_empty));
169 mProgressView = findViewById(R.id.hostList_progress);
171 findViewById(R.id.host_setup_link_android).setOnClickListener(this);
173 // Bring native components online.
174 JniInterface.loadLibrary(this);
178 protected void onNewIntent(Intent intent) {
179 super.onNewIntent(intent);
180 if (mTokenFetcher != null) {
181 if (mTokenFetcher.handleTokenFetched(intent)) {
182 mTokenFetcher = null;
188 * Called when the activity becomes visible. This happens on initial launch and whenever the
189 * user switches to the activity, for example, by using the window-switcher or when coming from
190 * the device's lock screen.
193 public void onStart() {
196 mAccounts = AccountManager.get(this).getAccountsByType(ACCOUNT_TYPE);
197 if (mAccounts.length == 0) {
198 showNoAccountsDialog();
202 SharedPreferences prefs = getPreferences(MODE_PRIVATE);
204 if (prefs.contains("account_name") && prefs.contains("account_type")) {
205 mAccount = new Account(prefs.getString("account_name", null),
206 prefs.getString("account_type", null));
207 index = Arrays.asList(mAccounts).indexOf(mAccount);
210 // Preference not loaded, or does not correspond to a valid account, so just pick the
211 // first account arbitrarily.
213 mAccount = mAccounts[0];
216 if (mAccounts.length == 1) {
217 getActionBar().setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE,
218 ActionBar.DISPLAY_SHOW_TITLE | ActionBar.DISPLAY_SHOW_CUSTOM);
219 getActionBar().setTitle(R.string.mode_me2me);
220 getActionBar().setSubtitle(mAccount.name);
222 mAccountsAdapter = new AccountsAdapter(this, mAccounts);
223 Spinner accountsSpinner = new Spinner(getActionBar().getThemedContext(), null,
224 android.R.attr.actionDropDownStyle);
225 accountsSpinner.setAdapter(mAccountsAdapter);
226 getActionBar().setCustomView(accountsSpinner);
227 getActionBar().setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM,
228 ActionBar.DISPLAY_SHOW_TITLE | ActionBar.DISPLAY_SHOW_CUSTOM);
229 accountsSpinner.setOnItemSelectedListener(this);
230 accountsSpinner.setSelection(index);
236 /** Called when the activity is finally finished. */
238 public void onDestroy() {
240 JniInterface.disconnectFromHost();
243 /** Called when the display is rotated (as registered in the manifest). */
245 public void onConfigurationChanged(Configuration newConfig) {
246 super.onConfigurationChanged(newConfig);
248 // Reload the spinner resources, since the font sizes are dependent on the screen
250 if (mAccounts.length != 1) {
251 mAccountsAdapter.notifyDataSetChanged();
255 /** Called to initialize the action bar. */
257 public boolean onCreateOptionsMenu(Menu menu) {
258 getMenuInflater().inflate(R.menu.chromoting_actionbar, menu);
259 mRefreshButton = menu.findItem(R.id.actionbar_directoryrefresh);
261 if (mAccount == null) {
262 // If there is no account, don't allow the user to refresh the listing.
263 mRefreshButton.setEnabled(false);
266 return super.onCreateOptionsMenu(menu);
269 /** Called whenever an action bar button is pressed. */
271 public boolean onOptionsItemSelected(MenuItem item) {
272 int id = item.getItemId();
273 if (id == R.id.actionbar_directoryrefresh) {
277 if (id == R.id.actionbar_help) {
278 HelpActivity.launch(this, HELP_URL);
281 return super.onOptionsItemSelected(item);
284 /** Called when the user touches hyperlinked text. */
286 public void onClick(View view) {
287 HelpActivity.launch(this, HOST_SETUP_URL);
290 /** Called when the user taps on a host entry. */
291 public void connectToHost(HostInfo host) {
292 mProgressIndicator = ProgressDialog.show(
295 getString(R.string.footer_connecting),
298 new DialogInterface.OnCancelListener() {
300 public void onCancel(DialogInterface dialog) {
301 JniInterface.disconnectFromHost();
302 mTokenFetcher = null;
305 SessionConnector connector = new SessionConnector(this, this, mHostListLoader);
306 assert mTokenFetcher == null;
307 mTokenFetcher = createTokenFetcher(host);
308 connector.connectToHost(mAccount.name, mToken, host);
311 private void refreshHostList() {
312 if (mWaitingForAuthToken) {
316 mTriedNewAuthToken = false;
317 setHostListProgressVisible(true);
319 // The refresh button simply makes use of the currently-chosen account.
323 private void requestAuthToken() {
324 AccountManager.get(this).getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null);
325 mWaitingForAuthToken = true;
329 public void run(AccountManagerFuture<Bundle> future) {
330 Log.i("auth", "User finished with auth dialogs");
331 mWaitingForAuthToken = false;
333 Bundle result = null;
334 String explanation = null;
336 // Here comes our auth token from the Android system.
337 result = future.getResult();
338 } catch (OperationCanceledException ex) {
339 // User canceled authentication. No need to report an error.
340 } catch (AuthenticatorException ex) {
341 explanation = getString(R.string.error_unexpected);
342 } catch (IOException ex) {
343 explanation = getString(R.string.error_network_error);
346 if (result == null) {
347 setHostListProgressVisible(false);
348 if (explanation != null) {
349 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
354 mToken = result.getString(AccountManager.KEY_AUTHTOKEN);
355 Log.i("auth", "Received an auth token from system");
357 mHostListLoader.retrieveHostList(mToken, this);
361 public void onItemSelected(AdapterView parent, View view, int itemPosition, long itemId) {
362 mAccount = mAccounts[itemPosition];
364 getPreferences(MODE_PRIVATE).edit().putString("account_name", mAccount.name)
365 .putString("account_type", mAccount.type).apply();
367 // The current host list is no longer valid for the new account, so clear the list.
368 mHosts = new HostInfo[0];
374 public void onNothingSelected(AdapterView parent) {
378 public void onHostListReceived(HostInfo[] hosts) {
379 // Store a copy of the array, so that it can't be mutated by the HostListLoader. HostInfo
380 // is an immutable type, so a shallow copy of the array is sufficient here.
381 mHosts = Arrays.copyOf(hosts, hosts.length);
382 setHostListProgressVisible(false);
387 public void onError(HostListLoader.Error error) {
388 String explanation = null;
393 explanation = getString(R.string.error_network_error);
395 case UNEXPECTED_RESPONSE:
396 case SERVICE_UNAVAILABLE:
398 explanation = getString(R.string.error_unexpected);
405 if (explanation != null) {
406 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
407 setHostListProgressVisible(false);
411 // This is the AUTH_FAILED case.
413 if (!mTriedNewAuthToken) {
414 // This was our first connection attempt.
416 AccountManager authenticator = AccountManager.get(this);
417 mTriedNewAuthToken = true;
419 Log.w("auth", "Requesting renewal of rejected auth token");
420 authenticator.invalidateAuthToken(mAccount.type, mToken);
424 // We're not in an error state *yet*.
427 // Authentication truly failed.
428 Log.e("auth", "Fresh auth token was also rejected");
429 explanation = getString(R.string.error_authentication_failed);
430 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
431 setHostListProgressVisible(false);
436 * Updates the infotext and host list display.
438 private void updateUi() {
439 if (mRefreshButton != null) {
440 mRefreshButton.setEnabled(mAccount != null);
442 ArrayAdapter<HostInfo> displayer = new HostListAdapter(this, R.layout.host, mHosts);
443 Log.i("hostlist", "About to populate host list display");
444 mHostListView.setAdapter(displayer);
448 public void onConnectionState(JniInterface.ConnectionListener.State state,
449 JniInterface.ConnectionListener.Error error) {
450 boolean dismissProgress = false;
455 // The connection is still being established.
459 dismissProgress = true;
460 // Display the remote desktop.
461 startActivityForResult(new Intent(this, Desktop.class), 0);
465 dismissProgress = true;
466 Toast.makeText(this, getString(error.message()), Toast.LENGTH_LONG).show();
467 // Close the Desktop view, if it is currently running.
472 // No need to show toast in this case. Either the connection will have failed
473 // because of an error, which will trigger toast already. Or the disconnection will
474 // have been initiated by the user.
475 dismissProgress = true;
480 // Unreachable, but required by Google Java style and findbugs.
481 assert false : "Unreached";
484 if (dismissProgress && mProgressIndicator != null) {
485 mProgressIndicator.dismiss();
486 mProgressIndicator = null;
490 private ThirdPartyTokenFetcher createTokenFetcher(HostInfo host) {
491 ThirdPartyTokenFetcher.Callback callback = new ThirdPartyTokenFetcher.Callback() {
493 public void onTokenFetched(String code, String accessToken) {
494 // The native client sends the OAuth authorization code to the host as the token so
495 // that the host can obtain the shared secret from the third party authorization
499 // The native client uses the OAuth access token as the shared secret to
500 // authenticate itself with the host using spake.
501 String sharedSecret = accessToken;
503 JniInterface.onThirdPartyTokenFetched(token, sharedSecret);
506 return new ThirdPartyTokenFetcher(this, host.getTokenUrlPatterns(), callback);
509 public void fetchThirdPartyToken(String tokenUrl, String clientId, String scope) {
510 assert mTokenFetcher != null;
511 mTokenFetcher.fetchToken(tokenUrl, clientId, scope);