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
.ArrayAdapter
;
29 import android
.widget
.ListView
;
30 import android
.widget
.Toast
;
32 import org
.chromium
.chromoting
.jni
.JniInterface
;
34 import java
.io
.IOException
;
35 import java
.util
.Arrays
;
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.
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";
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";
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";
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";
59 /** User's account details. */
60 private Account mAccount
;
62 /** List of accounts on the system. */
63 private Account
[] mAccounts
;
65 /** SpinnerAdapter used in the action bar for selecting accounts. */
66 private AccountsAdapter mAccountsAdapter
;
68 /** Account auth token. */
69 private String mToken
;
71 /** Helper for fetching the host list. */
72 private HostListLoader mHostListLoader
;
75 private HostInfo
[] mHosts
= new HostInfo
[0];
77 /** Refresh button. */
78 private MenuItem mRefreshButton
;
80 /** Host list as it appears to the user. */
81 private ListView mHostListView
;
83 /** Progress view shown instead of the host list when the host list is loading. */
84 private View mProgressView
;
86 /** Dialog for reporting connection progress. */
87 private ProgressDialog mProgressIndicator
;
89 /** Object for fetching OAuth2 access tokens from third party authorization servers. */
90 private ThirdPartyTokenFetcher mTokenFetcher
;
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.
97 boolean mTriedNewAuthToken
;
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")
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
);
117 builder
.setNegativeButton(R
.string
.close
, new DialogInterface
.OnClickListener() {
119 public void onClick(DialogInterface dialog
, int id
) {
123 builder
.setOnCancelListener(new DialogInterface
.OnCancelListener() {
125 public void onCancel(DialogInterface dialog
) {
130 AlertDialog dialog
= builder
.create();
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
);
139 // Hiding the host-list does not automatically hide the empty view, so do that here.
141 mHostListView
.getEmptyView().setVisibility(View
.GONE
);
146 * Called when the activity is first created. Loads the native library and requests an
147 * authentication token from the system.
150 public void onCreate(Bundle savedInstanceState
) {
151 super.onCreate(savedInstanceState
);
152 setContentView(R
.layout
.main
);
154 mTriedNewAuthToken
= false;
155 mHostListLoader
= new HostListLoader();
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
);
162 findViewById(R
.id
.host_setup_link_android
).setOnClickListener(this);
164 // Bring native components online.
165 JniInterface
.loadLibrary(this);
169 protected void onNewIntent(Intent intent
) {
170 super.onNewIntent(intent
);
171 if (mTokenFetcher
!= null) {
172 if (mTokenFetcher
.handleTokenFetched(intent
)) {
173 mTokenFetcher
= null;
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.
183 public void onStart() {
186 mAccounts
= AccountManager
.get(this).getAccountsByType(ACCOUNT_TYPE
);
187 if (mAccounts
.length
== 0) {
188 showNoAccountsDialog();
192 SharedPreferences prefs
= getPreferences(MODE_PRIVATE
);
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
);
200 // Preference not loaded, or does not correspond to a valid account, so just pick the
201 // first account arbitrarily.
203 mAccount
= mAccounts
[0];
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
);
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
);
222 /** Called when the activity is finally finished. */
224 public void onDestroy() {
226 JniInterface
.disconnectFromHost();
229 /** Called when the display is rotated (as registered in the manifest). */
231 public void onConfigurationChanged(Configuration newConfig
) {
232 super.onConfigurationChanged(newConfig
);
234 // Reload the spinner resources, since the font sizes are dependent on the screen
236 if (mAccounts
.length
!= 1) {
237 mAccountsAdapter
.notifyDataSetChanged();
241 /** Called to initialize the action bar. */
243 public boolean onCreateOptionsMenu(Menu menu
) {
244 getMenuInflater().inflate(R
.menu
.chromoting_actionbar
, menu
);
245 mRefreshButton
= menu
.findItem(R
.id
.actionbar_directoryrefresh
);
247 if (mAccount
== null) {
248 // If there is no account, don't allow the user to refresh the listing.
249 mRefreshButton
.setEnabled(false);
252 return super.onCreateOptionsMenu(menu
);
255 /** Called whenever an action bar button is pressed. */
257 public boolean onOptionsItemSelected(MenuItem item
) {
258 int id
= item
.getItemId();
259 if (id
== R
.id
.actionbar_directoryrefresh
) {
263 if (id
== R
.id
.actionbar_help
) {
264 HelpActivity
.launch(this, HELP_URL
);
267 return super.onOptionsItemSelected(item
);
270 /** Called when the user touches hyperlinked text. */
272 public void onClick(View view
) {
273 HelpActivity
.launch(this, HOST_SETUP_URL
);
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() {
282 public void onCancel(DialogInterface dialog
) {
283 JniInterface
.disconnectFromHost();
284 mTokenFetcher
= null;
287 SessionConnector connector
= new SessionConnector(this, this, mHostListLoader
);
288 assert mTokenFetcher
== null;
289 mTokenFetcher
= createTokenFetcher(host
);
290 connector
.connectToHost(mAccount
.name
, mToken
, host
);
293 private void refreshHostList() {
294 mTriedNewAuthToken
= false;
295 setHostListProgressVisible(true);
297 // The refresh button simply makes use of the currently-chosen account.
298 AccountManager
.get(this).getAuthToken(mAccount
, TOKEN_SCOPE
, null, this, this, null);
302 public void run(AccountManagerFuture
<Bundle
> future
) {
303 Log
.i("auth", "User finished with auth dialogs");
304 Bundle result
= null;
305 String explanation
= null;
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");
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
);
323 if (result
== null) {
324 if (explanation
!= null) {
325 Toast
.makeText(this, explanation
, Toast
.LENGTH_LONG
).show();
330 String authToken
= result
.getString(AccountManager
.KEY_AUTHTOKEN
);
331 Log
.i("auth", "Received an auth token from system");
335 mHostListLoader
.retrieveHostList(authToken
, this);
339 public boolean onNavigationItemSelected(int itemPosition
, long itemId
) {
340 mAccount
= mAccounts
[itemPosition
];
342 getPreferences(MODE_PRIVATE
).edit().putString("account_name", mAccount
.name
).
343 putString("account_type", mAccount
.type
).apply();
345 // The current host list is no longer valid for the new account, so clear the list.
346 mHosts
= new HostInfo
[0];
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);
362 public void onError(HostListLoader
.Error error
) {
363 String explanation
= null;
368 explanation
= getString(R
.string
.error_network_error
);
370 case UNEXPECTED_RESPONSE
:
371 case SERVICE_UNAVAILABLE
:
373 explanation
= getString(R
.string
.error_unexpected
);
380 if (explanation
!= null) {
381 Toast
.makeText(this, explanation
, Toast
.LENGTH_LONG
).show();
382 setHostListProgressVisible(false);
386 // This is the AUTH_FAILED case.
388 if (!mTriedNewAuthToken
) {
389 // This was our first connection attempt.
391 AccountManager authenticator
= AccountManager
.get(this);
392 mTriedNewAuthToken
= true;
394 Log
.w("auth", "Requesting renewal of rejected auth token");
395 authenticator
.invalidateAuthToken(mAccount
.type
, mToken
);
397 authenticator
.getAuthToken(mAccount
, TOKEN_SCOPE
, null, this, this, null);
399 // We're not in an error state *yet*.
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);
411 * Updates the infotext and host list display.
413 private void updateUi() {
414 if (mRefreshButton
!= null) {
415 mRefreshButton
.setEnabled(mAccount
!= null);
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
);
423 public void onConnectionState(JniInterface
.ConnectionListener
.State state
,
424 JniInterface
.ConnectionListener
.Error error
) {
425 boolean dismissProgress
= false;
430 // The connection is still being established.
434 dismissProgress
= true;
435 // Display the remote desktop.
436 startActivityForResult(new Intent(this, Desktop
.class), 0);
440 dismissProgress
= true;
441 Toast
.makeText(this, getString(error
.message()), Toast
.LENGTH_LONG
).show();
442 // Close the Desktop view, if it is currently running.
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;
455 // Unreachable, but required by Google Java style and findbugs.
456 assert false : "Unreached";
459 if (dismissProgress
&& mProgressIndicator
!= null) {
460 mProgressIndicator
.dismiss();
461 mProgressIndicator
= null;
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
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
;
477 JniInterface
.onThirdPartyTokenFetched(token
, sharedSecret
);
480 return new ThirdPartyTokenFetcher(this, host
.getTokenUrlPatterns(), callback
);
483 public void fetchThirdPartyToken(String tokenUrl
, String clientId
, String scope
) {
484 assert mTokenFetcher
!= null;
485 mTokenFetcher
.fetchToken(tokenUrl
, clientId
, scope
);