blob: 1928f3c715913b6d67598c02f438856a6d67a039 [file] [log] [blame]
// Copyright 2013 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chromoting;
import android.accounts.Account;
import android.accounts.AccountManager;
import android.accounts.AccountManagerCallback;
import android.accounts.AccountManagerFuture;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.annotation.SuppressLint;
import android.app.ActionBar;
import android.app.Activity;
import android.app.AlertDialog;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.os.Bundle;
import android.provider.Settings;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.ListView;
import android.widget.Toast;
import org.chromium.chromoting.jni.JniInterface;
import java.io.IOException;
import java.util.Arrays;
/**
* The user interface for querying and displaying a user's host list from the directory server. It
* also requests and renews authentication tokens using the system account manager.
*/
public class Chromoting extends Activity implements JniInterface.ConnectionListener,
AccountManagerCallback<Bundle>, ActionBar.OnNavigationListener, HostListLoader.Callback,
View.OnClickListener {
/** Only accounts of this type will be selectable for authentication. */
private static final String ACCOUNT_TYPE = "com.google";
/** Scopes at which the authentication token we request will be valid. */
private static final String TOKEN_SCOPE = "oauth2:https://www.googleapis.com/auth/chromoting " +
"https://www.googleapis.com/auth/googletalk";
/** Web page to be displayed in the Help screen when launched from this activity. */
private static final String HELP_URL =
"http://support.google.com/chrome/?p=mobile_crd_hostslist";
/** Web page to be displayed when user triggers the hyperlink for setting up hosts. */
private static final String HOST_SETUP_URL =
"https://support.google.com/chrome/answer/1649523";
/** User's account details. */
private Account mAccount;
/** List of accounts on the system. */
private Account[] mAccounts;
/** SpinnerAdapter used in the action bar for selecting accounts. */
private AccountsAdapter mAccountsAdapter;
/** Account auth token. */
private String mToken;
/** Helper for fetching the host list. */
private HostListLoader mHostListLoader;
/** List of hosts. */
private HostInfo[] mHosts = new HostInfo[0];
/** Refresh button. */
private MenuItem mRefreshButton;
/** Host list as it appears to the user. */
private ListView mHostListView;
/** Progress view shown instead of the host list when the host list is loading. */
private View mProgressView;
/** Dialog for reporting connection progress. */
private ProgressDialog mProgressIndicator;
/** Object for fetching OAuth2 access tokens from third party authorization servers. */
private ThirdPartyTokenFetcher mTokenFetcher;
/**
* This is set when receiving an authentication error from the HostListLoader. If that occurs,
* this flag is set and a fresh authentication token is fetched from the AccountsService, and
* used to request the host list a second time.
*/
boolean mTriedNewAuthToken;
/** Shows a warning explaining that a Google account is required, then closes the activity. */
private void showNoAccountsDialog() {
AlertDialog.Builder builder = new AlertDialog.Builder(this);
builder.setMessage(R.string.noaccounts_message);
builder.setPositiveButton(R.string.noaccounts_add_account,
new DialogInterface.OnClickListener() {
@SuppressLint("InlinedApi")
@Override
public void onClick(DialogInterface dialog, int id) {
Intent intent = new Intent(Settings.ACTION_ADD_ACCOUNT);
intent.putExtra(Settings.EXTRA_ACCOUNT_TYPES,
new String[] { ACCOUNT_TYPE });
if (intent.resolveActivity(getPackageManager()) != null) {
startActivity(intent);
}
finish();
}
});
builder.setNegativeButton(R.string.close, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int id) {
finish();
}
});
builder.setOnCancelListener(new DialogInterface.OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
finish();
}
});
AlertDialog dialog = builder.create();
dialog.show();
}
/** Shows or hides the progress indicator for loading the host list. */
private void setHostListProgressVisible(boolean visible) {
mHostListView.setVisibility(visible ? View.GONE : View.VISIBLE);
mProgressView.setVisibility(visible ? View.VISIBLE : View.GONE);
// Hiding the host-list does not automatically hide the empty view, so do that here.
if (visible) {
mHostListView.getEmptyView().setVisibility(View.GONE);
}
}
/**
* Called when the activity is first created. Loads the native library and requests an
* authentication token from the system.
*/
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.main);
mTriedNewAuthToken = false;
mHostListLoader = new HostListLoader();
// Get ahold of our view widgets.
mHostListView = (ListView)findViewById(R.id.hostList_chooser);
mHostListView.setEmptyView(findViewById(R.id.hostList_empty));
mProgressView = findViewById(R.id.hostList_progress);
findViewById(R.id.host_setup_link_android).setOnClickListener(this);
// Bring native components online.
JniInterface.loadLibrary(this);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
if (mTokenFetcher != null) {
if (mTokenFetcher.handleTokenFetched(intent)) {
mTokenFetcher = null;
}
}
}
/**
* Called when the activity becomes visible. This happens on initial launch and whenever the
* user switches to the activity, for example, by using the window-switcher or when coming from
* the device's lock screen.
*/
@Override
public void onStart() {
super.onStart();
mAccounts = AccountManager.get(this).getAccountsByType(ACCOUNT_TYPE);
if (mAccounts.length == 0) {
showNoAccountsDialog();
return;
}
SharedPreferences prefs = getPreferences(MODE_PRIVATE);
int index = -1;
if (prefs.contains("account_name") && prefs.contains("account_type")) {
mAccount = new Account(prefs.getString("account_name", null),
prefs.getString("account_type", null));
index = Arrays.asList(mAccounts).indexOf(mAccount);
}
if (index == -1) {
// Preference not loaded, or does not correspond to a valid account, so just pick the
// first account arbitrarily.
index = 0;
mAccount = mAccounts[0];
}
if (mAccounts.length == 1) {
getActionBar().setDisplayShowTitleEnabled(true);
getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
getActionBar().setTitle(R.string.mode_me2me);
getActionBar().setSubtitle(mAccount.name);
} else {
mAccountsAdapter = new AccountsAdapter(this, mAccounts);
getActionBar().setDisplayShowTitleEnabled(false);
getActionBar().setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
getActionBar().setListNavigationCallbacks(mAccountsAdapter, this);
getActionBar().setSelectedNavigationItem(index);
}
refreshHostList();
}
/** Called when the activity is finally finished. */
@Override
public void onDestroy() {
super.onDestroy();
JniInterface.disconnectFromHost();
}
/** Called when the display is rotated (as registered in the manifest). */
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// Reload the spinner resources, since the font sizes are dependent on the screen
// orientation.
if (mAccounts.length != 1) {
mAccountsAdapter.notifyDataSetChanged();
}
}
/** Called to initialize the action bar. */
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.chromoting_actionbar, menu);
mRefreshButton = menu.findItem(R.id.actionbar_directoryrefresh);
if (mAccount == null) {
// If there is no account, don't allow the user to refresh the listing.
mRefreshButton.setEnabled(false);
}
return super.onCreateOptionsMenu(menu);
}
/** Called whenever an action bar button is pressed. */
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
if (id == R.id.actionbar_directoryrefresh) {
refreshHostList();
return true;
}
if (id == R.id.actionbar_help) {
HelpActivity.launch(this, HELP_URL);
return true;
}
return super.onOptionsItemSelected(item);
}
/** Called when the user touches hyperlinked text. */
@Override
public void onClick(View view) {
HelpActivity.launch(this, HOST_SETUP_URL);
}
/** Called when the user taps on a host entry. */
public void connectToHost(HostInfo host) {
mProgressIndicator = ProgressDialog.show(this,
host.name, getString(R.string.footer_connecting), true, true,
new DialogInterface.OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
JniInterface.disconnectFromHost();
mTokenFetcher = null;
}
});
SessionConnector connector = new SessionConnector(this, this, mHostListLoader);
assert mTokenFetcher == null;
mTokenFetcher = createTokenFetcher(host);
connector.connectToHost(mAccount.name, mToken, host);
}
private void refreshHostList() {
mTriedNewAuthToken = false;
setHostListProgressVisible(true);
// The refresh button simply makes use of the currently-chosen account.
AccountManager.get(this).getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null);
}
@Override
public void run(AccountManagerFuture<Bundle> future) {
Log.i("auth", "User finished with auth dialogs");
Bundle result = null;
String explanation = null;
try {
// Here comes our auth token from the Android system.
result = future.getResult();
String authToken = result.getString(AccountManager.KEY_AUTHTOKEN);
Log.i("auth", "Received an auth token from system");
mToken = authToken;
mHostListLoader.retrieveHostList(authToken, this);
} catch (OperationCanceledException ex) {
// User canceled authentication. No need to report an error.
} catch (AuthenticatorException ex) {
explanation = getString(R.string.error_unexpected);
} catch (IOException ex) {
explanation = getString(R.string.error_network_error);
}
if (result == null) {
if (explanation != null) {
Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
}
return;
}
String authToken = result.getString(AccountManager.KEY_AUTHTOKEN);
Log.i("auth", "Received an auth token from system");
mToken = authToken;
mHostListLoader.retrieveHostList(authToken, this);
}
@Override
public boolean onNavigationItemSelected(int itemPosition, long itemId) {
mAccount = mAccounts[itemPosition];
getPreferences(MODE_PRIVATE).edit().putString("account_name", mAccount.name).
putString("account_type", mAccount.type).apply();
// The current host list is no longer valid for the new account, so clear the list.
mHosts = new HostInfo[0];
updateUi();
refreshHostList();
return true;
}
@Override
public void onHostListReceived(HostInfo[] hosts) {
// Store a copy of the array, so that it can't be mutated by the HostListLoader. HostInfo
// is an immutable type, so a shallow copy of the array is sufficient here.
mHosts = Arrays.copyOf(hosts, hosts.length);
setHostListProgressVisible(false);
updateUi();
}
@Override
public void onError(HostListLoader.Error error) {
String explanation = null;
switch (error) {
case AUTH_FAILED:
break;
case NETWORK_ERROR:
explanation = getString(R.string.error_network_error);
break;
case UNEXPECTED_RESPONSE:
case SERVICE_UNAVAILABLE:
case UNKNOWN:
explanation = getString(R.string.error_unexpected);
break;
default:
// Unreachable.
return;
}
if (explanation != null) {
Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
setHostListProgressVisible(false);
return;
}
// This is the AUTH_FAILED case.
if (!mTriedNewAuthToken) {
// This was our first connection attempt.
AccountManager authenticator = AccountManager.get(this);
mTriedNewAuthToken = true;
Log.w("auth", "Requesting renewal of rejected auth token");
authenticator.invalidateAuthToken(mAccount.type, mToken);
mToken = null;
authenticator.getAuthToken(mAccount, TOKEN_SCOPE, null, this, this, null);
// We're not in an error state *yet*.
return;
} else {
// Authentication truly failed.
Log.e("auth", "Fresh auth token was also rejected");
explanation = getString(R.string.error_authentication_failed);
Toast.makeText(this, explanation, Toast.LENGTH_LONG).show();
setHostListProgressVisible(false);
}
}
/**
* Updates the infotext and host list display.
*/
private void updateUi() {
if (mRefreshButton != null) {
mRefreshButton.setEnabled(mAccount != null);
}
ArrayAdapter<HostInfo> displayer = new HostListAdapter(this, R.layout.host, mHosts);
Log.i("hostlist", "About to populate host list display");
mHostListView.setAdapter(displayer);
}
@Override
public void onConnectionState(JniInterface.ConnectionListener.State state,
JniInterface.ConnectionListener.Error error) {
boolean dismissProgress = false;
switch (state) {
case INITIALIZING:
case CONNECTING:
case AUTHENTICATED:
// The connection is still being established.
break;
case CONNECTED:
dismissProgress = true;
// Display the remote desktop.
startActivityForResult(new Intent(this, Desktop.class), 0);
break;
case FAILED:
dismissProgress = true;
Toast.makeText(this, getString(error.message()), Toast.LENGTH_LONG).show();
// Close the Desktop view, if it is currently running.
finishActivity(0);
break;
case CLOSED:
// No need to show toast in this case. Either the connection will have failed
// because of an error, which will trigger toast already. Or the disconnection will
// have been initiated by the user.
dismissProgress = true;
finishActivity(0);
break;
default:
// Unreachable, but required by Google Java style and findbugs.
assert false : "Unreached";
}
if (dismissProgress && mProgressIndicator != null) {
mProgressIndicator.dismiss();
mProgressIndicator = null;
}
}
private ThirdPartyTokenFetcher createTokenFetcher(HostInfo host) {
ThirdPartyTokenFetcher.Callback callback = new ThirdPartyTokenFetcher.Callback() {
public void onTokenFetched(String code, String accessToken) {
// The native client sends the OAuth authorization code to the host as the token so
// that the host can obtain the shared secret from the third party authorization
// server.
String token = code;
// The native client uses the OAuth access token as the shared secret to
// authenticate itself with the host using spake.
String sharedSecret = accessToken;
JniInterface.nativeOnThirdPartyTokenFetched(token, sharedSecret);
}
};
return new ThirdPartyTokenFetcher(this, host.getTokenUrlPatterns(), callback);
}
public void fetchThirdPartyToken(String tokenUrl, String clientId, String scope) {
assert mTokenFetcher != null;
mTokenFetcher.fetchToken(tokenUrl, clientId, scope);
}
}