aboutsummaryrefslogtreecommitdiffstats
path: root/sdkstats/src/com/android/sdkstats/SdkStatsService.java
blob: c490fed2527a9e7b3a7ed1f4ca92fa7e178309bb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
/*
 * Copyright (C) 2007 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.sdkstats;

import org.eclipse.swt.widgets.Display;
import org.eclipse.swt.widgets.Shell;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** Utility class to send "ping" usage reports to the server. */
public class SdkStatsService {

    protected static final String SYS_PROP_OS_ARCH      = "os.arch";        //$NON-NLS-1$
    protected static final String SYS_PROP_JAVA_VERSION = "java.version";   //$NON-NLS-1$
    protected static final String SYS_PROP_OS_VERSION   = "os.version";     //$NON-NLS-1$
    protected static final String SYS_PROP_OS_NAME      = "os.name";        //$NON-NLS-1$

    /** Minimum interval between ping, in milliseconds. */
    private static final long PING_INTERVAL_MSEC = 86400 * 1000;  // 1 day

    private static final boolean DEBUG = System.getenv("ANDROID_DEBUG_PING") != null; //$NON-NLS-1$

    private DdmsPreferenceStore mStore = new DdmsPreferenceStore();

    public SdkStatsService() {
    }

    /**
     * Send a "ping" to the Google toolbar server, if enough time has
     * elapsed since the last ping, and if the user has not opted out.
     * <p/>
     * This is a simplified version of {@link #ping(String[])} that only
     * sends an "application" name and a "version" string. See the explanation
     * there for details.
     *
     * @param app The application name that reports the ping (e.g. "emulator" or "ddms".)
     *          Valid characters are a-zA-Z0-9 only.
     * @param version The version string (e.g. "12" or "1.2.3.4", 4 groups max.)
     * @see #ping(String[])
     */
    public void ping(String app, String version) {
        doPing(app, version, null);
    }

    /**
     * Send a "ping" to the Google toolbar server, if enough time has
     * elapsed since the last ping, and if the user has not opted out.
     * <p/>
     * The ping will not be sent if the user opt out dialog has not been shown yet.
     * Use {@link #checkUserPermissionForPing(Shell)} to display the dialog requesting
     * user permissions.
     * <p/>
     * Note: The actual ping (if any) is sent in a <i>non-daemon</i> background thread.
     * <p/>
     * The arguments are defined as follow:
     * <ul>
     * <li>Argument 0 is the "ping" command and is ignored.</li>
     * <li>Argument 1 is the application name that reports the ping (e.g. "emulator" or "ddms".)
     *          Valid characters are a-zA-Z0-9 only.</li>
     * <li>Argument 2 is the version string (e.g. "12" or "1.2.3.4", 4 groups max.)</li>
     * <li>Arguments 3+ are optional and depend on the application name.</li>
     * <li>"emulator" application currently has 3 optional arguments:
     *      <ul>
     *      <li>Arugment 3: android_gl_vendor</li>
     *      <li>Arugment 4: android_gl_renderer</li>
     *      <li>Arugment 5: android_gl_version</li>
     *      </ul>
     * </li>
     * </ul>
     *
     * @param arguments A non-empty non-null array of arguments to the ping as described above.
     */
    public void ping(String[] arguments) {
        if (arguments == null || arguments.length < 3) {
            throw new IllegalArgumentException(
                    "Invalid ping arguments: expected ['ping', app, version] but got " +
                    (arguments == null ? "null" : Arrays.toString(arguments)));
        }
        int len = arguments.length;
        String app = arguments[1];
        String version = arguments[2];

        Map<String, String> extras = new HashMap<String, String>();

        if ("emulator".equals(app)) {                                   //$NON-NLS-1$
            if (len > 3) {
                extras.put("glm", sanitizeGlArg(arguments[3])); //$NON-NLS-1$ vendor
            }
            if (len > 4) {
                extras.put("glr", sanitizeGlArg(arguments[4])); //$NON-NLS-1$ renderer
            }
            if (len > 5) {
                extras.put("glv", sanitizeGlArg(arguments[5])); //$NON-NLS-1$ version
            }
        }

        doPing(app, version, extras);
    }

