diff options
Diffstat (limited to 'mobile/android/thirdparty/com/keepsafe/switchboard')
5 files changed, 691 insertions, 0 deletions
diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/AsyncConfigLoader.java b/mobile/android/thirdparty/com/keepsafe/switchboard/AsyncConfigLoader.java new file mode 100644 index 000000000..2cff4b4c3 --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/AsyncConfigLoader.java @@ -0,0 +1,54 @@ +/* + 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 android.content.Context; +import android.os.AsyncTask; + +/** + * An async loader to load user config in background thread based on internal generated UUID. + * + * Call <code>AsyncConfigLoader.execute()</code> to load SwitchBoard.loadConfig() with own ID. + * To use your custom UUID call <code>AsyncConfigLoader.execute(uuid)</code> with uuid being your unique user id + * as a String + * + * @author Philipp Berner + * + */ +public class AsyncConfigLoader extends AsyncTask<Void, Void, Void> { + + private Context context; + private String defaultServerUrl; + + /** + * Sets the params for async loading either SwitchBoard.updateConfigServerUrl() + * or SwitchBoard.loadConfig. + * Loads config with a custom UUID + * @param c Application context + * @param defaultServerUrl Default URL endpoint for Switchboard config. + */ + public AsyncConfigLoader(Context c, String defaultServerUrl) { + this.context = c; + this.defaultServerUrl = defaultServerUrl; + } + + @Override + protected Void doInBackground(Void... params) { + SwitchBoard.loadConfig(context, defaultServerUrl); + return null; + } +} diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/DeviceUuidFactory.java b/mobile/android/thirdparty/com/keepsafe/switchboard/DeviceUuidFactory.java new file mode 100644 index 000000000..c4476d2cd --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/DeviceUuidFactory.java @@ -0,0 +1,70 @@ +/* + 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.util.UUID; + +import android.content.Context; +import android.content.SharedPreferences; + +/** + * Generates a UUID and stores is persistent as in the apps shared preferences. + * + * @author Philipp Berner + */ +public class DeviceUuidFactory { + protected static final String PREFS_FILE = "com.keepsafe.switchboard.uuid"; + protected static final String PREFS_DEVICE_ID = "device_id"; + + private static UUID uuid = null; + + public DeviceUuidFactory(Context context) { + if (uuid == null) { + synchronized (DeviceUuidFactory.class) { + if (uuid == null) { + final SharedPreferences prefs = context + .getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE); + final String id = prefs.getString(PREFS_DEVICE_ID, null); + + if (id != null) { + // Use the ids previously computed and stored in the prefs file + uuid = UUID.fromString(id); + } else { + uuid = UUID.randomUUID(); + + // Write the value out to the prefs file + prefs.edit().putString(PREFS_DEVICE_ID, uuid.toString()).apply(); + } + } + } + } + } + + /** + * Returns a unique UUID for the current android device. As with all UUIDs, + * this unique ID is "very highly likely" to be unique across all Android + * devices. Much more so than ANDROID_ID is. + * + * The UUID is generated with <code>UUID.randomUUID()</code>. + * + * @return a UUID that may be used to uniquely identify your device for most + * purposes. + */ + public UUID getDeviceUuid() { + return uuid; + } + +}
\ No newline at end of file diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/Preferences.java b/mobile/android/thirdparty/com/keepsafe/switchboard/Preferences.java new file mode 100644 index 000000000..f7f6f7cb7 --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/Preferences.java @@ -0,0 +1,105 @@ +/* + 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 android.content.Context; +import android.content.SharedPreferences; +import android.support.annotation.Nullable; + +/** + * Application preferences for SwitchBoard. + * @author Philipp Berner + * + */ +public class Preferences { + + private static final String switchBoardSettings = "com.keepsafe.switchboard.settings"; + + private static final String CONFIG_JSON = "config-json"; + private static final String OVERRIDE_PREFIX = "experiment.override."; + + + /** + * Gets the user config as a JSON string. + * @param c Context + * @return Config JSON + */ + @Nullable public static String getDynamicConfigJson(Context c) { + final SharedPreferences prefs = c.getApplicationContext().getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE); + return prefs.getString(CONFIG_JSON, null); + } + + /** + * Saves the user config as a JSON sting. + * @param c Context + * @param configJson Config JSON + */ + public static void setDynamicConfigJson(Context c, String configJson) { + final SharedPreferences.Editor editor = c.getApplicationContext(). + getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE).edit(); + editor.putString(CONFIG_JSON, configJson); + editor.apply(); + } + + /** + * Gets the override value for an experiment. + * + * @param c Context + * @param experimentName Experiment name + * @return Whether or not the experiment should be enabled, or null if there is no override. + */ + @Nullable public static Boolean getOverrideValue(Context c, String experimentName) { + final SharedPreferences prefs = c.getApplicationContext(). + getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE); + + final String key = OVERRIDE_PREFIX + experimentName; + if (prefs.contains(key)) { + // This will never fall back to the default value. + return prefs.getBoolean(key, false); + } + + // Default to returning null if no override was found. + return null; + } + + /** + * Saves an override value for an experiment. + * + * @param c Context + * @param experimentName Experiment name + * @param isEnabled Whether or not to enable the experiment + */ + public static void setOverrideValue(Context c, String experimentName, boolean isEnabled) { + final SharedPreferences.Editor editor = c.getApplicationContext(). + getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE).edit(); + editor.putBoolean(OVERRIDE_PREFIX + experimentName, isEnabled); + editor.apply(); + } + + /** + * Clears the override value for an experiment. + * + * @param c Context + * @param experimentName Experiment name + */ + public static void clearOverrideValue(Context c, String experimentName) { + final SharedPreferences.Editor editor = c.getApplicationContext(). + getSharedPreferences(switchBoardSettings, Context.MODE_PRIVATE).edit(); + editor.remove(OVERRIDE_PREFIX + experimentName); + editor.apply(); + } +} diff --git a/mobile/android/thirdparty/com/keepsafe/switchboard/Switch.java b/mobile/android/thirdparty/com/keepsafe/switchboard/Switch.java new file mode 100644 index 000000000..5307750bb --- /dev/null +++ b/mobile/android/thirdparty/com/keepsafe/switchboard/Switch.java @@ -0,0 +1,72 @@ +/* + 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 org.json.JSONObject; + +import android.content.Context; + +/** + * Single instance of an existing experiment for easier and cleaner code. + * + * @author Philipp Berner + * + */ +public class Switch { + + private Context context; + private String experimentName; + + /** + * Creates an instance of a single experiment to give more convenient access to its values. + * When the given experiment does not exist, it will give back default valued that can be found + * in <code>Switchboard</code>. Developer has to know that experiment exists when using it. + * @param c Application context + * @param experimentName Name of the experiment as defined on the server + */ + public Switch(Context c, String experimentName) { + this.context = c; + this.experimentName = experimentName; + } + + /** + * Returns true if the experiment is active for this particular user. + * @return Status of the experiment and false when experiment does not exist. + */ + public boolean isActive() { + return SwitchBoard.isInExperiment(context, experimentName); + } + + /** + * Returns true if the experiment has additional values. + * @return true when values exist + */ + public boolean hasValues() { + return SwitchBoard.hasExperimentValues(context, experimentName); + } + + /** + * Gives back all the experiment values in a JSONObject. This function checks if + * values exists. If no values exist, it returns null. + * @return Values in JSONObject or null if non + */ + public JSONObject getValues() { + if(hasValues()) + return SwitchBoard.getExperimentValuesFromJson(context, experimentName); + else + return null; + } +} 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); + } +} |