summaryrefslogtreecommitdiffstats
path: root/mobile/android/thirdparty/com/keepsafe/switchboard/SwitchBoard.java
blob: e99144045c9ad54eed2723a609dd72c8249bfe82 (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
/*
   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);
    }
}