io.teak.sdk.Session.java Source code

Java tutorial

Introduction

Here is the source code for io.teak.sdk.Session.java

Source

/* Teak -- Copyright (C) 2016 GoCarrot 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.teak.sdk;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.support.annotation.NonNull;
import android.util.Log;

import org.json.JSONObject;

import java.net.URL;
import java.net.URLEncoder;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.Locale;
import java.util.TimeZone;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

import javax.net.ssl.HttpsURLConnection;

class Session {
    private static final String LOG_TAG = "Teak:Session";
    private static final long SAME_SESSION_TIME_DELTA = 120000;

    // region State machine
    public enum State {
        Invalid("Invalid"), Allocated("Allocated"), Created("Created"), Configured("Configured"), UserIdentified(
                "UserIdentified"), Expiring("Expiring"), Expired("Expired");

        //public static final Integer length = 1 + Expired.ordinal();

        private static final State[][] allowedTransitions = { {}, { State.Created, State.Expiring },
                { State.Configured, State.Expiring }, { State.UserIdentified, State.Expiring }, { State.Expiring },
                { State.Created, State.Configured, State.UserIdentified, State.Expired }, {} };

        public final String name;

        State(String name) {
            this.name = name;
        }

        public boolean canTransitionTo(State nextState) {
            if (nextState == State.Invalid)
                return true;

            for (State allowedTransition : allowedTransitions[this.ordinal()]) {
                if (nextState == allowedTransition)
                    return true;
            }
            return false;
        }
    }

    private State state = State.Allocated;
    private State previousState = null;
    private final Object stateMutex = new Object();
    // endregion

    // region Event Listener
    public interface EventListener {
        void onStateChange(Session session, State oldState, State newState);
    }

    private static final Object eventListenersMutex = new Object();
    private static ArrayList<EventListener> eventListeners = new ArrayList<>();

    public static void addEventListener(EventListener e) {
        synchronized (eventListenersMutex) {
            if (!eventListeners.contains(e)) {
                eventListeners.add(e);
            }
        }
    }

    public static void removeEventListener(EventListener e) {
        synchronized (eventListenersMutex) {
            eventListeners.remove(e);
        }
    }
    // endregion

    // State: Created
    public final Date startDate;
    public final AppConfiguration appConfiguration;
    public final DeviceConfiguration deviceConfiguration;

    // State: Configured
    public RemoteConfiguration remoteConfiguration;

    // State: UserIdentified
    private String userId;

    private ScheduledExecutorService heartbeatService;
    private String countryCode;
    private String facebookAccessToken;

    // State: Expiring
    private Date endDate;

    // State Independent
    private String launchedFromTeakNotifId;
    private String launchedFromDeepLink;
    private ArrayList<String> attributionChain = new ArrayList<>();

    private Session(AppConfiguration appConfiguration, DeviceConfiguration deviceConfiguration) {
        // State: Created
        // Valid data:
        // - startDate
        // - appConfiguration
        // - deviceConfiguration
        this.startDate = new Date();
        this.appConfiguration = appConfiguration;
        this.deviceConfiguration = deviceConfiguration;

        DeviceConfiguration.addEventListener(this.deviceConfigurationListener);

        IntentFilter filter = new IntentFilter();
        filter.addAction(FacebookAccessTokenBroadcast.UPDATED_ACCESS_TOKEN_INTENT_ACTION);
        Teak.localBroadcastManager.registerReceiver(this.facebookBroadcastReceiver, filter);

        setState(State.Created);
    }

    public boolean hasExpired() {
        synchronized (stateMutex) {
            if (this.state == State.Expiring
                    && (new Date().getTime() - currentSession.endDate.getTime() > SAME_SESSION_TIME_DELTA)) {
                setState(State.Expired);
            }
            return (this.state == State.Expired);
        }
    }

    public static void setUserId(@NonNull String userId) {
        if (userId.isEmpty()) {
            Log.e(LOG_TAG, "User Id can not be null or empty.");
            return;
        }

        // If the user id has changed, create a new session
        synchronized (currentSessionMutex) {
            synchronized (currentSession.stateMutex) {
                if (currentSession.userId != null && !currentSession.userId.equals(userId)) {
                    Session newSession = new Session(currentSession.appConfiguration,
                            currentSession.deviceConfiguration);
                    newSession.attributionChain.addAll(currentSession.attributionChain);

                    currentSession.setState(State.Expiring);
                    currentSession.setState(State.Expired);

                    currentSession = newSession;
                }

                currentSession.userId = userId;

                if (currentSession.state == State.Configured) {
                    currentSession.identifyUser();
                }
            }
        }
    }

    private boolean setState(@NonNull State newState) {
        synchronized (stateMutex) {
            if (this.state == newState) {
                Log.i(LOG_TAG, String.format("Session State transition to same state (%s). Ignoring.", this.state));
                return false;
            }

            if (!this.state.canTransitionTo(newState)) {
                Log.e(LOG_TAG, String.format("Invalid Session State transition (%s -> %s). Ignoring.", this.state,
                        newState));
                return false;
            }

            ArrayList<Object[]> invalidValuesForTransition = new ArrayList<>();

            // Check the data that should be valid before transitioning to the next state. Perform any
            // logic that should occur on transition.
            switch (newState) {
            case Created: {
                if (this.startDate == null) {
                    invalidValuesForTransition.add(new Object[] { "startDate", "null" });
                    break;
                }

                if (this.appConfiguration == null) {
                    invalidValuesForTransition.add(new Object[] { "appConfiguration", "null" });
                    break;
                }

                if (this.deviceConfiguration == null) {
                    invalidValuesForTransition.add(new Object[] { "deviceConfiguration", "null" });
                    break;
                }

                RemoteConfiguration.addEventListener(remoteConfigurationListener);
            }
                break;

            case Configured: {
                if (this.remoteConfiguration == null) {
                    invalidValuesForTransition.add(new Object[] { "remoteConfiguration", "null" });
                    break;
                }

                RemoteConfiguration.removeEventListener(remoteConfigurationListener);

                if (this.userId != null) {
                    this.identifyUser();
                }
            }
                break;

            case UserIdentified: {
                if (this.userId == null) {
                    invalidValuesForTransition.add(new Object[] { "userId", "null" });
                    break;
                }

                // Start heartbeat, heartbeat service should be null right now
                if (this.heartbeatService != null) {
                    invalidValuesForTransition.add(new Object[] { "heartbeatService", this.heartbeatService });
                    break;
                }

                startHeartbeat();

                synchronized (userIdReadyRunnableQueueMutex) {
                    for (WhenUserIdIsReadyRun runnable : userIdReadyRunnableQueue) {
                        new Thread(runnable).start();
                    }
                    userIdReadyRunnableQueue.clear();
                }
            }
                break;

            case Expiring: {
                this.endDate = new Date();

                // TODO: When expiring, punt to background service and say "Hey check the state of this session in N seconds"

                // Stop heartbeat, Expiring->Expiring is possible, so no invalid data here
                if (this.heartbeatService != null) {
                    this.heartbeatService.shutdown();
                    this.heartbeatService = null;
                }
            }
                break;

            case Expired: {
                DeviceConfiguration.removeEventListener(this.deviceConfigurationListener);
                if (Teak.localBroadcastManager != null) {
                    Teak.localBroadcastManager.unregisterReceiver(this.facebookBroadcastReceiver);
                }

                // TODO: Report Session to server, once we collect that info.
            }
                break;
            }

            // Print out any invalid values
            if (invalidValuesForTransition.size() > 0) {
                Log.e(LOG_TAG, String.format(
                        "Invalid Session value%s while trying to transition from %s -> %s. Invalidating Session.",
                        invalidValuesForTransition.size() > 1 ? "s" : "", this.state, newState));
                for (Object[] invalidValue : invalidValuesForTransition) {
                    Log.e(LOG_TAG, String.format(Locale.US, "\t%s: %s", invalidValue[0], invalidValue[1]));
                }

                // Invalidate this session
                this.setState(State.Invalid);
                return false;
            }

            this.previousState = this.state;
            this.state = newState;

            if (Teak.isDebug) {
                Log.d(LOG_TAG,
                        String.format("Session State transition from %s -> %s.", this.previousState, this.state));
            }

            synchronized (eventListenersMutex) {
                for (EventListener e : eventListeners) {
                    e.onStateChange(this, this.previousState, this.state);
                }
            }
            return true;
        }
    }

    private void startHeartbeat() {
        final Session _this = this;

        this.heartbeatService = Executors.newSingleThreadScheduledExecutor();
        this.heartbeatService.scheduleAtFixedRate(new Runnable() {
            public void run() {
                if (Teak.isDebug) {
                    Log.v(LOG_TAG, "Sending heartbeat for user: " + userId);
                }

                HttpsURLConnection connection = null;
                try {
                    String queryString = "game_id=" + URLEncoder.encode(_this.appConfiguration.appId, "UTF-8")
                            + "&api_key=" + URLEncoder.encode(_this.userId, "UTF-8") + "&sdk_version="
                            + URLEncoder.encode(Teak.SDKVersion, "UTF-8") + "&sdk_platform="
                            + URLEncoder.encode(_this.deviceConfiguration.platformString, "UTF-8") + "&app_version="
                            + URLEncoder.encode(String.valueOf(_this.appConfiguration.appVersion), "UTF-8")
                            + (_this.countryCode == null ? ""
                                    : "&country_code="
                                            + URLEncoder.encode(String.valueOf(_this.countryCode), "UTF-8"))
                            + "&buster=" + URLEncoder.encode(UUID.randomUUID().toString(), "UTF-8");
                    URL url = new URL("https://iroko.gocarrot.com/ping?" + queryString);
                    connection = (HttpsURLConnection) url.openConnection();
                    connection.setRequestProperty("Accept-Charset", "UTF-8");
                    connection.setUseCaches(false);

                    int responseCode = connection.getResponseCode();
                    if (Teak.isDebug) {
                        Log.v(LOG_TAG, "Heartbeat response code: " + responseCode);
                    }
                } catch (Exception e) {
                    Log.e(LOG_TAG, Log.getStackTraceString(e));
                } finally {
                    if (connection != null) {
                        connection.disconnect();
                    }
                }
            }
        }, 0, 1, TimeUnit.MINUTES); // TODO: If RemoteConfiguration specifies a different rate, use that
    }

    private void identifyUser() {
        final Session _this = this;

        new Thread(new Runnable() {
            public void run() {
                synchronized (_this.stateMutex) {
                    HashMap<String, Object> payload = new HashMap<>();

                    if (_this.state == State.UserIdentified) {
                        payload.put("do_not_track_event", Boolean.TRUE);
                    }

                    TimeZone tz = TimeZone.getDefault();
                    long rawTz = tz.getRawOffset();
                    if (tz.inDaylightTime(new Date())) {
                        rawTz += tz.getDSTSavings();
                    }
                    long minutes = TimeUnit.MINUTES.convert(rawTz, TimeUnit.MILLISECONDS);
                    String tzOffset = new DecimalFormat("#0.00").format(minutes / 60.0f);
                    payload.put("timezone", tzOffset);

                    String locale = Locale.getDefault().toString();
                    payload.put("locale", locale);

                    if (_this.deviceConfiguration.advertsingInfo != null) {
                        payload.put("android_ad_id", _this.deviceConfiguration.advertsingInfo.getId());
                        payload.put("android_limit_ad_tracking",
                                _this.deviceConfiguration.advertsingInfo.isLimitAdTrackingEnabled());
                    }

                    if (_this.facebookAccessToken != null) {
                        payload.put("access_token", _this.facebookAccessToken);
                    }

                    if (_this.launchedFromTeakNotifId != null) {
                        payload.put("teak_notif_id", Long.valueOf(_this.launchedFromTeakNotifId));
                    }

                    if (_this.launchedFromDeepLink != null) {
                        payload.put("deep_link", _this.launchedFromDeepLink);
                    }

                    if (_this.deviceConfiguration.gcmId != null) {
                        payload.put("gcm_push_key", _this.deviceConfiguration.gcmId);
                    } else {
                        payload.put("gcm_push_key", "");
                    }

                    Log.d(LOG_TAG, "Identifying user: " + _this.userId);
                    Log.d(LOG_TAG, "        Timezone: " + tzOffset);
                    Log.d(LOG_TAG, "          Locale: " + locale);

                    new Request("/games/" + _this.appConfiguration.appId + "/users.json", payload, _this) {
                        @Override
                        protected void done(int responseCode, String responseBody) {
                            try {
                                JSONObject response = new JSONObject(responseBody);

                                // TODO: Grab 'id' and 'game_id' from response and store for Parsnip

                                // Enable verbose logging if flagged
                                boolean enableVerboseLogging = response.optBoolean("verbose_logging");
                                if (Teak.debugConfiguration != null) {
                                    Teak.debugConfiguration.setPreferenceForceDebug(enableVerboseLogging);
                                }

                                // Server requesting new push key.
                                if (response.optBoolean("reset_push_key", false)) {
                                    _this.deviceConfiguration.reRegisterPushToken(_this.appConfiguration);
                                }

                                if (response.has("country_code")) {
                                    _this.countryCode = response.getString("country_code");
                                }

                                // Prevent warning for 'do_not_track_event'
                                if (_this.state != State.UserIdentified) {
                                    _this.setState(State.UserIdentified);
                                }
                            } catch (Exception ignored) {
                            }

                            super.done(responseCode, responseBody);
                        }
                    }.run();
                }
            }
        }).start();
    }

    public static abstract class SessionRunnable {
        public abstract void run(Session session);
    }

    private static class WhenUserIdIsReadyRun implements Runnable {
        private SessionRunnable runnable;

        public WhenUserIdIsReadyRun(SessionRunnable runnable) {
            this.runnable = runnable;
        }

        @Override
        public void run() {
            synchronized (currentSessionMutex) {
                this.runnable.run(currentSession);
            }
        }
    }

    private static final Object userIdReadyRunnableQueueMutex = new Object();
    private static final ArrayList<WhenUserIdIsReadyRun> userIdReadyRunnableQueue = new ArrayList<>();

    public static void whenUserIdIsReadyRun(@NonNull SessionRunnable runnable) {
        synchronized (currentSessionMutex) {
            if (currentSession == null) {
                synchronized (userIdReadyRunnableQueueMutex) {
                    userIdReadyRunnableQueue.add(new WhenUserIdIsReadyRun(runnable));
                }
            } else {
                synchronized (currentSession.stateMutex) {
                    if (currentSession.state == State.UserIdentified) {
                        new Thread(new WhenUserIdIsReadyRun(runnable)).start();
                    } else {
                        synchronized (userIdReadyRunnableQueueMutex) {
                            userIdReadyRunnableQueue.add(new WhenUserIdIsReadyRun(runnable));
                        }
                    }
                }
            }
        }
    }

    /**
     * Process an Intent and assign new values for launching from a deep link or Teak notification.
     * <p/>
     * If currentSession was launched via a deep link or notification, and the incoming intent has
     * a new (non null/empty) value. Create a new Session, cloning state from the old one.
     *
     * @param intent Incoming Intent to process.
     */
    public static void processIntent(Intent intent, @NonNull AppConfiguration appConfiguration,
            @NonNull DeviceConfiguration deviceConfiguration) {
        if (intent == null)
            return;

        synchronized (currentSessionMutex) {
            // Call getCurrentSession() so the null || Expired logic stays in one place
            getCurrentSession(appConfiguration, deviceConfiguration);

            // Check for launch via deep link
            String intentDataString = intent.getDataString();
            String launchedFromDeepLink = null;
            if (intentDataString != null && !intentDataString.isEmpty()) {
                launchedFromDeepLink = intentDataString;
                if (Teak.isDebug) {
                    Log.d(LOG_TAG, "Launch from deep link: " + launchedFromDeepLink);
                }
            }

            // Check for launch via notification
            Bundle bundle = intent.getExtras();
            String launchedFromTeakNotifId = null;
            if (bundle != null) {
                String teakNotifId = bundle.getString("teakNotifId");
                if (teakNotifId != null && !teakNotifId.isEmpty()) {
                    launchedFromTeakNotifId = teakNotifId;
                    if (Teak.isDebug) {
                        Log.d(LOG_TAG, "Launch from Teak notification: " + launchedFromTeakNotifId);
                    }
                }
            }

            // If the current session has a launch from deep link/notification, and there is a new
            // deep link/notification, it's a new session
            if (stringsAreNotNullOrEmptyAndAreDifferent(currentSession.launchedFromDeepLink, launchedFromDeepLink)
                    || stringsAreNotNullOrEmptyAndAreDifferent(currentSession.launchedFromTeakNotifId,
                            launchedFromTeakNotifId)) {
                Session oldSession = currentSession;
                currentSession = new Session(oldSession.appConfiguration, oldSession.deviceConfiguration);
                currentSession.userId = oldSession.userId;
                currentSession.attributionChain.addAll(oldSession.attributionChain);

                oldSession.setState(State.Expiring);
                oldSession.setState(State.Expired);
            }

            // Assign attribution
            if (launchedFromDeepLink != null && !launchedFromDeepLink.isEmpty()) {
                currentSession.launchedFromDeepLink = launchedFromDeepLink;
                currentSession.attributionChain.add(launchedFromDeepLink);
            } else if (launchedFromTeakNotifId != null && !launchedFromTeakNotifId.isEmpty()) {
                currentSession.launchedFromTeakNotifId = launchedFromTeakNotifId;
                currentSession.attributionChain.add(launchedFromTeakNotifId);
            }
        }
    }

    /**
     * Used to listen for when remote configuration is ready
     */
    private RemoteConfiguration.EventListener remoteConfigurationListener = new RemoteConfiguration.EventListener() {
        @Override
        public void onConfigurationReady(RemoteConfiguration configuration) {
            remoteConfiguration = configuration;
            setState(State.Configured);
        }
    };

    /**
     * Used to listen for when a GCM key or Advertising Info is changed.
     */
    private DeviceConfiguration.EventListener deviceConfigurationListener = new DeviceConfiguration.EventListener() {
        @Override
        public void onGCMIdChanged(DeviceConfiguration deviceConfiguration) {
            synchronized (stateMutex) {
                if (state == State.UserIdentified) {
                    identifyUser();
                }
            }
        }

        @Override
        public void onAdvertisingInfoChanged(DeviceConfiguration deviceConfiguration) {
            synchronized (stateMutex) {
                if (state == State.UserIdentified) {
                    identifyUser();
                }
            }
        }
    };

    /**
     * Used to listen for Facebook Access Token update
     */
    private BroadcastReceiver facebookBroadcastReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context unused, Intent intent) {
            String action = intent.getAction();
            if (FacebookAccessTokenBroadcast.UPDATED_ACCESS_TOKEN_INTENT_ACTION.equals(action)) {
                facebookAccessToken = intent.getStringExtra("accessToken");
                if (Teak.isDebug) {
                    Log.d(LOG_TAG, "Facebook Access Token updated: " + facebookAccessToken);
                }
                synchronized (stateMutex) {
                    if (state == State.UserIdentified) {
                        identifyUser();
                    }
                }
            }
        }
    };

    /**
     * Called by Teak lifecycle when activity is paused, set current session state to Expiring
     */
    public static void onActivityPaused() {
        synchronized (currentSessionMutex) {
            currentSession.setState(State.Expiring);
        }
    }

    /**
     * Called by Teak lifecycle when activity is resumed, reset state on current session if it's 'Expiring'
     */
    public static void onActivityResumed(AppConfiguration appConfiguration,
            DeviceConfiguration deviceConfiguration) {
        synchronized (currentSessionMutex) {
            // Call getCurrentSession() so the null || Expired logic stays in one place
            getCurrentSession(appConfiguration, deviceConfiguration);

            // Reset state on current session, if it is expiring
            synchronized (currentSession.stateMutex) {
                if (currentSession.state == State.Expiring) {
                    currentSession.setState(currentSession.previousState);
                }
            }
        }
    }

    // region Accessors
    public String userId() {
        return userId;
    }
    // endregion

    // region Current Session
    private static Session currentSession;
    private static final Object currentSessionMutex = new Object();

    public static Session getCurrentSession(AppConfiguration appConfiguration,
            DeviceConfiguration deviceConfiguration) {
        synchronized (currentSessionMutex) {
            if (currentSession == null || currentSession.hasExpired()) {
                Session oldSession = currentSession;
                currentSession = new Session(appConfiguration, deviceConfiguration);

                if (oldSession != null) {
                    currentSession.attributionChain.addAll(oldSession.attributionChain);
                }

                // If the old session had a user id assigned, it needs to be passed to the newly created
                // session. When setState(State.Configured) happens, it will call identifyUser()
                if (oldSession != null && oldSession.userId != null) {
                    if (Teak.isDebug) {
                        Log.d(LOG_TAG, "Previous Session expired, assigning user id '" + oldSession.userId
                                + " to new Session.");
                    }
                    setUserId(oldSession.userId);
                }
            }
            return currentSession;
        }
    }
    // endregion

    // region Helpers
    private static boolean stringsAreNotNullOrEmptyAndAreDifferent(String currentValue, String newValue) {
        return (currentValue != null && !currentValue.isEmpty() && newValue != null && !newValue.isEmpty()
                && !currentValue.equals(newValue));
    }
    // endregion
}