From b7c46b701c5d1878f11f27ce85c92f2bc5772742 Mon Sep 17 00:00:00 2001 From: Scott Kyle Date: Tue, 16 Feb 2016 22:34:43 -0800 Subject: [PATCH] Add Mixpanel analytics for Android This is mostly copied and modified from the Realm Java repo. --- .../java/io/realm/react/RealmAnalytics.java | 209 ++++++++++++++++++ .../java/io/realm/react/RealmReactModule.java | 9 + 2 files changed, 218 insertions(+) create mode 100644 react-native/android/src/main/java/io/realm/react/RealmAnalytics.java diff --git a/react-native/android/src/main/java/io/realm/react/RealmAnalytics.java b/react-native/android/src/main/java/io/realm/react/RealmAnalytics.java new file mode 100644 index 00000000..7f49bbb1 --- /dev/null +++ b/react-native/android/src/main/java/io/realm/react/RealmAnalytics.java @@ -0,0 +1,209 @@ +/* Copyright 2016 Realm Inc - All Rights Reserved + * Proprietary and Confidential + */ + +package io.realm.react; + +import android.content.pm.ApplicationInfo; +import android.os.Build; +import android.util.Base64; + +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.URL; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Enumeration; +import java.util.Set; + +// Asynchronously submits build information to Realm when the annotation +// processor is running +// +// To be clear: this does *not* run when your app is in production or on +// your end-user's devices; it will only run when you build your app from source. +// +// Why are we doing this? Because it helps us build a better product for you. +// None of the data personally identifies you, your employer or your app, but it +// *will* help us understand what Realm version you use, what host OS you use, +// etc. Having this info will help with prioritizing our time, adding new +// features and deprecating old features. Collecting an anonymized bundle & +// anonymized MAC is the only way for us to count actual usage of the other +// metrics accurately. If we don't have a way to deduplicate the info reported, +// it will be useless, as a single developer building their app on Windows ten +// times would report 10 times more than a single developer that only builds +// once from Mac OS X, making the data all but useless. No one likes sharing +// data unless it's necessary, we get it, and we've debated adding this for a +// long long time. Since Realm is a free product without an email signup, we +// feel this is a necessary step so we can collect relevant data to build a +// better product for you. +// +// Currently the following information is reported: +// - What version of Realm is being used +// - What OS you are running on +// - An anonymized MAC address and bundle ID to aggregate the other information on. +public class RealmAnalytics { + private static RealmAnalytics instance; + private static final int READ_TIMEOUT = 2000; + private static final int CONNECT_TIMEOUT = 4000; + private static final String ADDRESS_PREFIX = "https://api.mixpanel.com/track/?data="; + private static final String ADDRESS_SUFFIX = "&ip=1"; + private static final String TOKEN = "ce0fac19508f6c8f20066d345d360fd0"; + private static final String EVENT_NAME = "Run"; + private static final String JSON_TEMPLATE + = "{\n" + + " \"event\": \"%EVENT%\",\n" + + " \"properties\": {\n" + + " \"token\": \"%TOKEN%\",\n" + + " \"distinct_id\": \"%USER_ID%\",\n" + + " \"Anonymized MAC Address\": \"%USER_ID%\",\n" + + " \"Anonymized Bundle ID\": \"%APP_ID%\",\n" + + " \"Binding\": \"react-native\",\n" + + " \"Language\": \"js\",\n" + + " \"Realm Version\": \"%REALM_VERSION%\",\n" + + " \"Host OS Type\": \"%OS_TYPE%\",\n" + + " \"Host OS Version\": \"%OS_VERSION%\",\n" + + " \"Target OS Type\": \"android\"\n" + + " }\n" + + "}"; + + private ApplicationInfo applicationInfo; + + private RealmAnalytics(ApplicationInfo applicationInfo) { + this.applicationInfo = applicationInfo; + } + + public static RealmAnalytics getInstance(ApplicationInfo applicationInfo) { + if (instance == null) { + instance = new RealmAnalytics(applicationInfo); + } + return instance; + } + + public static boolean shouldExecute() { + return System.getenv("REALM_DISABLE_ANALYTICS") == null + && (isRunningOnGenymotion() || isRunningOnStockEmulator()); + } + + private void send() { + try { + URL url = getUrl(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.connect(); + connection.getResponseCode(); + } catch (Exception ignored) { + } + } + + public void execute() { + Thread backgroundThread = new Thread(new Runnable() { + @Override + public void run() { + send(); + } + }); + backgroundThread.start(); + try { + backgroundThread.join(CONNECT_TIMEOUT + READ_TIMEOUT); + } catch (InterruptedException ignored) { + // We ignore this exception on purpose not to break the build system if this class fails + } catch (IllegalArgumentException ignored) { + // We ignore this exception on purpose not to break the build system if this class fails + } + } + + private URL getUrl() throws + MalformedURLException, + SocketException, + NoSuchAlgorithmException, + UnsupportedEncodingException { + return new URL(ADDRESS_PREFIX + base64Encode(generateJson()) + ADDRESS_SUFFIX); + } + + private String generateJson() throws SocketException, NoSuchAlgorithmException { + return JSON_TEMPLATE + .replaceAll("%EVENT%", EVENT_NAME) + .replaceAll("%TOKEN%", TOKEN) + .replaceAll("%USER_ID%", getAnonymousUserId()) + .replaceAll("%APP_ID%", getAnonymousAppId()) + .replaceAll("%REALM_VERSION%", Version.VERSION) + .replaceAll("%OS_TYPE%", System.getProperty("os.name")) + .replaceAll("%OS_VERSION%", System.getProperty("os.version")); + } + + private static boolean isRunningOnGenymotion() { + return Build.FINGERPRINT.contains("vbox"); + } + + private static boolean isRunningOnStockEmulator() { + return Build.FINGERPRINT.contains("generic"); + } + + /** + * Compute an anonymous user id from the hashed MAC address of the first network interface + * @return the anonymous user id + * @throws NoSuchAlgorithmException + * @throws SocketException + */ + private static String getAnonymousUserId() throws NoSuchAlgorithmException, SocketException { + Enumeration networkInterfaces = NetworkInterface.getNetworkInterfaces(); + + if (!networkInterfaces.hasMoreElements()) { + throw new IllegalStateException("No network interfaces detected"); + } + + NetworkInterface networkInterface = networkInterfaces.nextElement(); + byte[] hardwareAddress = networkInterface.getHardwareAddress(); // Normally this is the MAC address + + return hexStringify(sha256Hash(hardwareAddress)); + } + + /** + * Compute an anonymous app/library id from the packages containing RealmObject classes + * @return the anonymous app/library id + * @throws NoSuchAlgorithmException + */ + private String getAnonymousAppId() throws NoSuchAlgorithmException { + byte[] packagesBytes = applicationInfo.packageName.getBytes(); + + return hexStringify(sha256Hash(packagesBytes)); + } + + /** + * Encode the given string with Base64 + * @param data the string to encode + * @return the encoded string + * @throws UnsupportedEncodingException + */ + private static String base64Encode(String data) throws UnsupportedEncodingException { + return Base64.encodeToString(data.getBytes("UTF-8"), Base64.DEFAULT); + } + + /** + * Compute the SHA-256 hash of the given byte array + * @param data the byte array to hash + * @return the hashed byte array + * @throws NoSuchAlgorithmException + */ + private static byte[] sha256Hash(byte[] data) throws NoSuchAlgorithmException { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + return messageDigest.digest(data); + } + + /** + * Convert a byte array to its hex-string + * @param data the byte array to convert + * @return the hex-string of the byte array + */ + private static String hexStringify(byte[] data) { + StringBuilder stringBuilder = new StringBuilder(); + for (byte singleByte : data) { + stringBuilder.append(Integer.toString((singleByte & 0xff) + 0x100, 16).substring(1)); + } + + return stringBuilder.toString(); + } +} \ No newline at end of file diff --git a/react-native/android/src/main/java/io/realm/react/RealmReactModule.java b/react-native/android/src/main/java/io/realm/react/RealmReactModule.java index ffa9773f..37345b90 100644 --- a/react-native/android/src/main/java/io/realm/react/RealmReactModule.java +++ b/react-native/android/src/main/java/io/realm/react/RealmReactModule.java @@ -19,6 +19,7 @@ import fi.iki.elonen.NanoHTTPD; public class RealmReactModule extends ReactContextBaseJavaModule { private static final int DEFAULT_PORT = 8082; + private static boolean sentAnalytics = false; private AndroidWebServer webServer; private Handler handler = new Handler(Looper.getMainLooper()); @@ -38,6 +39,14 @@ public class RealmReactModule extends ReactContextBaseJavaModule { } setDefaultRealmFileDirectory(fileDir); + + // Attempt to send analytics info only once, and only if allowed to do so. + if (!sentAnalytics && RealmAnalytics.shouldExecute()) { + sentAnalytics = true; + + RealmAnalytics analytics = RealmAnalytics.getInstance(reactContext.getApplicationInfo()); + analytics.execute(); + } } @Override