Aligning mixpanel implementation with Java & Cocoa (#513)

Aligning mixpanel implementation with Java & Cocoa
This commit is contained in:
Scott Kyle 2016-06-30 05:26:36 -07:00 committed by Nabil Hachicha
parent c026c198ea
commit b06ee441bd
6 changed files with 281 additions and 234 deletions

View File

@ -0,0 +1,244 @@
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.NoSuchAlgorithmException;
import java.util.Enumeration;
import java.util.Set;
import org.gradle.api.DefaultTask
import org.gradle.api.tasks.TaskAction
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.security.NoSuchAlgorithmException;
import java.util.Scanner;
import javax.xml.bind.DatatypeConverter;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
// Submits build information to Realm when assembling the app
//
// 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.
class SendAnalyticsTask extends DefaultTask {
String applicationId = 'UNKNOWN'
String version = 'UNKNOWN'
@TaskAction
def sendAnalytics() {
try {
def env = System.getenv()
def disableAnalytics= env['REALM_DISABLE_ANALYTICS']
if (disableAnalytics == null || disableAnalytics != "true") {
send()
}
} catch(all) {}
}
//TODO replace with properties
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 = '''
{
"event": "%EVENT%",
"properties": {
"token": "%TOKEN%,
"distinct_id": "%USER_ID%",
"Anonymized MAC Address": "%USER_ID%",
"Anonymized Bundle ID": "%APP_ID%",
"Binding": "js",
"Language": "js",
"Framework": "react-native",
"Virtual Machine": "jsc",
"Realm Version": "%REALM_VERSION%",
"Host OS Type": "%OS_TYPE%",
"Host OS Version": "%OS_VERSION%",
"Target OS Type": "android"
}
}'''
void send() {
def connection = (ADDRESS_PREFIX + Utils.base64Encode(generateJson()) + ADDRESS_SUFFIX)
.toURL().openConnection()
connection.setConnectTimeout(CONNECT_TIMEOUT);
connection.setReadTimeout(READ_TIMEOUT);
connection.setRequestMethod("GET")
connection.connect()
connection.getResponseCode()
}
private String generateJson() {
JSON_TEMPLATE
.replaceAll("%EVENT%", EVENT_NAME)
.replaceAll("%TOKEN%", TOKEN)
.replaceAll("%USER_ID%", ComputerIdentifierGenerator.get())
.replaceAll("%APP_ID%", getAnonymousAppId())
.replaceAll("%REALM_VERSION%", version)
.replaceAll("%OS_TYPE%", System.getProperty("os.name"))
.replaceAll("%OS_VERSION%", System.getProperty("os.version"))
}
/**
* Computes an anonymous app/library id from the packages containing RealmObject classes
* @return the anonymous app/library id
* @throws NoSuchAlgorithmException
*/
private String getAnonymousAppId() {
byte[] packagesBytes = applicationId?.getBytes()
Utils.hexStringify(Utils.sha256Hash(packagesBytes))
}
}
class ComputerIdentifierGenerator {
private static final String UNKNOWN = "unknown";
private static String OS = System.getProperty("os.name").toLowerCase()
public static String get() {
if (isWindows()) {
return getWindowsIdentifier()
} else if (isMac()) {
return getMacOsIdentifier()
} else if (isLinux()) {
return getLinuxMacAddress()
} else {
return UNKNOWN
}
}
private static boolean isWindows() {
OS.contains("win")
}
private static boolean isMac() {
OS.contains("mac")
}
private static boolean isLinux() {
OS.contains("inux")
}
private static String getLinuxMacAddress() {
File machineId = new File("/var/lib/dbus/machine-id")
if (!machineId.exists()) {
machineId = new File("/etc/machine-id")
}
if (!machineId.exists()) {
return UNKNOWN
}
Scanner scanner = null
try {
scanner = new Scanner(machineId)
String id = scanner.useDelimiter("\\A").next()
return Utils.hexStringify(Utils.sha256Hash(id.getBytes()))
} finally {
if (scanner != null) {
scanner.close()
}
}
}
private static String getMacOsIdentifier() {
NetworkInterface networkInterface = NetworkInterface.getByName("en0")
byte[] hardwareAddress = networkInterface.getHardwareAddress()
Utils.hexStringify(Utils.sha256Hash(hardwareAddress))
}
private static String getWindowsIdentifier() {
Runtime runtime = Runtime.getRuntime()
Process process = runtime.exec(["wmic", "csproduct", "get", "UUID"])
String result = null
InputStream is = process.getInputStream()
Scanner sc = new Scanner(process.getInputStream())
try {
while (sc.hasNext()) {
String next = sc.next()
if (next.contains("UUID")) {
result = sc.next().trim()
break
}
}
} finally {
is.close()
}
result==null?UNKNOWN:Utils.hexStringify(Utils.sha256Hash(result.getBytes()))
}
}
class Utils {
/**
* Encode the given string with Base64
* @param data the string to encode
* @return the encoded string
* @throws UnsupportedEncodingException
*/
public static String base64Encode(String data) throws UnsupportedEncodingException {
return DatatypeConverter.printBase64Binary(data.getBytes("UTF-8"));
}
/**
* Compute the SHA-256 hash of the given byte array
* @param data the byte array to hash
* @return the hashed byte array
* @throws NoSuchAlgorithmException
*/
public 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
*/
public 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();
}
}
// see https://discuss.gradle.org/t/build-gradle-cant-find-task-class-defined-in-an-apply-from-external-script/5836/2
ext.SendAnalyticsTask = SendAnalyticsTask

