1 /* 2 * Copyright (C) 2007 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.sdkstats; 18 19 import com.android.prefs.AndroidLocation; 20 import com.android.prefs.AndroidLocation.AndroidLocationException; 21 22 import org.eclipse.jface.preference.PreferenceStore; 23 import org.eclipse.swt.SWT; 24 import org.eclipse.swt.events.SelectionAdapter; 25 import org.eclipse.swt.events.SelectionEvent; 26 import org.eclipse.swt.graphics.Color; 27 import org.eclipse.swt.graphics.Font; 28 import org.eclipse.swt.graphics.FontData; 29 import org.eclipse.swt.graphics.Point; 30 import org.eclipse.swt.graphics.Rectangle; 31 import org.eclipse.swt.layout.GridData; 32 import org.eclipse.swt.layout.GridLayout; 33 import org.eclipse.swt.program.Program; 34 import org.eclipse.swt.widgets.Button; 35 import org.eclipse.swt.widgets.Display; 36 import org.eclipse.swt.widgets.Label; 37 import org.eclipse.swt.widgets.Link; 38 import org.eclipse.swt.widgets.Shell; 39 40 import java.io.File; 41 import java.io.FileOutputStream; 42 import java.io.IOException; 43 import java.net.HttpURLConnection; 44 import java.net.URL; 45 import java.net.URLEncoder; 46 import java.util.Random; 47 import java.util.regex.Matcher; 48 import java.util.regex.Pattern; 49 50 /** Utility class to send "ping" usage reports to the server. */ 51 public class SdkStatsService { 52 53 /** Minimum interval between ping, in milliseconds. */ 54 private static final long PING_INTERVAL_MSEC = 86400 * 1000; // 1 day 55 56 /* Text strings displayed in the opt-out dialog. */ 57 private static final String WINDOW_TITLE_TEXT = 58 "Android SDK"; 59 60 private static final String HEADER_TEXT = 61 "Thanks for using the Android SDK!"; 62 63 private static final String NOTICE_TEXT = 64 "We know you just want to get started but please read this first."; 65 66 /** Used in the preference pane (PrefsDialog) as well. */ 67 public static final String BODY_TEXT = 68 "By choosing to send certain usage statistics to Google, you can " + 69 "help us improve the Android SDK. These usage statistics let us " + 70 "measure things like active usage of the SDK and let us know things " + 71 "like which versions of the SDK are in use and which tools are the " + 72 "most popular with developers. This limited data is not associated " + 73 "with personal information about you, is examined on an aggregate " + 74 "basis, and is maintained in accordance with the " + 75 "<a href=\"http://www.google.com/intl/en/privacy.html\">Google " + 76 "Privacy Policy</a>."; 77 78 /** Used in the preference pane (PrefsDialog) as well. */ 79 public static final String CHECKBOX_TEXT = 80 "Send usage statistics to Google."; 81 82 private static final String FOOTER_TEXT = 83 "If you later decide to change this setting, you can do so in the " + 84 "\"ddms\" tool under \"File\" > \"Preferences\" > \"Usage Stats\"."; 85 86 private static final String BUTTON_TEXT = 87 " Proceed "; 88 89 /** List of Linux browser commands to try, in order (see openUrl). */ 90 private static final String[] LINUX_BROWSERS = new String[] { 91 "firefox -remote openurl(%URL%,new-window)", // $NON-NLS-1$ running FF 92 "mozilla -remote openurl(%URL%,new-window)", // $NON-NLS-1$ running Moz 93 "firefox %URL%", // $NON-NLS-1$ new FF 94 "mozilla %URL%", // $NON-NLS-1$ new Moz 95 "kfmclient openURL %URL%", // $NON-NLS-1$ Konqueror 96 "opera -newwindow %URL%", // $NON-NLS-1$ Opera 97 }; 98 99 public final static String PING_OPT_IN = "pingOptIn"; //$NON-NLS-1$ 100 public final static String PING_TIME = "pingTime"; //$NON-NLS-1$ 101 public final static String PING_ID = "pingId"; //$NON-NLS-1$ 102 103 104 private static PreferenceStore sPrefStore; 105 106 /** 107 * Send a "ping" to the Google toolbar server, if enough time has 108 * elapsed since the last ping, and if the user has not opted out. 109 * If this is the first time, notify the user and offer an opt-out. 110 * Note: UI operations (if any) are synchronous, but the actual ping 111 * (if any) is sent in a <i>non-daemon</i> background thread. 112 * 113 * @param app name to report in the ping 114 * @param version to report in the ping 115 * @param display an optional {@link Display} object to use, or null, if a new one should be 116 * created. 117 */ ping(final String app, final String version, final Display display)118 public static void ping(final String app, final String version, final Display display) { 119 // Unique, randomly assigned ID for this installation. 120 PreferenceStore prefs = getPreferenceStore(); 121 if (prefs != null) { 122 if (prefs.contains(PING_ID) == false) { 123 // First time: make up a new ID. TODO: Use something more random? 124 prefs.setValue(PING_ID, new Random().nextLong()); 125 126 // ask the user whether he/she wants to opt-out. 127 // This will call doPing in the Display thread after the dialog closes. 128 getUserPermissionAndPing(app, version, prefs, display); 129 } else { 130 doPing(app, version, prefs); 131 } 132 } 133 } 134 135 /** 136 * Returns the DDMS {@link PreferenceStore}. 137 */ getPreferenceStore()138 public static synchronized PreferenceStore getPreferenceStore() { 139 if (sPrefStore == null) { 140 // get the location of the preferences 141 String homeDir = null; 142 try { 143 homeDir = AndroidLocation.getFolder(); 144 } catch (AndroidLocationException e1) { 145 // pass, we'll do a dummy store since homeDir is null 146 } 147 148 if (homeDir != null) { 149 String rcFileName = homeDir + "ddms.cfg"; //$NON-NLS-1$ 150 151 // also look for an old pref file in the previous location 152 String oldPrefPath = System.getProperty("user.home") //$NON-NLS-1$ 153 + File.separator + ".ddmsrc"; //$NON-NLS-1$ 154 File oldPrefFile = new File(oldPrefPath); 155 if (oldPrefFile.isFile()) { 156 try { 157 PreferenceStore oldStore = new PreferenceStore(oldPrefPath); 158 oldStore.load(); 159 160 oldStore.save(new FileOutputStream(rcFileName), ""); 161 oldPrefFile.delete(); 162 163 PreferenceStore newStore = new PreferenceStore(rcFileName); 164 newStore.load(); 165 sPrefStore = newStore; 166 } catch (IOException e) { 167 // create a new empty store. 168 sPrefStore = new PreferenceStore(rcFileName); 169 } 170 } else { 171 sPrefStore = new PreferenceStore(rcFileName); 172 173 try { 174 sPrefStore.load(); 175 } catch (IOException e) { 176 System.err.println("Error Loading Preferences"); 177 } 178 } 179 } else { 180 sPrefStore = new PreferenceStore(); 181 } 182 } 183 184 return sPrefStore; 185 } 186 187 /** 188 * Pings the usage stats server, as long as the prefs contain the opt-in boolean 189 * @param app name to report in the ping 190 * @param version to report in the ping 191 * @param prefs the preference store where the opt-in value and ping times are store 192 */ doPing(final String app, String version, PreferenceStore prefs)193 private static void doPing(final String app, String version, PreferenceStore prefs) { 194 // Validate the application and version input. 195 final String normalVersion = normalizeVersion(app, version); 196 197 // If the user has not opted in, do nothing and quietly return. 198 if (!prefs.getBoolean(PING_OPT_IN)) { 199 // user opted out. 200 return; 201 } 202 203 // If the last ping *for this app* was too recent, do nothing. 204 String timePref = PING_TIME + "." + app; // $NON-NLS-1$ 205 long now = System.currentTimeMillis(); 206 long then = prefs.getLong(timePref); 207 if (now - then < PING_INTERVAL_MSEC) { 208 // too soon after a ping. 209 return; 210 } 211 212 // Record the time of the attempt, whether or not it succeeds. 213 prefs.setValue(timePref, now); 214 try { 215 prefs.save(); 216 } 217 catch (IOException ioe) { 218 } 219 220 // Send the ping itself in the background (don't block if the 221 // network is down or slow or confused). 222 final long id = prefs.getLong(PING_ID); 223 new Thread() { 224 @Override 225 public void run() { 226 try { 227 actuallySendPing(app, normalVersion, id); 228 } catch (IOException e) { 229 e.printStackTrace(); 230 } 231 } 232 }.start(); 233 } 234 235 236 /** 237 * Unconditionally send a "ping" request to the Google toolbar server. 238 * 239 * @param app name to report in the ping 240 * @param version to report in the ping (dotted numbers, no more than four) 241 * @param id of the local installation 242 * @throws IOException if the ping failed 243 */ 244 @SuppressWarnings("deprecation") actuallySendPing(String app, String version, long id)245 private static void actuallySendPing(String app, String version, long id) 246 throws IOException { 247 // Detect and report the host OS. 248 String os = System.getProperty("os.name"); // $NON-NLS-1$ 249 if (os.startsWith("Mac OS")) { // $NON-NLS-1$ 250 os = "mac"; // $NON-NLS-1$ 251 String osVers = getVersion(); 252 if (osVers != null) { 253 os = os + "-" + osVers; // $NON-NLS-1$ 254 } 255 } else if (os.startsWith("Windows")) { // $NON-NLS-1$ 256 os = "win"; // $NON-NLS-1$ 257 String osVers = getVersion(); 258 if (osVers != null) { 259 os = os + "-" + osVers; // $NON-NLS-1$ 260 } 261 } else if (os.startsWith("Linux")) { // $NON-NLS-1$ 262 os = "linux"; // $NON-NLS-1$ 263 } else { 264 // Unknown -- surprising -- send it verbatim so we can see it. 265 os = URLEncoder.encode(os); 266 } 267 268 // Include the application's name as part of the as= value. 269 // Share the user ID for all apps, to allow unified activity reports. 270 271 URL url = new URL( 272 "http", // $NON-NLS-1$ 273 "tools.google.com", // $NON-NLS-1$ 274 "/service/update?as=androidsdk_" + app + // $NON-NLS-1$ 275 "&id=" + Long.toHexString(id) + // $NON-NLS-1$ 276 "&version=" + version + // $NON-NLS-1$ 277 "&os=" + os); // $NON-NLS-1$ 278 279 // Discard the actual response, but make sure it reads OK 280 HttpURLConnection conn = (HttpURLConnection) url.openConnection(); 281 282 // Believe it or not, a 404 response indicates success: 283 // the ping was logged, but no update is configured. 284 if (conn.getResponseCode() != HttpURLConnection.HTTP_OK && 285 conn.getResponseCode() != HttpURLConnection.HTTP_NOT_FOUND) { 286 throw new IOException( 287 conn.getResponseMessage() + ": " + url); // $NON-NLS-1$ 288 } 289 } 290 291 /** 292 * Returns the version of the os if it is defined as X.Y, or null otherwise. 293 * <p/> 294 * Example of returned versions can be found at http://lopica.sourceforge.net/os.html 295 * <p/> 296 * This method removes any exiting micro versions. 297 */ getVersion()298 private static String getVersion() { 299 Pattern p = Pattern.compile("(\\d+)\\.(\\d+).*"); // $NON-NLS-1$ 300 String osVers = System.getProperty("os.version"); // $NON-NLS-1$ 301 Matcher m = p.matcher(osVers); 302 if (m.matches()) { 303 return m.group(1) + "." + m.group(2); // $NON-NLS-1$ 304 } 305 306 return null; 307 } 308 309 /** 310 * Prompt the user for whether they want to opt out of reporting, and then calls 311 * {@link #doPing(String, String, PreferenceStore)} 312 */ getUserPermissionAndPing(final String app, final String version, final PreferenceStore prefs, Display display)313 private static void getUserPermissionAndPing(final String app, final String version, 314 final PreferenceStore prefs, Display display) { 315 boolean dispose = false; 316 if (display == null) { 317 display = new Display(); 318 dispose = true; 319 } 320 321 final Display currentDisplay = display; 322 final boolean disposeDisplay = dispose; 323 324 display.asyncExec(new Runnable() { 325 public void run() { 326 // Whether the user gave permission (size-1 array for writing to). 327 // Initialize to false, set when the user clicks the button. 328 final boolean[] permission = new boolean[] { false }; 329 330 331 final Shell shell = new Shell(currentDisplay, SWT.TITLE | SWT.BORDER); 332 shell.setText(WINDOW_TITLE_TEXT); 333 shell.setLayout(new GridLayout(1, false)); // 1 column 334 335 // Take the default font and scale it up for the title. 336 final Label title = new Label(shell, SWT.CENTER | SWT.WRAP); 337 final FontData[] fontdata = title.getFont().getFontData(); 338 for (int i = 0; i < fontdata.length; i++) { 339 fontdata[i].setHeight(fontdata[i].getHeight() * 4 / 3); 340 } 341 title.setFont(new Font(currentDisplay, fontdata)); 342 title.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 343 title.setText(HEADER_TEXT); 344 345 final Label notice = new Label(shell, SWT.WRAP); 346 notice.setFont(title.getFont()); 347 notice.setForeground(new Color(currentDisplay, 255, 0, 0)); 348 notice.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 349 notice.setText(NOTICE_TEXT); 350 351 final Link text = new Link(shell, SWT.WRAP); 352 text.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 353 text.setText(BODY_TEXT); 354 text.addSelectionListener(new SelectionAdapter() { 355 @Override 356 public void widgetSelected(SelectionEvent event) { 357 openUrl(event.text); 358 } 359 }); 360 361 final Button checkbox = new Button(shell, SWT.CHECK); 362 checkbox.setSelection(true); // Opt-in by default. 363 checkbox.setText(CHECKBOX_TEXT); 364 365 final Link footer = new Link(shell, SWT.WRAP); 366 footer.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 367 footer.setText(FOOTER_TEXT); 368 369 final Button button = new Button(shell, SWT.PUSH); 370 button.setLayoutData(new GridData(GridData.HORIZONTAL_ALIGN_CENTER)); 371 button.setText(BUTTON_TEXT); 372 button.addSelectionListener(new SelectionAdapter() { 373 @Override 374 public void widgetSelected(SelectionEvent event) { 375 permission[0] = checkbox.getSelection(); 376 shell.close(); 377 } 378 }); 379 380 // Size the window to a fixed width, as high as necessary, 381 // centered. 382 final Point size = shell.computeSize(450, SWT.DEFAULT, true); 383 final Rectangle screen = currentDisplay.getClientArea(); 384 shell.setBounds(screen.x + screen.width / 2 - size.x / 2, screen.y + screen.height 385 / 2 - size.y / 2, size.x, size.y); 386 387 shell.open(); 388 389 while (!shell.isDisposed()) { 390 if (!currentDisplay.readAndDispatch()) 391 currentDisplay.sleep(); 392 } 393 394 // the dialog has closed, take care of storing the user preference 395 // and do the ping (in a different thread) 396 prefs.setValue(PING_OPT_IN, permission[0]); 397 try { 398 prefs.save(); 399 doPing(app, version, prefs); 400 } 401 catch (IOException ioe) { 402 } 403 404 405 if (disposeDisplay) { 406 currentDisplay.dispose(); 407 } 408 } 409 }); 410 } 411 412 /** 413 * Open a URL in an external browser. 414 * @param url to open - MUST be sanitized and properly formed! 415 */ openUrl(final String url)416 public static void openUrl(final String url) { 417 // TODO: consider using something like BrowserLauncher2 418 // (http://browserlaunch2.sourceforge.net/) instead of these hacks. 419 420 // SWT's Program.launch() should work on Mac, Windows, and GNOME 421 // (because the OS shell knows how to launch a default browser). 422 if (!Program.launch(url)) { 423 // Must be Linux non-GNOME (or something else broke). 424 // Try a few Linux browser commands in the background. 425 new Thread() { 426 @Override 427 public void run() { 428 for (String cmd : LINUX_BROWSERS) { 429 cmd = cmd.replaceAll("%URL%", url); // $NON-NLS-1$ 430 try { 431 Process proc = Runtime.getRuntime().exec(cmd); 432 if (proc.waitFor() == 0) break; // Success! 433 } catch (InterruptedException e) { 434 // Should never happen! 435 throw new RuntimeException(e); 436 } catch (IOException e) { 437 // Swallow the exception and try the next browser. 438 } 439 } 440 441 // TODO: Pop up some sort of error here? 442 // (We're in a new thread; can't use the existing Display.) 443 } 444 }.start(); 445 } 446 } 447 448 /** 449 * Validate the supplied application version, and normalize the version. 450 * @param app to report 451 * @param version supplied by caller 452 * @return normalized dotted quad version 453 */ normalizeVersion(String app, String version)454 private static String normalizeVersion(String app, String version) { 455 // Application name must contain only word characters (no punctuaation) 456 if (!app.matches("\\w+")) { 457 throw new IllegalArgumentException("Bad app name: " + app); 458 } 459 460 // Version must be between 1 and 4 dotted numbers 461 String[] numbers = version.split("\\."); 462 if (numbers.length > 4) { 463 throw new IllegalArgumentException("Bad version: " + version); 464 } 465 for (String part: numbers) { 466 if (!part.matches("\\d+")) { 467 throw new IllegalArgumentException("Bad version: " + version); 468 } 469 } 470 471 // Always output 4 numbers, even if fewer were supplied (pad with .0) 472 StringBuffer normal = new StringBuffer(numbers[0]); 473 for (int i = 1; i < 4; i++) { 474 normal.append(".").append(i < numbers.length ? numbers[i] : "0"); 475 } 476 return normal.toString(); 477 } 478 } 479