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 } 424