com.deltadna.android.sdk.DDNA.java Source code

Java tutorial

Introduction

Here is the source code for com.deltadna.android.sdk.DDNA.java

Source

/*
 * Copyright (c) 2016 deltaDNA Ltd. All rights reserved.
 * 
 * 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.deltadna.android.sdk;

import android.app.Application;
import android.support.annotation.Nullable;
import android.text.TextUtils;
import android.util.Log;

import com.deltadna.android.sdk.exceptions.NotInitialisedException;
import com.deltadna.android.sdk.helpers.ClientInfo;
import com.deltadna.android.sdk.helpers.EngageArchive;
import com.deltadna.android.sdk.helpers.Preconditions;
import com.deltadna.android.sdk.helpers.Settings;
import com.deltadna.android.sdk.listeners.EngageListener;
import com.deltadna.android.sdk.listeners.ImageMessageListener;
import com.deltadna.android.sdk.listeners.SessionListener;
import com.deltadna.android.sdk.net.NetworkManager;

import org.json.JSONException;
import org.json.JSONObject;

import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.Locale;
import java.util.Set;
import java.util.TimeZone;
import java.util.UUID;
import java.util.WeakHashMap;

/**
 * Singleton class for accessing the deltaDNA SDK.
 * <p>
 * The singleton instance must first be initialised by calling
 * {@link DDNA#initialise(Configuration)} from a {@link Application}'s
 * {@link Application#onCreate()} method. Following this call an instance can
 * be accessed at any time through {@link DDNA#instance()}.
 * <p>
 * Prior to sending events, or performing engagements, the SDK must be started
 * through {@link #startSdk()} or {@link #startSdk(String)}. {@link #stopSdk()}
 * should be called when the game is stopped.
 * <p>
 * To customise behaviour after initialisation you can call
 * {@link #getSettings()} to get access to the {@link Settings}.
 */
public final class DDNA {

    private static final String SDK_VERSION = "Android SDK v" + BuildConfig.VERSION_NAME;
    private static final int ENGAGE_API_VERSION = 4;

    private static final String ENGAGE_STORAGE_PATH = "%s/ddsdk/engage/";

    private static final SimpleDateFormat TIMESTAMP_FORMAT;
    static {
        final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US);
        format.setTimeZone(TimeZone.getTimeZone("UTC"));

