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.app.Activity;
14import android.content.Context;
15import android.content.Intent;
16import android.content.SharedPreferences;
17import android.os.Bundle;
18import android.os.Handler;
19import android.os.HandlerThread;
20import android.text.Html;
21import android.util.Log;
22import android.view.Menu;
23import android.view.MenuItem;
24import android.view.View;
25import android.view.ViewGroup;
26import android.widget.ArrayAdapter;
27import android.widget.TextView;
28import android.widget.ListView;
29import android.widget.Toast;
30
31import org.chromium.chromoting.jni.JniInterface;
32import org.json.JSONArray;
33import org.json.JSONException;
34import org.json.JSONObject;
35
36import java.io.IOException;
37import java.net.URL;
38import java.net.URLConnection;
39import java.util.Scanner;
40
41/**
42 * The user interface for querying and displaying a user's host list from the directory server. It
43 * also requests and renews authentication tokens using the system account manager.
44 */
45public class Chromoting extends Activity {
46    /** Only accounts of this type will be selectable for authentication. */
47    private static final String ACCOUNT_TYPE = "com.google";
48
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";
52
53    /** Path from which to download a user's host list JSON object. */
54    private static final String HOST_LIST_PATH =
55            "https://www.googleapis.com/chromoting/v1/@me/hosts?key=";
56
57    /** Color to use for hosts that are online. */
58    private static final String HOST_COLOR_ONLINE = "green";
59
60    /** Color to use for hosts that are offline. */
61    private static final String HOST_COLOR_OFFLINE = "red";
62
63    /** User's account details. */
64    private Account mAccount;
65
66    /** Account auth token. */
67    private String mToken;
68
69    /** List of hosts. */
70    private JSONArray mHosts;
71
72    /** Refresh button. */
73    private MenuItem mRefreshButton;
74
75    /** Account switcher. */
76    private MenuItem mAccountSwitcher;
77
78    /** Greeting at the top of the displayed list. */
79    private TextView mGreeting;
80
81    /** Host list as it appears to the user. */
82    private ListView mList;
83
84    /** Callback handler to be used for network operations. */
85    private Handler mNetwork;
86
87    /**
88     * Called when the activity is first created. Loads the native library and requests an
89     * authentication token from the system.
90     */
91    @Override
92    public void onCreate(Bundle savedInstanceState) {
93        super.onCreate(savedInstanceState);
94        setContentView(R.layout.main);
95
96        // Get ahold of our view widgets.
97        mGreeting = (TextView)findViewById(R.id.hostList_greeting);
98        mList = (ListView)findViewById(R.id.hostList_chooser);
99
100        // Bring native components online.
101        JniInterface.loadLibrary(this);
102
103        // Thread responsible for downloading/displaying host list.
104        HandlerThread thread = new HandlerThread("auth_callback");
105        thread.start();
106        mNetwork = new Handler(thread.getLooper());
107
108        SharedPreferences prefs = getPreferences(MODE_PRIVATE);
109        if (prefs.contains("account_name") && prefs.contains("account_type")) {
110            // Perform authentication using saved account selection.
111            mAccount = new Account(prefs.getString("account_name", null),
112                    prefs.getString("account_type", null));
113            AccountManager.get(this).getAuthToken(mAccount, TOKEN_SCOPE, null, this,
114                    new HostListDirectoryGrabber(this), mNetwork);
115            if (mAccountSwitcher != null) {
116                mAccountSwitcher.setTitle(mAccount.name);
117            }
118        } else {
119            // Request auth callback once user has chosen an account.
120            Log.i("auth", "Requesting auth token from system");
121            AccountManager.get(this).getAuthTokenByFeatures(
122                    ACCOUNT_TYPE,
123                    TOKEN_SCOPE,
124                    null,
125                    this,
126                    null,
127                    null,
128                    new HostListDirectoryGrabber(this),
129                    mNetwork
130                );
131        }
132    }
133
134    /** Called when the activity is finally finished. */
135    @Override
136    public void onDestroy() {
137        super.onDestroy();
138        JniInterface.disconnectFromHost();
139    }
140
141    /** Called to initialize the action bar. */
142    @Override
143    public boolean onCreateOptionsMenu(Menu menu) {
144        getMenuInflater().inflate(R.menu.chromoting_actionbar, menu);
145        mRefreshButton = menu.findItem(R.id.actionbar_directoryrefresh);
146        mAccountSwitcher = menu.findItem(R.id.actionbar_accountswitcher);
147
148        Account[] usableAccounts = AccountManager.get(this).getAccountsByType(ACCOUNT_TYPE);
149        if (usableAccounts.length == 1 && usableAccounts[0].equals(mAccount)) {
150            // If we're using the only available account, don't offer account switching.
151            // (If there are *no* accounts available, clicking this allows you to add a new one.)
152            mAccountSwitcher.setEnabled(false);
153        }
154
155        if (mAccount == null) {
156            // If no account has been chosen, don't allow the user to refresh the listing.
157            mRefreshButton.setEnabled(false);
158        } else {
159            // If the user has picked an account, show its name directly on the account switcher.
160            mAccountSwitcher.setTitle(mAccount.name);
161        }
162
163        return super.onCreateOptionsMenu(menu);
164    }
165
166    /** Called whenever an action bar button is pressed. */
167    @Override
168    public boolean onOptionsItemSelected(MenuItem item) {
169        if (item == mAccountSwitcher) {
170            // The account switcher triggers a listing of all available accounts.
171            AccountManager.get(this).getAuthTokenByFeatures(
172                    ACCOUNT_TYPE,
173                    TOKEN_SCOPE,
174                    null,
175                    this,
176                    null,
177                    null,
178                    new HostListDirectoryGrabber(this),
179                    mNetwork
180                );
181        }
182        else {
183            // The refresh button simply makes use of the currently-chosen account.
184            AccountManager.get(this).getAuthToken(mAccount, TOKEN_SCOPE, null, this,
185                    new HostListDirectoryGrabber(this), mNetwork);
186        }
187
188        return true;
189    }
190
191    /**
192     * Processes the authentication token once the system provides it. Once in possession of such a
193     * token, attempts to request a host list from the directory server. In case of a bad response,
194     * this is retried once in case the system's cached auth token had expired.
195     */
196    private class HostListDirectoryGrabber implements AccountManagerCallback<Bundle> {
197        /** Whether authentication has already been attempted. */
198        private boolean mAlreadyTried;
199
200        /** Communication with the screen. */
201        private Activity mUi;
202
203        /** Constructor. */
204        public HostListDirectoryGrabber(Activity ui) {
205            mAlreadyTried = false;
206            mUi = ui;
207        }
208
209        /**
210         * Retrieves the host list from the directory server. This method performs
211         * network operations and must be run an a non-UI thread.
212         */
213        @Override
214        public void run(AccountManagerFuture<Bundle> future) {
215            Log.i("auth", "User finished with auth dialogs");
216            try {
217                // Here comes our auth token from the Android system.
218                Bundle result = future.getResult();
219                String accountName = result.getString(AccountManager.KEY_ACCOUNT_NAME);
220                String accountType = result.getString(AccountManager.KEY_ACCOUNT_TYPE);
221                String authToken = result.getString(AccountManager.KEY_AUTHTOKEN);
222                Log.i("auth", "Received an auth token from system");
223
224                synchronized (mUi) {
225                    mAccount = new Account(accountName, accountType);
226                    mToken = authToken;
227                    getPreferences(MODE_PRIVATE).edit().putString("account_name", accountName).
228                            putString("account_type", accountType).apply();
229                }
230
231                // Send our HTTP request to the directory server.
232                URLConnection link =
233                        new URL(HOST_LIST_PATH + JniInterface.getApiKey()).openConnection();
234                link.addRequestProperty("client_id", JniInterface.getClientId());
235                link.addRequestProperty("client_secret", JniInterface.getClientSecret());
236                link.setRequestProperty("Authorization", "OAuth " + authToken);
237
238                // Listen for the server to respond.
239                StringBuilder response = new StringBuilder();
240                Scanner incoming = new Scanner(link.getInputStream());
241                Log.i("auth", "Successfully authenticated to directory server");
242                while (incoming.hasNext()) {
243                    response.append(incoming.nextLine());
244                }
245                incoming.close();
246
247                // Interpret what the directory server told us.
248                JSONObject data = new JSONObject(String.valueOf(response)).getJSONObject("data");
249                mHosts = data.getJSONArray("items");
250                Log.i("hostlist", "Received host listing from directory server");
251            } catch (RuntimeException ex) {
252                // Make sure any other failure is reported to the user (as an unknown error).
253                throw ex;
254            } catch (Exception ex) {
255                // Assemble error message to display to the user.
256                String explanation = getString(R.string.error_unknown);
257                if (ex instanceof OperationCanceledException) {
258                    explanation = getString(R.string.error_auth_canceled);
259                } else if (ex instanceof AuthenticatorException) {
260                    explanation = getString(R.string.error_no_accounts);
261                } else if (ex instanceof IOException) {
262                    if (!mAlreadyTried) {
263                        // This was our first connection attempt.
264
265                        synchronized (mUi) {
266                            if (mAccount != null) {
267                                // We got an account, but couldn't log into it. We'll retry in case
268                                // the system's cached authentication token had already expired.
269                                AccountManager authenticator = AccountManager.get(mUi);
270                                mAlreadyTried = true;
271
272                                Log.w("auth", "Requesting renewal of rejected auth token");
273                                authenticator.invalidateAuthToken(mAccount.type, mToken);
274                                mToken = null;
275                                authenticator.getAuthToken(
276                                        mAccount, TOKEN_SCOPE, null, mUi, this, mNetwork);
277
278                                // We're not in an error state *yet*.
279                                return;
280                            }
281                        }
282
283                        // We didn't even get an account, so the auth server is likely unreachable.
284                        explanation = getString(R.string.error_bad_connection);
285                    } else {
286                        // Authentication truly failed.
287                        Log.e("auth", "Fresh auth token was also rejected");
288                        explanation = getString(R.string.error_auth_failed);
289                    }
290                } else if (ex instanceof JSONException) {
291                    explanation = getString(R.string.error_unexpected_response);
292                    runOnUiThread(new HostListDisplayer(mUi));
293                }
294
295                mHosts = null;
296                Log.w("auth", ex);
297                Toast.makeText(mUi, explanation, Toast.LENGTH_LONG).show();
298            }
299
300            // Share our findings with the user.
301            runOnUiThread(new HostListDisplayer(mUi));
302        }
303    }
304
305    /** Formats the host list and offers it to the user. */
306    private class HostListDisplayer implements Runnable {
307        /** Communication with the screen. */
308        private Activity mUi;
309
310        /** Constructor. */
311        public HostListDisplayer(Activity ui) {
312            mUi = ui;
313        }
314
315        /**
316         * Updates the infotext and host list display.
317         * This method affects the UI and must be run on its same thread.
318         */
319        @Override
320        public void run() {
321            synchronized (mUi) {
322                mRefreshButton.setEnabled(mAccount != null);
323                if (mAccount != null) {
324                    mAccountSwitcher.setTitle(mAccount.name);
325                }
326            }
327
328            if (mHosts == null) {
329                mGreeting.setText(getString(R.string.inst_empty_list));
330                mList.setAdapter(null);
331                return;
332            }
333
334            mGreeting.setText(getString(R.string.inst_host_list));
335
336            ArrayAdapter<JSONObject> displayer = new HostListAdapter(mUi, R.layout.host);
337            Log.i("hostlist", "About to populate host list display");
338            try {
339                int index = 0;
340                while (!mHosts.isNull(index)) {
341                    displayer.add(mHosts.getJSONObject(index));
342                    ++index;
343                }
344                mList.setAdapter(displayer);
345            }
346            catch(JSONException ex) {
347                Log.w("hostlist", ex);
348                Toast.makeText(
349                        mUi, getString(R.string.error_cataloging_hosts), Toast.LENGTH_LONG).show();
350
351                // Close the application.
352                finish();
353            }
354        }
355    }
356
357    /** Describes the appearance and behavior of each host list entry. */
358    private class HostListAdapter extends ArrayAdapter<JSONObject> {
359        /** Constructor. */
360        public HostListAdapter(Context context, int textViewResourceId) {
361            super(context, textViewResourceId);
362        }
363
364        /** Generates a View corresponding to this particular host. */
365        @Override
366        public View getView(int position, View convertView, ViewGroup parent) {
367            TextView target = (TextView)super.getView(position, convertView, parent);
368
369            try {
370                final JSONObject host = getItem(position);
371                target.setText(Html.fromHtml(host.getString("hostName") + " (<font color = \"" +
372                        (host.getString("status").equals("ONLINE") ? HOST_COLOR_ONLINE :
373                        HOST_COLOR_OFFLINE) + "\">" + host.getString("status") + "</font>)"));
374
375                if (host.getString("status").equals("ONLINE")) {  // Host is online.
376                    target.setOnClickListener(new View.OnClickListener() {
377                            @Override
378                            public void onClick(View v) {
379                                try {
380                                    synchronized (getContext()) {
381                                        JniInterface.connectToHost(mAccount.name, mToken,
382                                                host.getString("jabberId"),
383                                                host.getString("hostId"),
384                                                host.getString("publicKey"),
385                                                new Runnable() {
386                                            @Override
387                                            public void run() {
388                                                startActivity(
389                                                        new Intent(getContext(), Desktop.class));
390                                            }
391                                        });
392                                    }
393                                }
394                                catch(JSONException ex) {
395                                    Log.w("host", ex);
396                                    Toast.makeText(getContext(),
397                                            getString(R.string.error_reading_host),
398                                            Toast.LENGTH_LONG).show();
399
400                                    // Close the application.
401                                    finish();
402                                }
403                            }
404                        });
405                } else {  // Host is offline.
406                    // Disallow interaction with this entry.
407                    target.setEnabled(false);
408                }
409            }
410            catch(JSONException ex) {
411                Log.w("hostlist", ex);
412                Toast.makeText(getContext(),
413                        getString(R.string.error_displaying_host),
414                        Toast.LENGTH_LONG).show();
415
416                // Close the application.
417                finish();
418            }
419
420            return target;
421        }
422    }
423}
424