    private String sanitizeGlArg(String arg) {
        if (arg == null) {
        arg = "";                                                   //$NON-NLS-1$
        } else {
            try {
                arg = arg.trim();
                arg = arg.replaceAll("[^A-Za-z0-9\\s_()./-]", " "); //$NON-NLS-1$ //$NON-NLS-2$
                arg = arg.replaceAll("\\s\\s+", " ");               //$NON-NLS-1$ //$NON-NLS-2$

                // Guard from arbitrarily long parameters
                if (arg.length() > 128) {
                    arg = arg.substring(0, 128);
                }

                arg = URLEncoder.encode(arg, "UTF-8");              //$NON-NLS-1$
            } catch (UnsupportedEncodingException e) {
                arg = "";                                           //$NON-NLS-1$
            }
        }

        return arg;
    }

    /**
     * Display a dialog to the user providing information about the ping service,
     * and whether they'd like to opt-out of it.
     *
     * Once the dialog has been shown, it sets a preference internally indicating
     * that the user has viewed this dialog.
     */
    public void checkUserPermissionForPing(Shell parent) {
        if (!mStore.hasPingId()) {
            askUserPermissionForPing(parent);
            mStore.generateNewPingId();
        }
    }

    /**
     * Prompt the user for whether they want to opt out of reporting, and save the user
     * input in preferences.
     */
    private void askUserPermissionForPing(final Shell parent) {
        final Display display = parent.getDisplay();
        display.syncExec(new Runnable() {
            @Override
            public void run() {
                SdkStatsPermissionDialog dialog = new SdkStatsPermissionDialog(parent);
                dialog.open();
                mStore.setPingOptIn(dialog.getPingUserPreference());
            }
        });
    }

    // -------