View File

@ -219,6 +219,14 @@ task publishAndroid(dependsOn: [generateVersionClass, packageReactNdkLibs], type
'build.gradle'
}
}
// copy analytics script
into ('/') {
from "$projectDir/analytics_template"
rename { String fileName ->
'analytics.gradle'
}
}
}
// publishing into maven local

View File

@ -53,6 +53,26 @@ android {
}
}
String getAppId () {
String myappId;
try {
String build = new File("$projectDir/../../../android/app/build.gradle").text
def matcher = build =~ 'applicationId.*"'
def appId = matcher.size() > 0 ? matcher[0].trim() - 'applicationId' - ~/\s/ : '';
myappId = appId.replaceAll('"', '')
} catch(all) {}
return myappId
}
apply from: 'analytics.gradle'
task send(type: SendAnalyticsTask) {
applicationId = getAppId()
version = "npm --silent run get-version".execute(null, projectDir).text.trim()
}
preBuild.dependsOn send
dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
compile 'org.nanohttpd:nanohttpd:2.2.0'

View File

@ -1,223 +0,0 @@
////////////////////////////////////////////////////////////////////////////
//
// Copyright 2016 Realm Inc.
//
// 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 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 kind of JavaScript framework is being used (e.g. React Native)
// - What kind of JavaScript VM is being used (e.g. JavaScriptCore or V8)
// - 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\": \"js\",\n"
+ " \"Language\": \"js\",\n"
+ " \"Framework\": \"react-native\",\n"
+ " \"Virtual Machine\": \"jsc\",\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 && isRunningOnEmulator();
}
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"));
}
public static boolean isRunningOnEmulator() {
// Check if running in Genymotion or on the stock emulator.
return Build.FINGERPRINT.contains("vbox") || 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<NetworkInterface> 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.NO_WRAP);
}
/**
* 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();
}
}

View File

@ -1,6 +1,7 @@
package io.realm.react;
import android.content.res.AssetManager;
import android.os.Build;
import android.util.Log;
import com.facebook.react.bridge.ReactApplicationContext;
@ -21,7 +22,7 @@ import java.util.Map;
import fi.iki.elonen.NanoHTTPD;
public class RealmReactModule extends ReactContextBaseJavaModule {
class RealmReactModule extends ReactContextBaseJavaModule {
private static final int DEFAULT_PORT = 8082;
private static boolean sentAnalytics = false;
@ -49,14 +50,6 @@ public class RealmReactModule extends ReactContextBaseJavaModule {
}
setDefaultRealmFileDirectory(fileDir, assetManager);
// 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
@ -74,7 +67,7 @@ public class RealmReactModule extends ReactContextBaseJavaModule {
startWebServer();
List<String> hosts;
if (RealmAnalytics.isRunningOnEmulator()) {
if (isRunningOnEmulator()) {
hosts = Arrays.asList(new String[]{"localhost"});
} else {
hosts = getIPAddresses();
@ -92,6 +85,11 @@ public class RealmReactModule extends ReactContextBaseJavaModule {
stopWebServer();
}
private static boolean isRunningOnEmulator() {
// Check if running in Genymotion or on the stock emulator.
return Build.FINGERPRINT.contains("vbox") || Build.FINGERPRINT.contains("generic");
}
private List<String> getIPAddresses() {
ArrayList<String> ipAddresses = new ArrayList<String>();

View File

@ -24,4 +24,4 @@ public class RealmReactPackage implements ReactPackage {
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
}
}