diff options
Diffstat (limited to 'mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java')
-rw-r--r-- | mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java | 390 |
1 files changed, 390 insertions, 0 deletions
diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java b/mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java new file mode 100644 index 000000000..e99144045 --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java @@ -0,0 +1,390 @@ +/* + Copyright 2012 KeepSafe Software 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 com.keepsafe.switchboard; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.MissingResourceException; +import java.util.zip.CRC32; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONArray; + +import android.content.Context; +import android.content.pm.PackageManager.NameNotFoundException; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; + + +/** + * SwitchBoard is the core class of the KeepSafe Switchboard mobile A/B testing framework. + * This class provides a bunch of static methods that can be used in your app to run A/B tests. + * + * The SwitchBoard supports production and staging environment. + * + * For usage <code>initDefaultServerUrls</code> for first time usage. Server URLs can be updates from + * a remote location with <code>initConfigServerUrl</code>. + * + * To run a experiment use <code>isInExperiment()</code>. The experiment name has to match the one you + * setup on the server. + * All functions are design to be safe for programming mistakes and network connection issues. If the + * experiment does not exists it will return false and pretend the user is not part of it. + * + * @author Philipp Berner + * + */ +public class SwitchBoard { + + private static final String TAG = "SwitchBoard"; + + /** Set if the application is run in debug mode. */ + public static boolean DEBUG = true; + + // Top-level experiment keys. + private static final String KEY_DATA = "data"; + private static final String KEY_NAME = "name"; + private static final String KEY_MATCH = "match"; + private static final String KEY_BUCKETS = "buckets"; + private static final String KEY_VALUES = "values"; + + // Match keys. + private static final String KEY_APP_ID = "appId"; + private static final String KEY_COUNTRY = "country"; + private static final String KEY_DEVICE = "device"; + private static final String KEY_LANG = "lang"; + private static final String KEY_MANUFACTURER = "manufacturer"; + private static final String KEY_VERSION = "version"; + + // Bucket keys. + private static final String KEY_MIN = "min"; + private static final String KEY_MAX = "max"; + + /** + * Loads a new config for a user. This method does network I/O, so it + * should not be called on the main thread. + * + * @param c ApplicationContext + * @param serverUrl Server URL endpoint. + */ + static void loadConfig(Context c, @NonNull String serverUrl) { + final URL url; + try { + url = new URL(serverUrl); + } catch (MalformedURLException e) { + Log.e(TAG, "Exception creating server URL", e); + return; + } + + final String result = readFromUrlGET(url); + if (DEBUG) Log.d(TAG, "Result: " + result); + if (result == null) { + return; + } + + // Cache result locally in shared preferences. + Preferences.setDynamicConfigJson(c, result); + } + + public static boolean isInBucket(Context c, int low, int high) { + final int userBucket = getUserBucket(c); + return (userBucket >= low) && (userBucket < high); + } + + /** + * Looks up in config if user is in certain experiment. Returns false as a default value when experiment + * does not exist. + * Experiment names are defined server side as Key in array for return values. + * @param experimentName Name of the experiment to lookup + * @return returns value for experiment or false if experiment does not exist. + */ + public static boolean isInExperiment(Context c, String experimentName) { + final Boolean override = Preferences.getOverrideValue(c, experimentName); + if (override != null) { + return override; + } + + final String config = Preferences.getDynamicConfigJson(c); + if (config == null) { + return false; + } + + try { + // TODO: cache the array into a mapping so we don't do a loop everytime we are looking for a experiment key + final JSONArray experiments = new JSONObject(config).getJSONArray(KEY_DATA); + JSONObject experiment = null; + + for (int i = 0; i < experiments.length(); i++) { + JSONObject entry = experiments.getJSONObject(i); + final String name = entry.getString(KEY_NAME); + if (name.equals(experimentName)) { + experiment = entry; + break; + } + } + + if (experiment == null) { + return false; + } + + if (!isMatch(c, experiment.optJSONObject(KEY_MATCH))) { + return false; + } + + final JSONObject buckets = experiment.getJSONObject(KEY_BUCKETS); + final boolean inExperiment = isInBucket(c, buckets.getInt(KEY_MIN), buckets.getInt(KEY_MAX)); + + if (DEBUG) { + Log.d(TAG, experimentName + " = " + inExperiment); + } + return inExperiment; + } catch (JSONException e) { + // If the experiment name is not found in the JSON, just return false. + // There is no need to log an error, since we don't really care if an + // inactive experiment is missing from the config. + return false; + } + } + + private static List<String> getExperimentNames(Context c) throws JSONException { + // TODO: cache the array into a mapping so we don't do a loop everytime we are looking for a experiment key + final List<String> returnList = new ArrayList<>(); + final String config = Preferences.getDynamicConfigJson(c); + final JSONArray experiments = new JSONObject(config).getJSONArray(KEY_DATA); + + for (int i = 0; i < experiments.length(); i++) { + JSONObject entry = experiments.getJSONObject(i); + returnList.add(entry.getString(KEY_NAME)); + } + return returnList; + } + + @Nullable + private static JSONObject getExperiment(Context c, String experimentName) throws JSONException { + // TODO: cache the array into a mapping so we don't do a loop everytime we are looking for a experiment key + final String config = Preferences.getDynamicConfigJson(c); + final JSONArray experiments = new JSONObject(config).getJSONArray(KEY_DATA); + JSONObject experiment = null; + + for (int i = 0; i < experiments.length(); i++) { + JSONObject entry = experiments.getJSONObject(i); + if (entry.getString(KEY_NAME).equals(experimentName)) { + experiment = entry; + break; + } + } + return experiment; + } + + private static boolean isMatch(Context c, @Nullable JSONObject matchKeys) { + // If no match keys are specified, default to enabling the experiment. + if (matchKeys == null) { + return true; + } + + if (matchKeys.has(KEY_APP_ID)) { + final String packageName = c.getPackageName(); + try { + if (!packageName.matches(matchKeys.getString(KEY_APP_ID))) { + return false; + } + } catch (JSONException e) { + Log.e(TAG, "Exception matching appId", e); + } + } + + if (matchKeys.has(KEY_COUNTRY)) { + try { + final String country = Locale.getDefault().getISO3Country(); + if (!country.matches(matchKeys.getString(KEY_COUNTRY))) { + return false; + } + } catch (MissingResourceException|JSONException e) { + Log.e(TAG, "Exception matching country", e); + } + } + + if (matchKeys.has(KEY_DEVICE)) { + final String device = Build.DEVICE; + try { + if (!device.matches(matchKeys.getString(KEY_DEVICE))) { + return false; + } + } catch (JSONException e) { + Log.e(TAG, "Exception matching device", e); + } + + } + if (matchKeys.has(KEY_LANG)) { + try { + final String lang = Locale.getDefault().getISO3Language(); + if (!lang.matches(matchKeys.getString(KEY_LANG))) { + return false; + } + } catch (MissingResourceException|JSONException e) { + Log.e(TAG, "Exception matching lang", e); + } + } + if (matchKeys.has(KEY_MANUFACTURER)) { + final String manufacturer = Build.MANUFACTURER; + try { + if (!manufacturer.matches(matchKeys.getString(KEY_MANUFACTURER))) { + return false; + } + } catch (JSONException e) { + Log.e(TAG, "Exception matching manufacturer", e); + } + } + + if (matchKeys.has(KEY_VERSION)) { + try { + final String version = c.getPackageManager().getPackageInfo(c.getPackageName(), 0).versionName; + if (!version.matches(matchKeys.getString(KEY_VERSION))) { + return false; + } + } catch (NameNotFoundException|JSONException e) { + Log.e(TAG, "Exception matching version", e); + } + } + + // Default to return true if no matches failed. + return true; + } + + /** + * @return a list of all active experiments. + */ + public static List<String> getActiveExperiments(Context c) { + final List<String> returnList = new ArrayList<>(); + + final String config = Preferences.getDynamicConfigJson(c); + if (config == null) { + return returnList; + } + + try { + final JSONObject data = new JSONObject(config); + final List<String> experiments = getExperimentNames(c); + + for (int i = 0; i < experiments.size(); i++) { + final String name = experiments.get(i); + + // Check override value before reading saved JSON. + Boolean isActive = Preferences.getOverrideValue(c, name); + if (isActive == null) { + // TODO: This is inefficient because it will check all the match cases on all experiments. + isActive = isInExperiment(c, name); + } + if (isActive) { + returnList.add(name); + } + } + } catch (JSONException e) { + // Something went wrong! + } + + return returnList; + } + + /** + * Checks if a certain experiment has additional values. + * @param c ApplicationContext + * @param experimentName Name of the experiment + * @return true when experiment exists + */ + public static boolean hasExperimentValues(Context c, String experimentName) { + return getExperimentValuesFromJson(c, experimentName) != null; + } + + /** + * Returns the experiment value as a JSONObject. + * @param experimentName Name of the experiment + * @return Experiment value as String, null if experiment does not exist. + */ + @Nullable + public static JSONObject getExperimentValuesFromJson(Context c, String experimentName) { + final String config = Preferences.getDynamicConfigJson(c); + + if (config == null) { + return null; + } + + try { + final JSONObject experiment = getExperiment(c, experimentName); + if (experiment == null) { + return null; + } + return experiment.getJSONObject(KEY_VALUES); + } catch (JSONException e) { + Log.e(TAG, "Could not create JSON object from config string", e); + } + + return null; + } + + /** + * Returns a String containing the server response from a GET request + * @param url URL for GET request. + * @return Returns String from server or null when failed. + */ + @Nullable private static String readFromUrlGET(URL url) { + try { + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setUseCaches(false); + + InputStream is = connection.getInputStream(); + InputStreamReader inputStreamReader = new InputStreamReader(is); + BufferedReader bufferReader = new BufferedReader(inputStreamReader, 8192); + String line; + StringBuilder resultContent = new StringBuilder(); + while ((line = bufferReader.readLine()) != null) { + resultContent.append(line); + } + bufferReader.close(); + + return resultContent.toString(); + } catch (IOException e) { + e.printStackTrace(); + } + + return null; + } + + /** + * Return the bucket number of the user. There are 100 possible buckets. + */ + private static int getUserBucket(Context c) { + final DeviceUuidFactory df = new DeviceUuidFactory(c); + final String uuid = df.getDeviceUuid().toString(); + + CRC32 crc = new CRC32(); + crc.update(uuid.getBytes()); + long checksum = crc.getValue(); + return (int)(checksum % 100L); + } +} |