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 // Validate the application and version input. 120 final String normalVersion = normalizeVersion(app, version); 121 122 // Unique, randomly assigned ID for this installation. 123 PreferenceStore prefs = getPreferenceStore(); 124 if (prefs != null) { 125 if (!prefs.contains(PING_ID)) { 126 // First time: make up a new ID. TODO: Use something more random? 127 prefs.setValue(PING_ID, new Random().nextLong()); 128 129 // Also give them a chance to opt out. 130 prefs.setValue(PING_OPT_IN, getUserPermission(display)); 131 try { 132 prefs.save(); 133 } 134 catch (IOException ioe) { 135 } 136 } 137 138 // If the user has not opted in, do nothing and quietly return. 139 if (!prefs.getBoolean(PING_OPT_IN)) { 140 // user opted out. 141 return; 142 } 143 144 // If the last ping *for this app* was too recent, do nothing. 145 String timePref = PING_TIME + "." + app; // $NON-NLS-1$ 146 long now = System.currentTimeMillis(); 147 long then = prefs.getLong(timePref); 148 if (now - then < PING_INTERVAL_MSEC) { 149 // too soon after a ping. 150 return; 151 } 152 153 // Record the time of the attempt, whether or not it succeeds. 154 prefs.setValue(timePref, now); 155 try { 156 prefs.save(); 157 } 158 catch (IOException ioe) { 159 } 160 161 // Send the ping itself in the background (don't block if the 162 // network is down or slow or confused). 163 final long id = prefs.getLong(PING_ID); 164 new Thread() { 165 @Override 166 public void run() { 167 try { 168 actuallySendPing(app, normalVersion, id); 169 } catch (IOException e) { 170 e.printStackTrace(); 171 } 172 } 173 }.start(); 174 } 175 } 176 177 /** 178 * Returns the DDMS {@link PreferenceStore}. 179 */ getPreferenceStore()180 public static synchronized PreferenceStore getPreferenceStore() { 181 if (sPrefStore == null) { 182 // get the location of the preferences 183 String homeDir = null; 184 try { 185 homeDir = AndroidLocation.getFolder(); 186 } catch (AndroidLocationException e1) { 187 // pass, we'll do a dummy store since homeDir is null 188 } 189 190 if (homeDir != null) { 191 String rcFileName = homeDir + "ddms.cfg"; //$NON-NLS-1$ 192 193 // also look for an old pref file in the previous location 194 String oldPrefPath = System.getProperty("user.home") //$NON-NLS-1$ 195 + File.separator + ".ddmsrc"; //$NON-NLS-1$ 196 File oldPrefFile = new File(oldPrefPath); 197 if (oldPrefFile.isFile()) { 198 try { 199 PreferenceStore oldStore = new PreferenceStore(oldPrefPath); 200 oldStore.load(); 201 202 oldStore.save(new FileOutputStream(rcFileName), ""); 203 oldPrefFile.delete(); 204 205 PreferenceStore newStore = new PreferenceStore(rcFileName); 206 newStore.load(); 207 sPrefStore = newStore; 208 } catch (IOException e) { 209 // create a new empty store. 210 sPrefStore = new PreferenceStore(rcFileName); 211 } 212 } else { 213 sPrefStore = new PreferenceStore(rcFileName); 214 215 try { 216 sPrefStore.load(); 217 } catch (IOException e) { 218 System.err.println("Error Loading Preferences"); 219 } 220 } 221 } else { 222 sPrefStore = new PreferenceStore(); 223 } 224 } 225 226 return sPrefStore; 227 } 228 229 /** 230 * Unconditionally send a "ping" request to the Google toolbar server. 231 * 232 * @param app name to report in the ping 233 * @param version to report in the ping (dotted numbers, no more than four) 234 * @param id of the local installation 235 * @throws IOException if the ping failed 236 */ 237 @SuppressWarnings("deprecation") actuallySendPing(String app, String version, long id)238 private static void actuallySendPing(String app, String version, long id) 239 throws IOException { 240 // Detect and report the host OS. 241 String os = System.getProperty("os.name"); // $NON-NLS-1$ 242 if (os.startsWith("Mac OS")) { // $NON-NLS-1$ 243 os = "mac"; // $NON-NLS-1$ 244 String osVers = getVersion(); 245 if (osVers != null) { 246 os = os + "-" + osVers; // $NON-NLS-1$ 247 } 248 } else if (os.startsWith("Windows")) { // $NON-NLS-1$ 249 os = "win"; // $NON-NLS-1$ 250 String osVers = getVersion(); 251 if (osVers != null) { 252 os = os + "-" + osVers; // $NON-NLS-1$ 253 } 254 } else if (os.startsWith("Linux")) { // $NON-NLS-1$ 255 os = "linux"; // $NON-NLS-1$ 256 } else { 257 // Unknown -- surprising -- send it verbatim so we can see it. 258 os = URLEncoder.encode(os); 259 } 260 261 // Include the application's name as part of the as= value. 262 // Share the user ID for all apps, to allow unified activity reports. 263 264 URL url = new URL( 265 "http", // $NON-NLS-1$ 266 "tools.google.com", // $NON-NLS-1$ 267 "/service/update?as=androidsdk_" + app + // $NON-NLS-1$ 268 "&id=" + Long.toHexString(id) + // $NON-NLS-1$ 269 "&version=" + version + // $NON-NLS-1$ 270 "&os=" + os); // $NON-NLS-1$ 271 272 // Discard the actual response, but make sure it reads OK 273 HttpURLConnection conn = (HttpURLConnection) url.openConnection(); 274 275 // Believe it or not, a 404 response indicates success: 276 // the ping was logged, but no update is configured. 277 if (conn.getResponseCode() != HttpURLConnection.HTTP_OK && 278 conn.getResponseCode() != HttpURLConnection.HTTP_NOT_FOUND) { 279 throw new IOException( 280 conn.getResponseMessage() + ": " + url); // $NON-NLS-1$ 281 } 282 } 283 284 /** 285 * Returns the version of the os if it is defined as X.Y, or null otherwise. 286 * <p/> 287 * Example of returned versions can be found at http://lopica.sourceforge.net/os.html 288 * <p/> 289 * This method removes any exiting micro versions. 290 */ getVersion()291 private static String getVersion() { 292 Pattern p = Pattern.compile("(\\d+)\\.(\\d+).*"); // $NON-NLS-1$ 293 String osVers = System.getProperty("os.version"); // $NON-NLS-1$ 294 Matcher m = p.matcher(osVers); 295 if (m.matches()) { 296 return m.group(1) + "." + m.group(2); // $NON-NLS-1$ 297 } 298 299 return null; 300 } 301 302 /** 303 * Prompt the user for whether they want to opt out of reporting. 304 * @return whether the user allows reporting (they do not opt out). 305 */ getUserPermission(Display display)306 private static boolean getUserPermission(Display display) { 307 // Whether the user gave permission (size-1 array for writing to). 308 // Initialize to false, set when the user clicks the button. 309 final boolean[] permission = new boolean[] { false }; 310 311 boolean dispose = false; 312 if (display == null) { 313 display = new Display(); 314 dispose = true; 315 } 316 317 final Display currentDisplay = display; 318 final boolean disposeDisplay = dispose; 319 320 display.syncExec(new Runnable() { 321 public void run() { 322 final Shell shell = new Shell(currentDisplay, SWT.TITLE | SWT.BORDER); 323 shell.setText(WINDOW_TITLE_TEXT); 324 shell.setLayout(new GridLayout(1, false)); // 1 column 325 326 // Take the default font and scale it up for the title. 327 final Label title = new Label(shell, SWT.CENTER | SWT.WRAP); 328 final FontData[] fontdata = title.getFont().getFontData(); 329 for (int i = 0; i < fontdata.length; i++) { 330 fontdata[i].setHeight(fontdata[i].getHeight() * 4 / 3); 331 } 332 title.setFont(new Font(currentDisplay, fontdata)); 333 title.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 334 title.setText(HEADER_TEXT); 335 336 final Label notice = new Label(shell, SWT.WRAP); 337 notice.setFont(title.getFont()); 338 notice.setForeground(new Color(currentDisplay, 255, 0, 0)); 339 notice.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 340 notice.setText(NOTICE_TEXT); 341 342 final Link text = new Link(shell, SWT.WRAP); 343 text.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 344 text.setText(BODY_TEXT); 345 text.addSelectionListener(new SelectionAdapter() { 346 @Override 347 public void widgetSelected(SelectionEvent event) { 348 openUrl(event.text); 349 } 350 }); 351 352 final Button checkbox = new Button(shell, SWT.CHECK); 353 checkbox.setSelection(true); // Opt-in by default. 354 checkbox.setText(CHECKBOX_TEXT); 355 356 final Link footer = new Link(shell, SWT.WRAP); 357 footer.setLayoutData(new GridData(GridData.FILL_HORIZONTAL)); 358 footer.setText(FOOTER_TEXT); 359 360 final Button button = new Button(shell, SWT.PUSH); 361 button.setLayoutData(new GridData(GridData.HORIZONTAL_ALIGN_CENTER)); 362 button.setText(BUTTON_TEXT); 363 button.addSelectionListener(new SelectionAdapter() { 364 @Override 365 public void widgetSelected(SelectionEvent event) { 366 permission[0] = checkbox.getSelection(); 367 shell.close(); 368 } 369 }); 370 371 // Size the window to a fixed width, as high as necessary, 372 // centered. 373 final Point size = shell.computeSize(450, SWT.DEFAULT, true); 374 final Rectangle screen = currentDisplay.getClientArea(); 375 shell.setBounds(screen.x + screen.width / 2 - size.x / 2, screen.y + screen.height 376 / 2 - size.y / 2, size.x, size.y); 377 378 shell.open(); 379 while (!shell.isDisposed()) { 380 if (!currentDisplay.readAndDispatch()) 381 currentDisplay.sleep(); 382 } 383 384 if (disposeDisplay) { 385 currentDisplay.dispose(); 386 } 387 } 388 }); 389 390 return permission[0]; 391 } 392 393 /** 394 * Open a URL in an external browser. 395 * @param url to open - MUST be sanitized and properly formed! 396 */ openUrl(final String url)397 public static void openUrl(final String url) { 398 // TODO: consider using something like BrowserLauncher2 399 // (http://browserlaunch2.sourceforge.net/) instead of these hacks. 400 401 // SWT's Program.launch() should work on Mac, Windows, and GNOME 402 // (because the OS shell knows how to launch a default browser). 403 if (!Program.launch(url)) { 404 // Must be Linux non-GNOME (or something else broke). 405 // Try a few Linux browser commands in the background. 406 new Thread() { 407 @Override 408 public void run() { 409 for (String cmd : LINUX_BROWSERS) { 410 cmd = cmd.replaceAll("%URL%", url); // $NON-NLS-1$ 411 try { 412 Process proc = Runtime.getRuntime().exec(cmd); 413 if (proc.waitFor() == 0) break; // Success! 414 } catch (InterruptedException e) { 415 // Should never happen! 416 throw new RuntimeException(e); 417 } catch (IOException e) { 418 // Swallow the exception and try the next browser. 419 } 420 } 421 422 // TODO: Pop up some sort of error here? 423 // (We're in a new thread; can't use the existing Display.) 424 } 425 }.start(); 426 } 427 } 428 429 /** 430 * Validate the supplied application version, and normalize the version. 431 * @param app to report 432 * @param version supplied by caller 433 * @return normalized dotted quad version 434 */ normalizeVersion(String app, String version)435 private static String normalizeVersion(String app, String version) { 436 // Application name must contain only word characters (no punctuaation) 437 if (!app.matches("\\w+")) { 438 throw new IllegalArgumentException("Bad app name: " + app); 439 } 440 441 // Version must be between 1 and 4 dotted numbers 442 String[] numbers = version.split("\\."); 443 if (numbers.length > 4) { 444 throw new IllegalArgumentException("Bad version: " + version); 445 } 446 for (String part: numbers) { 447 if (!part.matches("\\d+")) { 448 throw new IllegalArgumentException("Bad version: " + version); 449 } 450 } 451 452 // Always output 4 numbers, even if fewer were supplied (pad with .0) 453 StringBuffer normal = new StringBuffer(numbers[0]); 454 for (int i = 1; i < 4; i++) { 455 normal.append(".").append(i < numbers.length ? numbers[i] : "0"); 456 } 457 return normal.toString(); 458 } 459 } 460