    /**
     * Pings the usage stats server, as long as the prefs contain the opt-in boolean
     *
     * @param app The application name that reports the ping (e.g. "emulator" or "ddms".)
     *          Will be normalized.  Valid characters are a-zA-Z0-9 only.
     * @param version The version string (e.g. "12" or "1.2.3.4", 4 groups max.)
     * @param extras Extra key/value parameters to send. They are send as-is and must
     *  already be well suited and escaped using {@link URLEncoder#encode(String, String)}.
     */
    protected void doPing(String app, String version, final Map<String, String> extras) {
        // Note: if you change the implementation here, you also need to change
        // the overloaded SdkStatsServiceTest.doPing() used for testing.

        // Validate the application and version input.
        final String nApp = normalizeAppName(app);
        final String nVersion = normalizeVersion(version);

        // If the user has not opted in, do nothing and quietly return.
        if (!mStore.isPingOptIn()) {
            // user opted out.
            return;
        }

        // If the last ping *for this app* was too recent, do nothing.
        long now = System.currentTimeMillis();
        long then = mStore.getPingTime(app);
        if (now - then < PING_INTERVAL_MSEC) {
            // too soon after a ping.
            return;
        }

        // Record the time of the attempt, whether or not it succeeds.
        mStore.setPingTime(app, now);

        // Send the ping itself in the background (don't block if the
        // network is down or slow or confused).
        final long id = mStore.getPingId();
        new Thread() {
            @Override
            public void run() {
                try {
                    URL url = createPingUrl(nApp, nVersion, id, extras);
                    actuallySendPing(url);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }.start();
    }


    /**
     * Unconditionally send a "ping" request to the server.
     *
     * @param url The URL to send to the server.
     * * @throws IOException if the ping failed
     */
    private void actuallySendPing(URL url) throws IOException {
        assert url != null;

        if (DEBUG) {
            System.err.println("Ping: " + url.toString());          //$NON-NLS-1$
        }

        // Discard the actual response, but make sure it reads OK
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();

        // Believe it or not, a 404 response indicates success:
        // the ping was logged, but no update is configured.
        if (conn.getResponseCode() != HttpURLConnection.HTTP_OK &&
            conn.getResponseCode() != HttpURLConnection.HTTP_NOT_FOUND) {
            throw new IOException(
                conn.getResponseMessage() + ": " + url);            //$NON-NLS-1$
        }
    }

    /**
     * Compute the ping URL to send the data to the server.
     *
     * @param app The application name that reports the ping (e.g. "emulator" or "ddms".)
     *          Valid characters are a-zA-Z0-9 only.
     * @param version The version string already formatted as a 4 dotted group (e.g. "1.2.3.4".)
     * @param id of the local installation
     * @param extras Extra key/value parameters to send. They are send as-is and must
     *  already be well suited and escaped using {@link URLEncoder#encode(String, String)}.
     */
    protected URL createPingUrl(String app, String version, long id, Map<String, String> extras)
            throws UnsupportedEncodingException, MalformedURLException {

        String osName  = URLEncoder.encode(getOsName(),  "UTF-8");  //$NON-NLS-1$
        String osArch  = URLEncoder.encode(getOsArch(),  "UTF-8");  //$NON-NLS-1$
        String jvmArch = URLEncoder.encode(getJvmInfo(), "UTF-8");  //$NON-NLS-1$

        // Include the application's name as part of the as= value.
        // Share the user ID for all apps, to allow unified activity reports.

        String extraStr = "";                                       //$NON-NLS-1$
        if (extras != null && !extras.isEmpty()) {
            StringBuilder sb = new StringBuilder();
            for (Map.Entry<String, String> entry : extras.entrySet()) {
                sb.append('&').append(entry.getKey()).append('=').append(entry.getValue());
            }
            extraStr = sb.toString();
        }

        URL url = new URL(
            "http",                                                 //$NON-NLS-1$
            "tools.google.com",                                     //$NON-NLS-1$
            "/service/update?as=androidsdk_" + app +                //$NON-NLS-1$
                "&id=" + Long.toHexString(id) +                     //$NON-NLS-1$
                "&version=" + version +                             //$NON-NLS-1$
                "&os=" + osName +                                   //$NON-NLS-1$
                "&osa=" + osArch +                                  //$NON-NLS-1$
                "&vma=" + jvmArch +                                 //$NON-NLS-1$
                extraStr);
        return url;
    }

    /**
     * Detects and reports the host OS: "linux", "win" or "mac".
     * For Windows and Mac also append the version, so for example
     * Win XP will return win-5.1.
     */
    protected String getOsName() {                   // made protected for testing
        String os = getSystemProperty(SYS_PROP_OS_NAME);

        if (os == null || os.length() == 0) {
            return "unknown";                               //$NON-NLS-1$
        }

        String os2 = os.toLowerCase(Locale.US);

        if (os2.startsWith("mac")) {                        //$NON-NLS-1$
            os = "mac";                                     //$NON-NLS-1$
            String osVers = getOsVersion();
            if (osVers != null) {
                os = os + '-' + osVers;
            }
        } else if (os2.startsWith("win")) {                 //$NON-NLS-1$
            os = "win";                                     //$NON-NLS-1$
            String osVers = getOsVersion();
            if (osVers != null) {
                os = os + '-' + osVers;
            }
        } else if (os2.startsWith("linux")) {               //$NON-NLS-1$
            os = "linux";                                   //$NON-NLS-1$

        } else if (os.length() > 32) {
            // Unknown -- send it verbatim so we can see it
            // but protect against arbitrarily long values
            os = os.substring(0, 32);
        }
        return os;
    }

    /**
     * Detects and returns the OS architecture: x86, x86_64, ppc.
     * This may differ or be equal to the JVM architecture in the sense that
     * a 64-bit OS can run a 32-bit JVM.
     */
    protected String getOsArch() {                   // made protected for testing
        String arch = getJvmArch();

        if ("x86_64".equals(arch)) {                                    //$NON-NLS-1$
            // This is a simple case: the JVM runs in 64-bit so the
            // OS must be a 64-bit one.
            return arch;

        } else if ("x86".equals(arch)) {                                //$NON-NLS-1$
            // This is the misleading case: the JVM is 32-bit but the OS
            // might be either 32 or 64. We can't tell just from this
            // property.
            // Macs are always on 64-bit, so we just need to figure it
            // out for Windows and Linux.

            String os = getOsName();
            if (os.startsWith("win")) {                                 //$NON-NLS-1$
                // When WOW64 emulates a 32-bit environment under a 64-bit OS,
                // it sets PROCESSOR_ARCHITEW6432 to AMD64 or IA64 accordingly.
                // Ref: http://msdn.microsoft.com/en-us/library/aa384274(v=vs.85).aspx

                String w6432 = getSystemEnv("PROCESSOR_ARCHITEW6432");  //$NON-NLS-1$
                if (w6432 != null && w6432.indexOf("64") != -1) {       //$NON-NLS-1$
                    return "x86_64";                                    //$NON-NLS-1$
                }
            } else if (os.startsWith("linux")) {                        //$NON-NLS-1$
                // Let's try the obvious. This works in Ubuntu and Debian
                String s = getSystemEnv("HOSTTYPE");                    //$NON-NLS-1$

                s = sanitizeOsArch(s);
                if (s.indexOf("86") != -1) {                            //$NON-NLS-1$
                    arch = s;
                }
            }
        }

        return arch;
    }

    /**
     * Returns the version of the OS version if it is defined as X.Y, or null otherwise.
     * <p/>
     * Example of returned versions can be found at http://lopica.sourceforge.net/os.html
     * <p/>
     * This method removes any exiting micro versions.
     * Returns null if the version doesn't match X.Y.Z.
     */
    protected String getOsVersion() {                           // made protected for testing
        Pattern p = Pattern.compile("(\\d+)\\.(\\d+).*");       //$NON-NLS-1$
        String osVers = getSystemProperty(SYS_PROP_OS_VERSION);
        if (osVers != null && osVers.length() > 0) {
            Matcher m = p.matcher(osVers);
            if (m.matches()) {
                return m.group(1) + '.' + m.group(2);
            }
        }
        return null;
    }

    /**
     * Detects and returns the JVM info: version + architecture.
     * Examples: 1.4-ppc, 1.6-x86, 1.7-x86_64
     */
    protected String getJvmInfo() {                      // made protected for testing
        return getJvmVersion() + '-' + getJvmArch();
    }

    /**
     * Returns the major.minor Java version.
     * <p/>
     * The "java.version" property returns something like "1.6.0_20"
     * of which we want to return "1.6".
     */
    protected String getJvmVersion() {                   // made protected for testing
        String version = getSystemProperty(SYS_PROP_JAVA_VERSION);

        if (version == null || version.length() == 0) {
            return "unknown";                                   //$NON-NLS-1$
        }

        Pattern p = Pattern.compile("(\\d+)\\.(\\d+).*");       //$NON-NLS-1$
        Matcher m = p.matcher(version);
        if (m.matches()) {
            return m.group(1) + '.' + m.group(2);
        }

        // Unknown version. Send it as-is within a reasonable size limit.
        if (version.length() > 8) {
            version = version.substring(0, 8);
        }
        return version;
    }

    /**
     * Detects and returns the JVM architecture.
     * <p/>
     * The HotSpot JVM has a private property for this, "sun.arch.data.model",
     * which returns either "32" or "64". However it's not in any kind of spec.
     * <p/>
     * What we want is to know whether the JVM is running in 32-bit or 64-bit and
     * the best indicator is to use the "os.arch" property.
     * - On a 32-bit system, only a 32-bit JVM can run so it will be x86 or ppc.<br/>
     * - On a 64-bit system, a 32-bit JVM will also return x86 since the OS needs
     *   to masquerade as a 32-bit OS for backward compatibility.<br/>
     * - On a 64-bit system, a 64-bit JVM will properly return x86_64.
     * <pre>
     * JVM:       Java 32-bit   Java 64-bit
     * Windows:   x86           x86_64
     * Linux:     x86           x86_64
     * Mac        untested      x86_64
     * </pre>
     */
    protected String getJvmArch() {                  // made protected for testing
        String arch = getSystemProperty(SYS_PROP_OS_ARCH);
        return sanitizeOsArch(arch);
    }

    private String sanitizeOsArch(String arch) {
        if (arch == null || arch.length() == 0) {
            return "unknown";                               //$NON-NLS-1$
        }

        if (arch.equalsIgnoreCase("x86_64") ||              //$NON-NLS-1$
                arch.equalsIgnoreCase("ia64") ||            //$NON-NLS-1$
                arch.equalsIgnoreCase("amd64")) {           //$NON-NLS-1$
            return "x86_64";                                //$NON-NLS-1$
        }

        if (arch.length() >= 4 && arch.charAt(0) == 'i' && arch.indexOf("86") == 2) { //$NON-NLS-1$
            // Any variation of iX86 counts as x86 (i386, i486, i686).
            return "x86";                                   //$NON-NLS-1$
        }

        if (arch.equalsIgnoreCase("PowerPC")) {             //$NON-NLS-1$
            return "ppc";                                   //$NON-NLS-1$
        }

        // Unknown arch. Send it as-is but protect against arbitrarily long values.
        if (arch.length() > 32) {
            arch = arch.substring(0, 32);
        }
        return arch;
    }

    /**
     * Normalize the supplied application name.
     *
     * @param app to report
     */
    protected String normalizeAppName(String app) {
        // Filter out \W , non-word character: [^a-zA-Z_0-9]
        String app2 = app.replaceAll("\\W", "");                  //$NON-NLS-1$ //$NON-NLS-2$

        if (app.length() == 0) {
            throw new IllegalArgumentException("Bad app name: " + app);         //$NON-NLS-1$
        }

        return app2;
    }

    /**
     * Validate the supplied application version, and normalize the version.
     *
     * @param version supplied by caller
     * @return normalized dotted quad version
     */
    protected String normalizeVersion(String version) {

        // Version must be between 1 and 4 dotted numbers
        String[] numbers = version.split("\\.");                                //$NON-NLS-1$
        if (numbers.length > 4) {
            throw new IllegalArgumentException("Bad version: " + version);      //$NON-NLS-1$
        }
        for (String part: numbers) {
            if (!part.matches("\\d+")) {                                        //$NON-NLS-1$
                throw new IllegalArgumentException("Bad version: " + version);  //$NON-NLS-1$
            }
        }

        // Always output 4 numbers, even if fewer were supplied (pad with .0)
        StringBuffer normal = new StringBuffer(numbers[0]);
        for (int i = 1; i < 4; i++) {
            normal.append('.').append(i < numbers.length ? numbers[i] : "0");   //$NON-NLS-1$
        }
        return normal.toString();
    }

    /**
     * Calls {@link System#getProperty(String)}.
     * Allows unit-test to override the return value.
     * @see System#getProperty(String)
     */
    protected String getSystemProperty(String name) {
        return System.getProperty(name);
    }

    /**
     * Calls {@link System#getenv(String)}.
     * Allows unit-test to override the return value.
     * @see System#getenv(String)
     */
    protected String getSystemEnv(String name) {
        return System.getenv(name);
    }
}