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.app.ActionBar;
14 import android.app.Activity;
15 import android.app.AlertDialog;
16 import android.app.ProgressDialog;
17 import android.content.DialogInterface;
18 import android.content.Intent;
19 import android.content.SharedPreferences;
20 import android.content.res.Configuration;
21 import android.os.Bundle;
22 import android.provider.Settings;
23 import android.util.Log;
24 import android.view.Menu;
25 import android.view.MenuItem;
26 import android.view.View;
27 import android.widget.ArrayAdapter;
28 import android.widget.ListView;
29 import android.widget.Toast;
31 import org.chromium.chromoting.jni.JniInterface;
33 import java.io.IOException;
34 import java.util.Arrays;
37 * The user interface for querying and displaying a user's host list from the directory server. It
38 * also requests and renews authentication tokens using the system account manager.
40 public class Chromoting extends Activity implements JniInterface.ConnectionListener,
41 AccountManagerCallback<Bundle>, ActionBar.OnNavigationListener, HostListLoader.Callback,
42 View.OnClickListener {
43 /** Only accounts of this type will be selectable for authentication. */
44 private static final String ACCOUNT_TYPE = "com.google";
46 /** Scopes at which the authentication token we request will be valid. */
47 private static final String TOKEN_SCOPE = "oauth2:https://www.googleapis.com/auth/chromoting " +
48 "https://www.googleapis.com/auth/googletalk";
50 /** Web page to be displayed in the Help screen when launched from this activity. */
51 private static final String HELP_URL =
52 "http://support.google.com/chrome/?p=mobile_crd_hostslist";
54 /** Web page to be displayed when user triggers the hyperlink for setting up hosts. */
55 private static final String HOST_SETUP_URL =
56 "https://support.google.com/chrome/answer/1649523";
58 /** User's account details. */
59 private Account mAccount;
61 /** List of accounts on the system. */
62 private Account[] mAccounts;
64 /** SpinnerAdapter used in the action bar for selecting accounts. */
65 private AccountsAdapter mAccountsAdapter;
67 /** Account auth token. */
68 private String mToken;
70 /** Helper for fetching the host list. */
71 private HostListLoader mHostListLoader;
74 private HostInfo[] mHosts = new HostInfo[0];
76 /** Refresh button. */
77 private MenuItem mRefreshButton;
79 /** Host list as it appears to the user. */
80 private ListView mHostListView;
82 /** Progress view shown instead of the host list when the host list is loading. */
83 private View mProgressView;
85 /** Dialog for reporting connection progress. */
86 private ProgressDialog mProgressIndicator;
89 * This is set when receiving an authentication error from the HostListLoader. If that occurs,
90 * this flag is set and a fresh authentication token is fetched from the AccountsService, and
91 * used to request the host list a second time.
93 boolean mTriedNewAuthToken;
95 /** Shows a warning explaining that a Google account is required, then closes the activity. */
96 private void showNoAccountsDialog() {
97 AlertDialog.Builder builder = new AlertDialog.Builder(this);
98 builder.setMessage(R.string.noaccounts_message);
99 builder.setPositiveButton(R.string.noaccounts_add_account,
100 new DialogInterface.OnClickListener() {
102 public void onClick(DialogInterface dialog, int id) {
103 Intent intent = new Intent(Settings.ACTION_ADD_ACCOUNT);
104 intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES,
105 new String[] { ACCOUNT_TYPE });
106 if (intent.resolveActivity(getPackageManager()) != null) {
107 startActivity(intent);
112 builder.setNegativeButton(R.string.close, new DialogInterface.OnClickListener() {
114 public void onClick(DialogInterface dialog, int id) {
118 builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
120 public void onCancel(DialogInterface dialog) {
125 AlertDialog dialog = builder.create();
129 /** Shows or hides the progress indicator for loading the host list. */
130 private void setHostListProgressVisible(boolean visible) {
131 mHostListView.setVisibility(visible ? View.GONE : View.VISIBLE);
132 mProgressView.setVisibility(visible ? View.VISIBLE : View.GONE);
134 // Hiding the host-list does not automatically hide the empty view, so do that here.
136 mHostListView.getEmptyView().setVisibility(View.GONE);
141 * Called when the activity is first created. Loads the native library and requests an
142 * authentication token from the system.
145 public void onCreate(Bundle savedInstanceState) {
146 super.onCreate(savedInstanceState);
147 setContentView(R.layout.main);
149 mTriedNewAuthToken = false;
150 mHostListLoader = new HostListLoader();
152 // Get ahold of our view widgets.
153 mHostListView = (ListView)findViewById(R.id.hostList_chooser);
154 mHostListView.setEmptyView(findViewById(R.id.hostList_empty));
155 mProgressView = findViewById(R.id.hostList_progress);
157 findViewById(R.id.host_setup_link_android).setOnClickListener(this);
159 // Bring native components online.
160 JniInterface.loadLibrary(this);
164 * Called when the activity becomes visible. This happens on initial launch and whenever the
165 * user switches to the activity, for example, by using the window-switcher or when coming from
166 * the device's lock screen.
169 public void onStart() {
172 mAccounts = AccountManager.get(this).getAccountsByType(ACCOUNT_TYPE);
173 if (mAccounts.length == 0) {
174 showNoAccountsDialog();
178 SharedPreferences prefs = getPreferences(MODE_PRIVATE);
180 if (prefs.contains("account_name") && prefs.contains("account_type")) {
181 mAccount = new Account(prefs.getString("account_name", null),
182 prefs.getString("account_type", null));
183 index = Arrays.asList(mAccounts).indexOf(mAccount);
186 // Preference not loaded, or does not correspond to a valid account, so just pick the
187 // first account arbitrarily.
189 mAccount = mAccounts[0];
192 if (mAccounts.length == 1) {
193 getActionBar().setDisplayShowTitleEnabled(true);
194 getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
195 getActionBar().setTitle(R.string.mode_me2me);
196 getActionBar().setSubtitle(mAccount.name);
198 mAccountsAdapter = new AccountsAdapter(this, mAccounts);
199 getActionBar().setDisplayShowTitleEnabled(false);
200 getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
201 getActionBar().setListNavigationCallbacks(mAccountsAdapter, this);
202 getActionBar().setSelectedNavigationItem(index);
208 /** Called when the activity is finally finished. */
210 public void onDestroy() {
212 JniInterface.disconnectFromHost();
215 /** Called when the display is rotated (as registered in the manifest). */
217 public void onConfigurationChanged(Configuration newConfig) {
218 super.onConfigurationChanged(newConfig);
220 // Reload the spinner resources, since the font sizes are dependent on the screen
222 if (mAccounts.length != 1) {
223 mAccountsAdapter.notifyDataSetChanged();
227 /** Called to initialize the action bar. */
229 public boolean onCreateOptionsMenu(Menu menu) {
230 getMenuInflater().inflate(R.menu.chromoting_actionbar, menu);
231 mRefreshButton = menu.findItem(R.id.actionbar_directoryrefresh);
233 if (mAccount == null) {
234 // If there is no account, don't allow the user to refresh the listing.
235 mRefreshButton.setEnabled(false);
238 return super.onCreateOptionsMenu(menu);
241 /** Called whenever an action bar button is pressed. */
243 public boolean onOptionsItemSelected(MenuItem item) {
244 int id = item.getItemId();
245 if (id == R.id.actionbar_directoryrefresh) {
249 if (id == R.id.actionbar_help) {
250 HelpActivity.launch(this, HELP_URL);
253 return super.onOptionsItemSelected(item);
256 /** Called when the user touches hyperlinked text. */
258 public void onClick(View view) {
259 HelpActivity.launch(this, HOST_SETUP_URL);
262 /** Called when the user taps on a host entry. */
263 public void connectToHost(HostInfo host) {
264 mProgressIndicator = ProgressDialog.show(this,
265 host.name, getString(R.string.footer_connecting), true, true,
266 new DialogInterface.OnCancelListener() {
268 public void onCancel(DialogInterface dialog) {
269 JniInterface.disconnectFromHost();
272 SessionConnector connector = new SessionConnector(this, this, mHostListLoader);
273 connector.connectToHost(mAccount.name, mToken, host);
276 private void refreshHostList() {
277 mTriedNewAuthToken = false;
278 setHostListProgressVisible(true);
280 // The refresh button simply makes use of the currently-chosen account.
281 AccountManager.get(this).getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null);
285 public void run(AccountManagerFuture<Bundle> future) {
286 Log.i("auth", "User finished with auth dialogs");
287 Bundle result = null;
288 String explanation = null;
290 // Here comes our auth token from the Android system.
291 result = future.getResult();
292 String authToken = result.getString(AccountManager.KEY_AUTHTOKEN);
293 Log.i("auth", "Received an auth token from system");
297 mHostListLoader.retrieveHostList(authToken, this);
298 } catch (OperationCanceledException ex) {
299 // User canceled authentication. No need to report an error.
300 } catch (AuthenticatorException ex) {
301 explanation = getString(R.string.error_unexpected);
302 } catch (IOException ex) {
303 explanation = getString(R.string.error_network_error);
306 if (result == null) {
307 if (explanation != null) {
308 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
313 String authToken = result.getString(AccountManager.KEY_AUTHTOKEN);
314 Log.i("auth", "Received an auth token from system");
318 mHostListLoader.retrieveHostList(authToken, this);
322 public boolean onNavigationItemSelected(int itemPosition, long itemId) {
323 mAccount = mAccounts[itemPosition];
325 getPreferences(MODE_PRIVATE).edit().putString("account_name", mAccount.name).
326 putString("account_type", mAccount.type).apply();
328 // The current host list is no longer valid for the new account, so clear the list.
329 mHosts = new HostInfo[0];
336 public void onHostListReceived(HostInfo[] hosts) {
337 // Store a copy of the array, so that it can't be mutated by the HostListLoader. HostInfo
338 // is an immutable type, so a shallow copy of the array is sufficient here.
339 mHosts = Arrays.copyOf(hosts, hosts.length);
340 setHostListProgressVisible(false);
345 public void onError(HostListLoader.Error error) {
346 String explanation = null;
351 explanation = getString(R.string.error_network_error);
353 case UNEXPECTED_RESPONSE:
354 case SERVICE_UNAVAILABLE:
356 explanation = getString(R.string.error_unexpected);
363 if (explanation != null) {
364 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
365 setHostListProgressVisible(false);
369 // This is the AUTH_FAILED case.
371 if (!mTriedNewAuthToken) {
372 // This was our first connection attempt.
374 AccountManager authenticator = AccountManager.get(this);
375 mTriedNewAuthToken = true;
377 Log.w("auth", "Requesting renewal of rejected auth token");
378 authenticator.invalidateAuthToken(mAccount.type, mToken);
380 authenticator.getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null);
382 // We're not in an error state *yet*.
385 // Authentication truly failed.
386 Log.e("auth", "Fresh auth token was also rejected");
387 explanation = getString(R.string.error_authentication_failed);
388 Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
389 setHostListProgressVisible(false);
394 * Updates the infotext and host list display.
396 private void updateUi() {
397 mRefreshButton.setEnabled(mAccount != null);
399 ArrayAdapter<HostInfo> displayer = new HostListAdapter(this, R.layout.host, mHosts);
400 Log.i("hostlist", "About to populate host list display");
401 mHostListView.setAdapter(displayer);
405 public void onConnectionState(JniInterface.ConnectionListener.State state,
406 JniInterface.ConnectionListener.Error error) {
407 boolean dismissProgress = false;
412 // The connection is still being established.
416 dismissProgress = true;
417 // Display the remote desktop.
418 startActivityForResult(new Intent(this, Desktop.class), 0);
422 dismissProgress = true;
423 Toast.makeText(this, getString(error.message()), Toast.LENGTH_LONG).show();
424 // Close the Desktop view, if it is currently running.
429 // No need to show toast in this case. Either the connection will have failed
430 // because of an error, which will trigger toast already. Or the disconnection will
431 // have been initiated by the user.
432 dismissProgress = true;
437 // Unreachable, but required by Google Java style and findbugs.
438 assert false : "Unreached";
441 if (dismissProgress && mProgressIndicator != null) {
442 mProgressIndicator.dismiss();
443 mProgressIndicator = null;