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 | |
5 | package org.chromium.chromoting; |
6 | |
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.Activity; |
14 | import android.content.Context; |
15 | import android.content.Intent; |
16 | import android.content.SharedPreferences; |
17 | import android.os.Bundle; |
18 | import android.os.Handler; |
19 | import android.os.HandlerThread; |
20 | import android.text.Html; |
21 | import android.util.Log; |
22 | import android.view.Menu; |
23 | import android.view.MenuItem; |
24 | import android.view.View; |
25 | import android.view.ViewGroup; |
26 | import android.widget.ArrayAdapter; |
27 | import android.widget.TextView; |
28 | import android.widget.ListView; |
29 | import android.widget.Toast; |
30 | |
31 | import org.chromium.chromoting.jni.JniInterface; |
32 | import org.json.JSONArray; |
33 | import org.json.JSONException; |
34 | import org.json.JSONObject; |
35 | |
36 | import java.io.IOException; |
37 | import java.net.URL; |
38 | import java.net.URLConnection; |
39 | import 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 | */ |
45 | public 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 | } |