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