001package ai.lilystyle.analytics_android;
002
003import android.content.Context;
004import android.content.SharedPreferences;
005import android.os.Handler;
006import android.os.Looper;
007import android.util.Log;
008
009import org.jetbrains.annotations.NotNull;
010import org.json.JSONObject;
011
012import java.util.HashMap;
013import java.util.Locale;
014import java.util.Map;
015import java.util.concurrent.ConcurrentHashMap;
016import java.util.concurrent.ExecutorService;
017import java.util.concurrent.LinkedBlockingDeque;
018import java.util.concurrent.ThreadPoolExecutor;
019import java.util.concurrent.TimeUnit;
020
021import okhttp3.OkHttpClient;
022
023
024/**
025 * Core class for integrating Lily.Ai analytics.
026 *
027 * <p>First obtain api-key and token to access Lily.Ai analytics.
028 * <p>Place the obtained api-key and token in meta-data tags in your AndroidManifest.xml inside application tag:
029 * <pre>
030 * {@code
031 * <application ...>
032 *     <meta-data android:name="ai.lily.analytics.api_token" android:value="PLACE_YOUR_API_TOKEN_HERE" />
033 *     <meta-data android:name="ai.lily.analytics.api_key" android:value="PLACE_YOUR_API_KEY_HERE" />
034 *     <meta-data android:name="ai.lily.analytics.endpoint" android:value="PLACE_YOUR_ENDPOINT_HERE" />
035 * </application>
036 * }
037 * </pre>
038 *
039 * <p>Now you can call {@link #getInstance(Context)} to obtain LilyAi instance
040 *
041 * <p>You might want to keep your api-key and token more secure. In this case obfuscate those as you please and
042 * to obtain LilyAi instance use {@link #getInstance(Context, String)} with your token or {@link #getInstance(Context, String, String, String)} with both.
043 *
044 * <p>When initiated LilyAi tries to restore a unique id (UUID or lpid) of the user from it's shared preferences or
045 * generates a new one, if not present, and stores it. You can override this by calling {@link #setUUID(String)} with your own id for the user.
046 * Also you can use {@link #getUUID()} to check what UUID is currently used by LilyAi or call {@link #resetUUID()} to
047 * drop the stored UUID value and generate a new random UUID.
048 *
049 * <p>Once you obtained an instance of LilyAi and done with setting up the UUID you can call either of {@link #track(JSONObject)} or
050 * {@link #track(JSONObject, LilyAiListener)} methods to send your data.
051 */
052public final class LilyAi {
053
054    private final static Map<String, LilyAi> instancesMap = new HashMap<>();
055
056    private final SharedPreferences prefs;
057    private final String baseUrl;
058    private final Map<String, String> headers = new ConcurrentHashMap<>();
059    private final TrackingDataPersistentStorage trackingDataStorage;
060    private long lastTrackId;
061    private final String token;
062    private final String apiKey;
063    private final ExecutorService executorService;
064    private String uuid;
065    private String sessionId;
066    private Long sessionStartTime;
067    private Long sessionLastEventOccurred;
068    private long sessionDuration;
069    private final OkHttpClient okHttpClient = RequestsHelper.getOkHttpClient();
070    private final Handler handler;
071
072
073    private LilyAi(Context context, String baseUrl, String token, String apiKey) {
074        this.baseUrl = baseUrl;
075        this.token = token;
076        this.apiKey = apiKey;
077        prefs = context.getApplicationContext().getSharedPreferences(Constants.PREFS_FILE_NAME+(apiKey+token+baseUrl).hashCode(), Context.MODE_PRIVATE);
078        executorService = new ThreadPoolExecutor(Constants.THREAD_POOL_SIZE, Constants.THREAD_POOL_SIZE,
079                1, TimeUnit.MINUTES, new LinkedBlockingDeque<Runnable>(), new WorkerThreadFactory());
080        uuid = Utils.getUUID(prefs, true);
081        sessionId = prefs.getString(Constants.SESSION_ID_PREF_NAME, null);
082        sessionStartTime = prefs.getLong(Constants.SESSION_START_PREF_NAME, 0);
083        sessionLastEventOccurred = prefs.getLong(Constants.SESSION_LAST_EVENT_PREF_NAME, 0);
084        sessionDuration = prefs.getLong(Constants.SESSION_DURATION, Constants.DEFAULT_SESSION_DURATION);
085        lastTrackId = prefs.getLong(Constants.LAST_TRACKED_ID, 0);
086        trackingDataStorage = TrackingDataPersistentStorage.getInstance(context.getApplicationContext(),
087                String.format(Locale.getDefault(), Constants.TRACKED_DATA_STORAGE_DIR, baseUrl.hashCode()),
088                new TrackingDataPersistentStorageListener() {
089                    @Override
090                    public void withUndeliveredData(TrackingData trackingData) {
091                        synchronized (trackingDataStorage) {
092                            if (!trackingDataStorage.isSending(trackingData)) {
093                                trackingDataStorage.put(trackingData, true);
094                                executorService.submit(new Worker(LilyAi.this.baseUrl, trackingData, true, okHttpClient, workerListener, null));
095                            }
096                        }
097                    }
098        });
099        handler = new Handler(Looper.getMainLooper());
100    }
101
102    /**
103     * Get instance of LilyAI.
104     *
105     * <p>To use this method you should provide api-key and token in meta-data tags in your AndroidManifest.xml inside application tag:
106     * <pre>
107     * {@code
108     * <application ...>
109     *     <meta-data android:name="ai.lily.analytics.api_token" android:value="PLACE_YOUR_API_TOKEN_HERE" />
110     *     <meta-data android:name="ai.lily.analytics.api_key" android:value="PLACE_YOUR_API_KEY_HERE" />
111     *     <meta-data android:name="ai.lily.analytics.endpoint" android:value="PLACE_YOUR_ENDPOINT_HERE" />
112     * </application>
113     * }
114     * </pre>
115     *
116     * @param context The application context.
117     * @return an instance of LilyAI associated with provided token and api-key.
118     */
119    public static LilyAi getInstance(Context context) {
120        return getInstance(context, null, null, null);
121    }
122
123    /**
124     * Get instance of LilyAI.
125     *
126     * <p>To use this method you should provide api-key in meta-data tag in your AndroidManifest.xml inside application tag:
127     * <pre>
128     * {@code
129     * <application ...>
130     *     <meta-data android:name="ai.lily.analytics.api_key" android:value="PLACE_YOUR_API_KEY_HERE" />
131     *     <meta-data android:name="ai.lily.analytics.endpoint" android:value="PLACE_YOUR_ENDPOINT_HERE" />
132     * </application>
133     * }
134     * </pre>
135     *
136     * @param context The application context.
137     * @param token Your api token string.
138     * @return an instance of LilyAI associated with provided token and api-key.
139     */
140    public static LilyAi getInstance(Context context, String token) {
141        return getInstance(context, null, token, null);
142    }
143
144    /**
145     * Get instance of LilyAI.
146     *
147     * @param context The application context.
148     * @param token Your api token string.
149     * @param apiKey Your api-key string.
150     * @return an instance of LilyAI associated with provided token and api-key.
151     */
152    public static LilyAi getInstance(Context context, String baseUrl, String token, String apiKey) {
153        if (context == null) {
154            Log.e(Constants.LOG_TAG, "Context can't be null.");
155            return null;
156        }
157
158        if (baseUrl == null) {
159            baseUrl = Utils.getMetadataString(context, Constants.META_BASE_URL_KEY);
160            if (baseUrl == null) {
161                Log.e(Constants.LOG_TAG, "Application meta-data " + Constants.META_BASE_URL_KEY +
162                        " is not set. Set it or provide it with getInstance() method.");
163                return null;
164            }
165        }
166
167        if (token == null) {
168            token = Utils.getMetadataString(context, Constants.META_API_TOKEN_KEY);
169            if (token == null) {
170                Log.e(Constants.LOG_TAG, "Application meta-data " + Constants.META_API_TOKEN_KEY +
171                        " is not set. Set it or provide it with getInstance() method.");
172                return null;
173            }
174        }
175
176        if (apiKey == null) {
177            apiKey = Utils.getMetadataString(context, Constants.META_API_KEY_KEY);
178            if (apiKey == null) {
179                Log.e(Constants.LOG_TAG, "Application meta-data " + Constants.META_API_KEY_KEY +
180                        " is not set. Set it or provide it with getInstance() method.");
181                return null;
182            }
183        }
184
185        synchronized (instancesMap) {
186            LilyAi instance = instancesMap.get(apiKey+token+baseUrl);
187            if (instance == null) {
188                instance = new LilyAi(context, baseUrl, token, apiKey);
189                instancesMap.put(apiKey+token+baseUrl, instance);
190            }
191            return instance;
192        }
193    }
194
195    /**
196     * Reset the stored UUID to a new random value.
197     */
198    public void resetUUID() {
199        uuid = Utils.getUUID(prefs, false);
200        sessionId = null;
201        prefs.edit().putString(Constants.SESSION_ID_PREF_NAME, null).apply();
202    }
203
204    /**
205     * Set a new custom UUID value.
206     *
207     * @param uuid New UUID value to store and use. Can't be null or empty string.
208     */
209    public void setUUID(@NotNull String uuid) {
210        if (uuid.isEmpty()) {
211            Log.e(Constants.LOG_TAG, "LilyAI UUID can't be null or empty string! Ignoring setUUID() call with argument "+ uuid);
212            return;
213        }
214        if (!uuid.equals(this.uuid)) {
215            sessionId = null;
216            prefs.edit().putString(Constants.SESSION_ID_PREF_NAME, null).apply();
217        }
218        this.uuid = uuid;
219        Utils.updateUUID(prefs, uuid);
220    }
221
222    /**
223     * Get current UUID value stored by LilyAI.
224     *
225     * @return UUID string
226     */
227    public String getUUID() {
228        return uuid;
229    }
230
231    /**
232     * Set session duration time.
233     *
234     * @param sessionDuration max session duration time (millis)
235     */
236    public void setSessionDuration(long sessionDuration) {
237        this.sessionDuration = sessionDuration;
238        prefs.edit().putLong(Constants.SESSION_DURATION, sessionDuration).apply();
239    }
240
241    /**
242     * Send your data to LilyAI.
243     * Data is sent asynchronously.
244     * Same as {@link #track(JSONObject, LilyAiListener)} with listener set to null.
245     *
246     * @param data Your JSON data.
247     */
248    public void track(JSONObject data) {
249        track(data, null);
250    }
251
252    /**
253     * Send your data to LilyAI.
254     * Data is sent asynchronously.
255     * Methods of {@link LilyAiListener} will be called on the MainLooper.
256     *
257     * @param data Your JSON data. If data is null {@link LilyAiListener#onError(String)} will be called with message "JSON data is null".
258     * @param listener Callbacks for data send success or error. Can be null.
259     */
260    public void track(JSONObject data, final LilyAiListener listener) {
261        if (data == null) {
262            if (listener != null) {
263                handler.post(new Runnable() {
264                    @Override
265                    public void run() {
266                        listener.onError("JSON data is null");
267                    }
268                });
269            }
270            return;
271        }
272        if (sessionLastEventOccurred != null && sessionLastEventOccurred > 0 && System.currentTimeMillis() - sessionLastEventOccurred > sessionDuration) {
273            sessionId = null;
274        }
275        if (sessionId == null) {
276            sessionId = getUUID() + "-" + System.currentTimeMillis();
277            sessionStartTime = System.currentTimeMillis();
278            prefs.edit().putString(Constants.SESSION_ID_PREF_NAME, sessionId)
279                    .putLong(Constants.SESSION_START_PREF_NAME, sessionStartTime)
280                    .apply();
281        }
282        addHeader("lsid", sessionId);
283        addHeader("lsstart", (sessionStartTime != null && sessionStartTime > 0) ? sessionStartTime.toString() : null);
284        addHeader("lsend", String.valueOf(System.currentTimeMillis() + sessionDuration));
285        sessionLastEventOccurred = System.currentTimeMillis();
286        prefs.edit().putLong(Constants.SESSION_LAST_EVENT_PREF_NAME, sessionLastEventOccurred).apply();
287        addHeader("x-api-key", apiKey);
288        addHeader("Api-Token", token);
289        addHeader("lpid", uuid);
290        synchronized (trackingDataStorage) {
291            TrackingData newData = new TrackingData(lastTrackId++, headers, data);
292            trackingDataStorage.put(newData, true);
293            executorService.submit(new Worker(baseUrl, newData, false, okHttpClient, workerListener, listener));
294            trackingDataStorage.getNotDelivered(-1, Constants.THREAD_POOL_SIZE);
295        }
296    }
297
298    public void addHeader(String name, String value) {
299        if (name != null) {
300            if (value == null) {
301                headers.remove(name);
302            } else {
303                headers.put(name, value);
304            }
305        }
306    }
307
308    public void setUserID(String uid) {
309        addHeader("uid", uid);
310    }
311
312    public void setAnalyticsProviderID(String aid) {
313        addHeader("aid", aid);
314    }
315
316    public void setAnalyticsSessionID(String sid) {
317        addHeader("sid", sid);
318    }
319
320    public void setHashedUserEmail(String uem) {
321        addHeader("uem", uem);
322    }
323
324    public void setReferer(String src, String mdm, String pgpath, String pgcat, String pgtype) {
325        addHeader("src", src);
326        addHeader("mdm", mdm);
327        addHeader("pgpath", pgpath);
328        addHeader("pgcat", pgcat);
329        addHeader("pgtype", pgtype);
330    }
331
332    public void setExperimentId(String expid) {
333        addHeader("expid", expid);
334    }
335
336    public void setUserSourceIP(String sip) {
337        addHeader("sip", sip);
338    }
339
340    public void setVisitorId(String vid) {
341        addHeader("vid", vid);
342    }
343
344    private final WorkerListener workerListener = new WorkerListener() {
345        @Override
346        public void onSuccess(TrackingData data, boolean isFromRetry, final LilyAiListener lilyAiListener) {
347            synchronized (trackingDataStorage) {
348                trackingDataStorage.delivered(data);
349                if (isFromRetry) {
350                    trackingDataStorage.getNotDelivered(data.id, 1);
351                }
352            }
353            if (lilyAiListener != null) {
354                handler.post(new Runnable() {
355                    @Override
356                    public void run() {
357                        lilyAiListener.onSuccess();
358                    }
359                });
360            }
361        }
362
363        @Override
364        public void onError(TrackingData data, boolean isFromRetry, int code, final String message, final LilyAiListener lilyAiListener) {
365            synchronized (trackingDataStorage) {
366                if (code <= 0 || (code >= 500 && code < 600)) {
367                    trackingDataStorage.put(data, false);
368                } else {
369                    trackingDataStorage.delivered(data);
370                    if (isFromRetry) {
371                        trackingDataStorage.getNotDelivered(data.id, 1);
372                    }
373                }
374            }
375
376            if (lilyAiListener != null) {
377                handler.post(new Runnable() {
378                    @Override
379                    public void run() {
380                        lilyAiListener.onError(message);
381                    }
382                });
383            }
384        }
385    };
386}