        TIMESTAMP_FORMAT = format;
    }

    private static DDNA instance;

    private final Settings settings;
    @Nullable
    private final String clientVersion;

    private final Preferences preferences;
    private final EventStore store;
    private final EngageArchive archive;
    private final NetworkManager network;

    private final SessionRefreshHandler sessionHandler;
    private final EventHandler eventHandler;

    private final String engageStoragePath;

    private boolean started;
    private String sessionId = UUID.randomUUID().toString();

    private Set<SessionListener> sessionListeners = Collections
            .newSetFromMap(new WeakHashMap<SessionListener, Boolean>(1));

    public static synchronized DDNA initialise(Configuration configuration) {
        Preconditions.checkArg(configuration != null, "configuration cannot be null");

        if (instance == null) {
            instance = new DDNA(configuration.application, configuration.environmentKey, configuration.collectUrl,
                    configuration.engageUrl, configuration.settings, configuration.hashSecret,
                    configuration.clientVersion, configuration.userId);
        } else {
            Log.w(BuildConfig.LOG_TAG, "SDK has already been initialised");
        }

        return instance;
    }

    public static synchronized DDNA instance() {
        if (instance == null) {
            throw new NotInitialisedException();
        }

        return instance;
    }

    /**
     * Starts the SDK.
     * <p>
     * This method needs to be called before sending events or making
     * engagements.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA startSdk() {
        return startSdk(null);
    }

    /**
     * Starts the SDK.
     * <p>
     * This method needs to be called before sending events or making
     * engagements.
     *
     * @param userId the user id, may be {@code null} in which case the
     *               SDK will generate an id.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA startSdk(@Nullable String userId) {
        Log.d(BuildConfig.LOG_TAG, "Starting SDK");

        if (started) {
            Log.w(BuildConfig.LOG_TAG, "SDK already started");
        } else {
            setUserId(userId);
            newSession(true);

            started = true;

            if (settings.getSessionTimeout() > 0) {
                sessionHandler.register();
            }

            if (settings.backgroundEventUpload()) {
                eventHandler.start(settings.backgroundEventUploadStartDelaySeconds(),
                        settings.backgroundEventUploadRepeatRateSeconds());
            }

            triggerDefaultEvents();

            Log.d(BuildConfig.LOG_TAG, "SDK started");
        }

        return this;
    }

    /**
     * Stops the SDK.
     * <p>
     * Calling this method sends a 'gameEnded' event to Collect, disables
     * background uploads and automatic session refreshing.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA stopSdk() {
        Log.d(BuildConfig.LOG_TAG, "Stopping SDK");

        if (!started) {
            Log.w(BuildConfig.LOG_TAG, "SDK has not been started");
        } else {
            recordEvent("gameEnded");

            sessionHandler.unregister();
            eventHandler.stop(true);
            if (archive != null) {
                archive.save();
            }

            Log.d(BuildConfig.LOG_TAG, "SDK stopped");
            started = false;
        }

        return this;
    }

    /**
     * Queries the status of the SDK.
     *
     * @return {@code true} if the SDK has been started, else {@code false}
     */
    public boolean isStarted() {
        return started;
    }

    /**
     * Changes the session id.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA newSession() {
        return newSession(false);
    }

    DDNA newSession(boolean suppressWarning) {
        if (!suppressWarning && settings.getSessionTimeout() > 0) {
            Log.w(BuildConfig.LOG_TAG, "Automatic session refreshing is enabled");
        }

        sessionId = UUID.randomUUID().toString();

        for (final SessionListener listener : sessionListeners) {
            listener.onSessionUpdated();
        }

        return this;
    }

    /**
     * Records an event with Collect.
     *
     * @param name the name of the event
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code name} is null or empty
     */
    public DDNA recordEvent(String name) {
        return recordEvent(new Event(name));
    }

    /**
     * Records an event with Collect.
     *
     * @param event the event
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code event} is null
     */
    public DDNA recordEvent(Event event) {
        Preconditions.checkArg(event != null, "event cannot be null");

        if (!started) {
            Log.w(BuildConfig.LOG_TAG, "SDK has not been started");
        }

        final JSONObject jsonEvent = new JSONObject();
        try {
            jsonEvent.put("eventName", event.name);
            jsonEvent.put("eventTimestamp", getCurrentTimestamp());
            jsonEvent.put("eventUUID", UUID.randomUUID().toString());
            jsonEvent.put("sessionID", sessionId);
            jsonEvent.put("userID", getUserId());

            JSONObject params = new JSONObject(event.params.toJson().toString());
            params.put("platform", ClientInfo.platform());
            params.put("sdkVersion", SDK_VERSION);

            jsonEvent.put("eventParams", params);
        } catch (JSONException e) {
            // should never happen due to params enforcement
            throw new IllegalArgumentException(e);
        }

        eventHandler.handleEvent(jsonEvent);

        return this;
    }

    /**
     * Records an event with Collect.
     *
     * @param name      the name of the event
     * @param params    the parameters of the event, may be {@code null}
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code name} is null or empty
     *
     * @deprecated as of version 4, replaced by {@link #recordEvent(Event)}
     */
    @Deprecated
    public DDNA recordEvent(String name, @Nullable Params params) {
        return recordEvent((params != null) ? new Event(name, params) : new Event(name));
    }

    /**
     * Records an event with Collect.
     *
     * @param name      name of the event
     * @param params    parameters of the event, may be {@code null}
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code name} is null or empty
     *
     * @deprecated as of version 4, replaced by {@link #recordEvent(Event)}
     */
    @Deprecated
    public DDNA recordEvent(String name, @Nullable JSONObject params) {
        return recordEvent((params != null) ? new Event(name, new Params(params)) : new Event(name));
    }

    /**
     * Record when a push notification has been opened.
     *
     * @return this {@link DDNA} instance
     *
     * @deprecated  as of version 4.1.2, replaced by
     *              {@link #recordNotificationOpened(boolean)}
     */
    @Deprecated
    public DDNA recordNotificationOpened() {
        return recordNotificationOpened(true);
    }

    /**
     * Record when a push notification has been opened.
     *
     * @param launch whether the notification launched the app
     *
     * @return this {@link DDNA} instance
     */
    public DDNA recordNotificationOpened(boolean launch) {
        return recordEvent(new Event("notificationOpened").putParam("notificationLaunch", launch));
    }

    /**
     * Record when a push notification has been dismissed.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA recordNotificationDismissed() {
        return recordEvent(new Event("notificationOpened").putParam("notificationLaunch", false));
    }

    /**
     * Makes an Engage request.
     * <p>
    * The result will be passed into the provided {@code listener} through
    * one of the callback methods on the main UI thread, even if this method
    * was called from a background thread.
     *
     * @param decisionPoint the decision point for the engagement
     * @param listener      listener for the result
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code decisionPoint} is null
     *                                  or empty
     */
    public DDNA requestEngagement(String decisionPoint, EngageListener<Engagement> listener) {

        return requestEngagement(new Engagement(decisionPoint), listener);
    }

    /**
     * Makes an Engage request.
     * <p>
    * The result will be passed into the provided {@code listener} through
    * one of the callback methods on the main UI thread, even if this method
    * was called from a background thread.
     *
     * @param engagement    the engagement
     * @param listener      listener for the result
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code engagement} is null
     */
    public <E extends Engagement> DDNA requestEngagement(E engagement, EngageListener<E> listener) {

        Preconditions.checkArg(engagement != null, "engagement cannot be null");

        if (!started) {
            Log.w(BuildConfig.LOG_TAG, "SDK has not been started, aborting engagement");
            return this;
        }

        eventHandler.handleEngagement(engagement, listener, getUserId(), sessionId, ENGAGE_API_VERSION,
                SDK_VERSION);

        return this;
    }

    /**
     * Makes an Engage request.
     * <p>
     * The result will be passed into the provided {@code listener} through
     * one of the callback methods on the main UI thread, even if this method
     * was called from a background thread.
     *
     * @param decisionPoint decision point for the request, as defined
     *                      in the Portal
     * @param params        additional parameters for the engagement, may be
     *                      {@code null}
     * @param listener      listener for the result
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code decisionPoint} is null
     *                                  or empty
     *
     * @deprecated  as of version 4, replaced by
     *              {@link #requestEngagement(Engagement, EngageListener)}
     */
    @Deprecated
    public DDNA requestEngagement(String decisionPoint, @Nullable JSONObject params,
            EngageListener<Engagement> listener) {

        return requestEngagement(decisionPoint, null, params, listener);
    }

    /**
     * @param decisionPoint decision point for the request, as defined
     *                      in the Portal
     * @param flavour       flavour for the decision point, may be {@code null}
     * @param params        additional parameters for the engagement, may be
     *                      {@code null}
     * @param listener      listener for the result
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code decisionPoint} is null
     *                                  or empty
     *
     * @deprecated  as of version 4, replaced by
     *              {@link #requestEngagement(Engagement, EngageListener)}
     */
    @Deprecated
    public DDNA requestEngagement(String decisionPoint, @Nullable String flavour, @Nullable JSONObject params,
            EngageListener<Engagement> listener) {

        return requestEngagement((params != null) ? new Engagement(decisionPoint, flavour, new Params(params))
                : new Engagement(decisionPoint, flavour), listener);
    }

    /**
     * Makes a simple Image Message request.
     * <p>
     * The result will be passed into the provided {@code listener} through
     * one of the callback methods on the main UI thread, even if this method
     * was called from a background thread.
     *
     * @param decisionPoint the decision point for the engagement
     * @param listener      listener for the result
     *
     * @return this {@link DDNA} instance
     *
     * @throws IllegalArgumentException if the {@code decisionPoint} is null
     *                                  or empty
     *
     * @deprecated  as of version 4.1, replaced by
     *              {@link #requestEngagement(Engagement, EngageListener)}
     */
    @Deprecated
    public DDNA requestImageMessage(String decisionPoint, ImageMessageListener listener) {

        return requestImageMessage(new Engagement(decisionPoint), listener);
    }

    /**
     * Makes an Image Message request.
     * <p>
     * The result will be passed into the provided {@code listener} through
     * one of the callback methods on the main UI thread, even if this method
     * was called from a background thread.
     *
     * @param engagement    the engagement
     * @param listener      listener for the result
     *
     * @return this {@link DDNA} instance
     *
     * @deprecated  as of version 4.1, replaced by
     *              {@link #requestEngagement(Engagement, EngageListener)}
     */
    @Deprecated
    public DDNA requestImageMessage(Engagement engagement, ImageMessageListener listener) {

        return requestEngagement(engagement, listener);
    }

    /**
     * Sends pending events to our platform.
     * <p>
     * This is usually called automatically, but in the case that automatic
     * event uploads are disabled the game will be responsible for calling
     * this method at suitable points in the game flow.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA upload() {
        eventHandler.dispatch();
        return this;
    }

    /**
     * Gets the user id.
     *
     * @return  the user id, may be {@code null} if the user id hasn't
     *          been set or generated by the SDK at this point
     */
    @Nullable
    public String getUserId() {
        return preferences.getUserId();
    }

    /**
     * Sets the user id.
     * <p>
     * Will be applied next time the SDK will be started.
     *
     * @param userId the user id, may be {@code null} in which case
     *               the SDK will generate a user id internally.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA setUserId(@Nullable String userId) {
        final String currentUserId = getUserId();
        final String newUserId;
        boolean changed = false;

        if (TextUtils.isEmpty(currentUserId)) {
            if (TextUtils.isEmpty(userId)) {
                newUserId = UUID.randomUUID().toString();
                Log.d(BuildConfig.LOG_TAG, "Generated user id " + newUserId);
            } else {
                newUserId = userId;
            }
        } else {
            if (!TextUtils.isEmpty(userId) && !currentUserId.equals(userId)) {

                Log.d(BuildConfig.LOG_TAG,
                        String.format(Locale.US, "User id has changed from %s to %s", currentUserId, userId));

                changed = true;
                newUserId = userId;
            } else {
                Log.d(BuildConfig.LOG_TAG, "User id has not changed");
                return this;
            }
        }

        preferences.setUserId(newUserId);
        if (changed) {
            preferences.clearFirstRun();
        }

        return this;
    }

    /**
     * Gets the registration id for push notifications.
     *
     * @return the registration id, may be {@code null} if not set
     */
    @Nullable
    public String getRegistrationId() {
        return preferences.getRegistrationId();
    }

    /**
     * Sets the registration id for push notifications.
     *
     * @param registrationId the registration id, may be {@code null}
     *                       in order to unregister from notifications
     *
     * @return this {@link DDNA} instance
     */
    public DDNA setRegistrationId(@Nullable String registrationId) {
        preferences.setRegistrationId(registrationId);
        return recordEvent(new Event("notificationServices").putParam("androidRegistrationID",
                (registrationId == null) ? "" : registrationId));
    }

    /**
     * Clears the registration id associated with this device for disabling
     * push notifications.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA clearRegistrationId() {
        return setRegistrationId(null);
    }

    /**
     * Clears persistent data, such as the user id, Collect events,
     * and Engage cache.
     *
     * @return this {@link DDNA} instance
     */
    public DDNA clearPersistentData() {
        preferences.clear();
        store.clear();
        archive.clear();

        return this;
    }

    public Settings getSettings() {
        return settings;
    }

    public String getSessionId() {
        return sessionId;
    }

    String getEngageStoragePath() {
        return engageStoragePath;
    }

    // FIXME should not be exposed
    public NetworkManager getNetworkManager() {
        return network;
    }

    public DDNA register(SessionListener listener) {
        sessionListeners.add(listener);
        return this;
    }

    public DDNA unregister(SessionListener listener) {
        sessionListeners.remove(listener);
        return this;
    }

    /**
     * Fires the default events, should only be called from
     * {@link #startSdk(String)}.
     */
    private void triggerDefaultEvents() {
        if (settings.onFirstRunSendNewPlayerEvent() && preferences.getFirstRun() > 0) {

            Log.d(BuildConfig.LOG_TAG, "Recording 'newPlayer' event");

            recordEvent(new Event("newPlayer").putParam("userCountry", ClientInfo.countryCode()));

            preferences.setFirstRun(0);
        }

        if (settings.onInitSendGameStartedEvent()) {
            Log.d(BuildConfig.LOG_TAG, "Recording 'gameStarted' event");

            final Event event = new Event("gameStarted").putParam("userLocale", ClientInfo.locale());
            if (!TextUtils.isEmpty(clientVersion)) {
                event.putParam("clientVersion", clientVersion);
            }
            if (getRegistrationId() != null) {
                event.putParam("androidRegistrationID", getRegistrationId());
            }

            recordEvent(event);
        }

        if (settings.onInitSendClientDeviceEvent()) {
            Log.d(BuildConfig.LOG_TAG, "Recording 'clientDevice' event");

            recordEvent(new Event("clientDevice").putParam("deviceName", ClientInfo.deviceName())
                    .putParam("deviceType", ClientInfo.deviceType())
                    .putParam("hardwareVersion", ClientInfo.deviceModel())
                    .putParam("operatingSystem", ClientInfo.operatingSystem())
                    .putParam("operatingSystemVersion", ClientInfo.operatingSystemVersion())
                    .putParam("manufacturer", ClientInfo.manufacturer())
                    .putParam("timezoneOffset", ClientInfo.timezoneOffset())
                    .putParam("userLanguage", ClientInfo.languageCode()));
        }
    }

    DDNA(Application application, String environmentKey, String collectUrl, String engageUrl, Settings settings,
            @Nullable String hashSecret, @Nullable String clientVersion, @Nullable String userId) {

        this.settings = settings;
        this.clientVersion = clientVersion;

        // FIXME event archive
        final File dir = application.getExternalFilesDir(null);
        final String path = (dir != null) ? dir.getAbsolutePath() : "/";

        preferences = new Preferences(application);
        store = new EventStore(application, settings, preferences);
        archive = new EngageArchive(engageStoragePath = String.format(Locale.US, ENGAGE_STORAGE_PATH, path));

        sessionHandler = new SessionRefreshHandler(application, settings, new SessionRefreshHandler.Listener() {
            @Override
            public void onExpired() {
                Log.d(BuildConfig.LOG_TAG, "Session expired, updating id");
                newSession(true);
            }
        });
        eventHandler = new EventHandler(store, archive,
                network = new NetworkManager(environmentKey, collectUrl, engageUrl, settings, hashSecret));

        setUserId(userId);
    }

    private static String validateUrl(String url) {
        if (!url.toLowerCase(Locale.US).startsWith("http://")
                && !url.toLowerCase(Locale.US).startsWith("https://")) {

            return "http://" + url;
        }

        return url;
    }

    private static String getCurrentTimestamp() {
        return TIMESTAMP_FORMAT.format(new Date());
    }

    /**
     * Class for providing a configuration when initialising the
     * SDK through {@link DDNA#initialise(Configuration)} inside of an
     * {@link Application} class.
     */
    public static final class Configuration {

        private final Application application;
        private final String environmentKey;
        private final String collectUrl;
        private final String engageUrl;

        @Nullable
        private String hashSecret;
        @Nullable
        private String clientVersion;
        @Nullable
        private String userId;

        private final Settings settings;

        public Configuration(Application application, String environmentKey, String collectUrl, String engageUrl) {

            Preconditions.checkArg(application != null, "application cannot be null");
            Preconditions.checkArg(!TextUtils.isEmpty(environmentKey), "environmentKey cannot be null or empty");
            Preconditions.checkArg(!TextUtils.isEmpty(collectUrl), "collectUrl cannot be null or empty");
            Preconditions.checkArg(!TextUtils.isEmpty(engageUrl), "engageUrl cannot be null or empty");

            this.application = application;
            this.environmentKey = environmentKey;
            this.collectUrl = validateUrl(collectUrl);
            this.engageUrl = validateUrl(engageUrl);

            this.settings = new Settings();
        }

        /**
         * Sets the hash secret.
         *
         * @param hashSecret the hash secret
         *
         * @return this {@link Configuration} instance
         */
        public Configuration hashSecret(@Nullable String hashSecret) {
            this.hashSecret = hashSecret;
            return this;
        }

        /**
         * Sets the client version.
         * <p>
         * Could be the {@code VERSION_NAME} from your application's
         * {@code BuildConfig}.
         *
         * @param clientVersion the client version
         *
         * @return this {@link Configuration} instance
         */
        public Configuration clientVersion(@Nullable String clientVersion) {
            this.clientVersion = clientVersion;
            return this;
        }

        /**
         * Sets the user id.
         * <p>
         * You may use this method to set the user id if you create
         * one in your {@link Application} class, else you may ignore
         * it and set the id later, or let the SDK create its own id.
         *
         * @param userId the user id
         *
         * @return this {@link Configuration} instance
         */
        public Configuration userId(@Nullable String userId) {
            this.userId = userId;
            return this;
        }

        /**
         * Allows changing of {@link Settings} values.
         *
         * @param modifier the settings modifier
         *
         * @return this {@link Configuration} instance
         */
        public Configuration withSettings(SettingsModifier modifier) {
            modifier.modify(settings);
            return this;
        }
    }

    public interface SettingsModifier {

        void modify(Settings settings);
    }
}