package net.hockeyapp.android.metrics;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import net.hockeyapp.android.Constants;
import net.hockeyapp.android.PrivateEventManager;
import net.hockeyapp.android.metrics.model.Data;
import net.hockeyapp.android.metrics.model.Domain;
import net.hockeyapp.android.metrics.model.EventData;
import net.hockeyapp.android.metrics.model.SessionState;
import net.hockeyapp.android.metrics.model.SessionStateData;
import net.hockeyapp.android.metrics.model.TelemetryData;
import net.hockeyapp.android.utils.AsyncTaskUtils;
import net.hockeyapp.android.utils.HockeyLog;
import net.hockeyapp.android.utils.Util;
import java.lang.ref.WeakReference;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* <h3>Description</h3>
* <p/>
* Provides functionality to gather User Metrics, both active users, sessions,
* and custom events.
*
**/
public class MetricsManager {
private static final String TAG = "HA-MetricsManager";
/**
* Whether User Metrics should be globally enabled or not.
* Includes both user/session telemetry as well as custom events.
* Default is true.
*/
private static boolean sUserMetricsEnabled = true;
/**
* The activity counter.
*/
protected static final AtomicInteger ACTIVITY_COUNT = new AtomicInteger(0);
/**
* The timestamp of the last activity background event.
*/
protected static final AtomicLong LAST_BACKGROUND = new AtomicLong(getTime());
/**
* Background time interval for the app after which a session gets renewed (in milliseconds).
*/
private static final Integer SESSION_RENEWAL_INTERVAL = 20 * 1000;
/**
* Synchronization lock for setting static context.
*/
private static final Object LOCK = new Object();
/**
* The MetricsManager singleton instance.
*/
private static volatile MetricsManager instance;
/**
* Weak reference to the application necessary for capturing session events.
*/
private static WeakReference<Application> sWeakApplication;
/**
* Sender instance to send data to the endpoint. Serves the goal of easy customization by the user
* in the registration process.
*/
private static Sender sSender;
/**
* Channel for collecting new events before storing and sending them.
*/
private static Channel sChannel;
/**
* A telemetry context which is used to automatically add environment and meta information
* to events.
*/
private static TelemetryContext sTelemetryContext;
/**
* Flag that indicates disabled session tracking.
* Default is false.
*/
private volatile boolean mSessionTrackingDisabled;
private TelemetryLifecycleCallbacks mTelemetryLifecycleCallbacks;
/**
* Creates and initializes a new instance of the MetricsManager class.
* Not publicly accessible, only accessible for internal use and testing/mocking.
*
* @param context Context that will be used.
* @param telemetryContext Telemetry context, contains meta-information necessary for metrics
* feature.
* @param sender Usually null, to be set for unit testing/dependency injection.
* @param persistence Included for unit testing/dependency injection.
* @param channel Included for unit testing/dependency injection.
*/
protected MetricsManager(Context context, TelemetryContext telemetryContext, Sender sender,
Persistence persistence, Channel channel) {
sTelemetryContext = telemetryContext;
// Important: create sender and persistence first, wire them up and then create the channel!
if (sender == null) {
sender = new Sender();
}
sSender = sender;
if (persistence == null) {
persistence = new Persistence(context, sender);
} else {
persistence.setSender(sender);
}
// Link sender
sSender.setPersistence(persistence);
// Create the channel and wire the persistence to it.
if (channel == null) {
sChannel = new Channel(sTelemetryContext, persistence);
} else {
sChannel = channel;
}
// Check if any previous events are in persistence and send them
if (persistence.hasFilesAvailable()) {
persistence.getSender().triggerSending();
}
}
/**
* Register a new MetricsManager and collect metrics about user and session.
* HockeyApp app identifier is read from configuration values in AndroidManifest.xml.
*
* @param application The application which is required to get application lifecycle
* callbacks.
*/
public static void register(Application application) {
String appIdentifier = Util.getAppIdentifier(application.getApplicationContext());
if (appIdentifier == null || appIdentifier.length() == 0) {
throw new IllegalArgumentException("HockeyApp app identifier was not configured correctly in manifest or build configuration.");
}
register(application, appIdentifier);
}
/**
* Register a new MetricsManager and collect metrics about user and session, while
* explicitly providing your HockeyApp app identifier.
*
* @param application The application which is required to get application lifecycle
* callbacks.
* @param appIdentifier Your HockeyApp App Identifier.
*/
public static void register(Application application, String appIdentifier) {
register(application, appIdentifier, null, null, null);
}
/**
* Register a new MetricsManager and collect metrics about user and session.
* HockeyApp app identifier is read from configuration values in AndroidManifest.xml.
*
* @param context The context to use. Usually your activity.
* @param application The application which is required to get application lifecycle
* callbacks.
* @deprecated Use {@link #register(Application)} instead.
*/
@Deprecated
public static void register(Context context, Application application) {
String appIdentifier = Util.getAppIdentifier(context);
if (appIdentifier == null || appIdentifier.length() == 0) {
throw new IllegalArgumentException("HockeyApp app identifier was not configured correctly in manifest or build configuration.");
}
register(context, application, appIdentifier);
}
/**
* Register a new MetricsManager and collect metrics about user and session, while
* explicitly providing your HockeyApp app identifier.
*
* @param application The application which is required to get application lifecycle
* callbacks.
* @param context The context to use. Usually your activity.
* @param appIdentifier Your HockeyApp App Identifier.
* @deprecated Use {@link #register(Application, String)} instead.
*/
@Deprecated
public static void register(Context context, Application application, String appIdentifier) {
register(application, appIdentifier, null, null, null);
}
/**
* Register a new MetricsManager and collect metrics information about user and session.
* Intended to be used for unit testing only.
*
* @param application The application which is required to get application lifecycle
* callbacks.
* @param appIdentifier Your HockeyApp app identifier.
* @param sender Sender for dependency injection.
* @param persistence Persistence for dependency injection.
* @param channel Channel for dependency injection.
*/
protected static void register(Application application, String appIdentifier,
Sender sender, Persistence persistence, Channel channel) {
MetricsManager result = instance;
if (result == null) {
synchronized (LOCK) {
result = instance; // thread may have instantiated the object
if (result == null) {
Constants.loadFromContext(application.getApplicationContext());
result = new MetricsManager(application.getApplicationContext(), new TelemetryContext(application.getApplicationContext(), appIdentifier),
sender, persistence, channel);
sWeakApplication = new WeakReference<>(application);
}
result.mSessionTrackingDisabled = !Util.sessionTrackingSupported();
instance = result;
if (!result.mSessionTrackingDisabled) {
setSessionTrackingDisabled(false);
}
}
PrivateEventManager.addEventListener(new PrivateEventManager.HockeyEventListener() {
@Override
public void onHockeyEvent(PrivateEventManager.Event event) {
if (event.getType() == PrivateEventManager.EVENT_TYPE_UNCAUGHT_EXCEPTION) {
sChannel.synchronize();
}
}
});
}
}
/**
* Disables User Metrics collection and transmission. Use this if your user opts out of
* telemetry collection.
*/
public static void disableUserMetrics() {
setUserMetricsEnabled(false);
}
/**
* Re-enables User Metrics collection and transmission. Use this if your user granted you
* telemetry collection. User Metrics collection is enabled by default.
*/
public static void enableUserMetrics() {
setUserMetricsEnabled(true);
}
public static boolean isUserMetricsEnabled() {
return sUserMetricsEnabled;
}
private static void setUserMetricsEnabled(boolean enabled) {
sUserMetricsEnabled = enabled;
if (sUserMetricsEnabled) {
instance.registerTelemetryLifecycleCallbacks();
} else {
instance.unregisterTelemetryLifecycleCallbacks();
}
}
/**
* Determines if session tracking was enabled.
*
* @return YES if session tracking is enabled
*/
public static boolean sessionTrackingEnabled() {
return isUserMetricsEnabled() && !instance.mSessionTrackingDisabled;
}
/**
* Enable and disable tracking of sessions
*
* @param disabled flag to indicate
*/
public static void setSessionTrackingDisabled(Boolean disabled) {
if (instance == null || !isUserMetricsEnabled()) {
HockeyLog.warn(TAG, "MetricsManager hasn't been registered or User Metrics has been disabled. No User Metrics will be collected!");
} else {
synchronized (LOCK) {
if (Util.sessionTrackingSupported()) {
instance.mSessionTrackingDisabled = disabled;
//TODO persist this setting so the dev doesn't have to take care of this
//between launches?
if (!disabled) {
instance.registerTelemetryLifecycleCallbacks();
}
} else {
instance.mSessionTrackingDisabled = true;
instance.unregisterTelemetryLifecycleCallbacks();
}
}
}
}
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
private void registerTelemetryLifecycleCallbacks() {
if (mTelemetryLifecycleCallbacks == null) {
mTelemetryLifecycleCallbacks = new TelemetryLifecycleCallbacks();
}
getApplication().registerActivityLifecycleCallbacks(mTelemetryLifecycleCallbacks);
}
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
private void unregisterTelemetryLifecycleCallbacks() {
if (mTelemetryLifecycleCallbacks == null) {
return;
}
getApplication().unregisterActivityLifecycleCallbacks(mTelemetryLifecycleCallbacks);
mTelemetryLifecycleCallbacks = null;
}
/**
* Set the server url if you want metrics to be sent to a custom server
*
* @param serverURL the URL of your custom metrics server as a String
*/
public static void setCustomServerURL(String serverURL) {
if (sSender != null) {
sSender.setCustomServerURL(serverURL);
} else {
HockeyLog.warn(TAG, "HockeyApp couldn't set the custom server url. Please register(...) the MetricsManager before setting the server URL.");
}
}
/**
* Get the reference to the Application (used for life-cycle tracking)
*
* @return the reference to the application that was used during initialization of the SDK
*/
private static Application getApplication() {
Application application = null;
if (sWeakApplication != null) {
application = sWeakApplication.get();
}
return application;
}
/**
* Get the current time
*
* @return the current time in milliseconds
*/
private static long getTime() {
return new Date().getTime();
}
protected static Channel getChannel() {
return sChannel;
}
protected void setChannel(Channel channel) {
sChannel = channel;
}
protected static Sender getSender() {
return sSender;
}
protected static void setSender(Sender sender) {
sSender = sender;
}
protected static MetricsManager getInstance() {
return instance;
}
/**
* Updates the session. If session tracking is enabled, a new session will be started for the
* first activity.
* In case we have already started a session, we determine if we should renew a session.
* This is done by comparing NOW with the last time, onPause has been called.
*/
private void updateSession() {
int count = this.ACTIVITY_COUNT.getAndIncrement();
if (count == 0) {
if (sessionTrackingEnabled()) {
HockeyLog.debug(TAG, "Starting & tracking session");
renewSession();
} else {
HockeyLog.debug(TAG, "Session management disabled by the developer");
}
} else {
//we should already have a session now
//check if the session should be renewed
long now = this.getTime();
long then = this.LAST_BACKGROUND.getAndSet(getTime());
boolean shouldRenew = ((now - then) >= SESSION_RENEWAL_INTERVAL);
HockeyLog.debug(TAG, "Checking if we have to renew a session, time difference is: " + (now - then));
if (shouldRenew && sessionTrackingEnabled()) {
HockeyLog.debug(TAG, "Renewing session");
renewSession();
}
}
}
protected void renewSession() {
String sessionId = UUID.randomUUID().toString();
sTelemetryContext.renewSessionContext(sessionId);
trackSessionState(SessionState.START);
}
/**
* Creates and enqueues a session event for the given state.
*
* @param sessionState value that determines whether the session started or ended
*/
private void trackSessionState(final SessionState sessionState) {
try {
AsyncTaskUtils.execute(new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
SessionStateData sessionItem = new SessionStateData();
sessionItem.setState(sessionState);
Data<Domain> data = createData(sessionItem);
sChannel.enqueueData(data);
return null;
}
});
} catch (RejectedExecutionException e) {
HockeyLog.error("Could not track session state. Executor rejected async task.", e);
}
}
/**
* Pack and forward the telemetry item to the queue.
*
* @param telemetryData The telemetry event to be persisted and sent
* @return a base data object containing the telemetry data
*/
protected static Data<Domain> createData(TelemetryData telemetryData) {
Data<Domain> data = new Data<>();
data.setBaseData(telemetryData);
data.setBaseType(telemetryData.getBaseType());
data.QualifiedName = telemetryData.getEnvelopeName();
return data;
}
public static void trackEvent(final String eventName) {
trackEvent(eventName, null);
}
public static void trackEvent(final String eventName, final Map<String, String> properties) {
trackEvent(eventName, properties, null);
}
public static void trackEvent(final String eventName, final Map<String, String> properties, final Map<String, Double> measurements) {
if (TextUtils.isEmpty(eventName)) {
return;
}
if (instance == null) {
Log.w(TAG, "MetricsManager hasn't been registered or User Metrics has been disabled. No User Metrics will be collected!");
return;
}
if (!isUserMetricsEnabled()) {
HockeyLog.warn("User Metrics is disabled. Will not track event.");
return;
}
try {
AsyncTaskUtils.execute(new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
EventData eventItem = new EventData();
eventItem.setName(eventName);
if (properties != null) {
eventItem.setProperties(properties);
}
if (measurements != null) {
eventItem.setMeasurements(measurements);
}
Data<Domain> data = createData(eventItem);
sChannel.enqueueData(data);
return null;
}
});
} catch (RejectedExecutionException e) {
HockeyLog.error("Could not track custom event. Executor rejected async task.", e);
}
}
@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
private class TelemetryLifecycleCallbacks implements Application.ActivityLifecycleCallbacks {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
// unused but required to implement ActivityLifecycleCallbacks
//NOTE:
//This callback doesn't work for the starting
//activity of the app because the SDK will be setup and initialized in the onCreate, so
//we don't get the very first call to an app activity's onCreate.
}
@Override
public void onActivityStarted(Activity activity) {
// unused but required to implement ActivityLifecycleCallbacks
}
@Override
public void onActivityResumed(Activity activity) {
updateSession();
}
@Override
public void onActivityPaused(Activity activity) {
//set the timestamp when the app was last send to the background. This will be continuously
//updated when the user navigates through the app.
LAST_BACKGROUND.set(getTime());
}
@Override
public void onActivityStopped(Activity activity) {
// unused but required to implement ActivityLifecycleCallbacks
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
// unused but required to implement ActivityLifecycleCallbacks
}
@Override
public void onActivityDestroyed(Activity activity) {
// unused but required to implement ActivityLifecycleCallbacks
}
}
}