/**
* Copyright 2010-present Facebook.
*
* 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.facebook;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;
import com.facebook.internal.*;
import com.facebook.model.GraphObject;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.*;
import java.math.BigDecimal;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* <p>
* The AppEventsLogger class allows the developer to log various types of events back to Facebook. In order to log
* events, the app must create an instance of this class via a {@link #newLogger newLogger} method, and then call
* the various "log" methods off of that.
* </p>
* <p>
* This client-side event logging is then available through Facebook App Insights
* and for use with Facebook Ads conversion tracking and optimization.
* </p>
* <p>
* The AppEventsLogger class has a few related roles:
* <ul>
* <li>
* Logging predefined and application-defined events to Facebook App Insights with a
* numeric value to sum across a large number of events, and an optional set of key/value
* parameters that define "segments" for this event (e.g., 'purchaserStatus' : 'frequent', or
* 'gamerLevel' : 'intermediate'). These events may also be used for ads conversion tracking,
* optimization, and other ads related targeting in the future.
* </li>
* <li>
* Methods that control the way in which events are flushed out to the Facebook servers.
* </li>
* </ul>
* Here are some important characteristics of the logging mechanism provided by AppEventsLogger:
* <ul>
* <li>
* Events are not sent immediately when logged. They're cached and flushed out to the Facebook servers
* in a number of situations:
* <ul>
* <li>when an event count threshold is passed (currently 100 logged events).</li>
* <li>when a time threshold is passed (currently 60 seconds).</li>
* <li>when an app has gone to background and is then brought back to the foreground.</li>
* </ul>
* <li>
* Events will be accumulated when the app is in a disconnected state, and sent when the connection is
* restored and one of the above 'flush' conditions are met.
* </li>
* <li>
* The AppEventsLogger class is intended to be used from the thread it was created on. Multiple AppEventsLoggers
* may be created on other threads if desired.
* </li>
* <li>
* The developer can call the setFlushBehavior method to force the flushing of events to only
* occur on an explicit call to the `flush` method.
* </li>
* <li>
* The developer can turn on console debug output for event logging and flushing to the server
* Settings.addLoggingBehavior(LoggingBehavior.APP_EVENTS);
* </li>
* </ul>
* Some things to note when logging events:
* <ul>
* <li>
* There is a limit on the number of unique event names an app can use, on the order of 300.
* </li>
* <li>
* There is a limit to the number of unique parameter names in the provided parameters that can
* be used per event, on the order of 10. This is not just for an individual call, but for all
* invocations for that eventName.
* </li>
* <li>
* Event names and parameter names (the keys in the NSDictionary) must be between 2 and 40 characters, and
* must consist of alphanumeric characters, _, -, or spaces.
* </li>
* <li>
* The length of each parameter value can be no more than on the order of 100 characters.
* </li>
* </ul>
* @beta
*/
public class AppEventsLogger {
// Enums
/**
* Controls when an AppEventsLogger sends log events to the server
*/
public enum FlushBehavior {
/**
* Flush automatically: periodically (once a minute or after every 100 events), and always at app reactivation.
* This is the default value.
*/
AUTO,
/**
* Only flush when AppEventsLogger.flush() is explicitly invoked.
*/
EXPLICIT_ONLY,
}
private enum SuppressionTimeoutBehavior {
// Successfully logging an event will reset the timeout period (i.e., events will log no more than every N
// seconds).
RESET_TIMEOUT_WHEN_LOG_SUCCESSFUL,
// Attempting to log an event, even if it is suppressed, will reset the timeout period (i.e., events will not
// be logged until they have been "silent" for at least N seconds).
RESET_TIMEOUT_WHEN_LOG_ATTEMPTED,
}
private static class EventSuppression {
// Timeout period in seconds
private int timeoutPeriod;
private SuppressionTimeoutBehavior behavior;
EventSuppression(int timeoutPeriod, SuppressionTimeoutBehavior behavior) {
this.timeoutPeriod = timeoutPeriod;
this.behavior = behavior;
}
int getTimeoutPeriod() {
return timeoutPeriod;
}
SuppressionTimeoutBehavior getBehavior() {
return behavior;
}
}
// Constants
private static final String TAG = AppEventsLogger.class.getCanonicalName();
private static final int NUM_LOG_EVENTS_TO_TRY_TO_FLUSH_AFTER = 100;
private static final int FLUSH_PERIOD_IN_SECONDS = 60;
private static final int APP_SUPPORTS_ATTRIBUTION_ID_RECHECK_PERIOD_IN_SECONDS = 60 * 60 * 24;
private static final int APP_ACTIVATE_SUPPRESSION_PERIOD_IN_SECONDS = 5 * 60;
private static final String APP_EVENT_PREFERENCES = "com.facebook.sdk.appEventPreferences";
// Instance member variables
private final Context context;
private final AccessTokenAppIdPair accessTokenAppId;
private static Map<AccessTokenAppIdPair, SessionEventsState> stateMap =
new ConcurrentHashMap<AccessTokenAppIdPair, SessionEventsState>();
private static Timer flushTimer;
private static Timer supportsAttributionRecheckTimer;
private static FlushBehavior flushBehavior = FlushBehavior.AUTO;
private static boolean requestInFlight;
private static Context applicationContext;
private static Object staticLock = new Object();
private static Map<String, Date> mapEventsToSuppressionTime = new HashMap<String, Date>();
@SuppressWarnings("serial")
private static Map<String, EventSuppression> mapEventNameToSuppress = new HashMap<String, EventSuppression>() {
{
put(AppEventsConstants.EVENT_NAME_ACTIVATED_APP,
new EventSuppression(APP_ACTIVATE_SUPPRESSION_PERIOD_IN_SECONDS,
SuppressionTimeoutBehavior.RESET_TIMEOUT_WHEN_LOG_ATTEMPTED));
}
};
// Rather than retaining Sessions, we extract the information we need and track app events by
// application ID and access token (which may be null for Session-less calls). This avoids needing to
// worry about Session lifecycle and also allows us to coalesce app events from different Sessions
// that have the same access token/app ID.
private static class AccessTokenAppIdPair implements Serializable {
private static final long serialVersionUID = 1L;
private final String accessToken;
private final String applicationId;
AccessTokenAppIdPair(Session session) {
this(session.getAccessToken(), session.getApplicationId());
}
AccessTokenAppIdPair(String accessToken, String applicationId) {
this.accessToken = Utility.isNullOrEmpty(accessToken) ? null : accessToken;
this.applicationId = applicationId;
}
String getAccessToken() {
return accessToken;
}
String getApplicationId() {
return applicationId;
}
@Override
public int hashCode() {
return (accessToken == null ? 0 : accessToken.hashCode()) ^
(applicationId == null ? 0 : applicationId.hashCode());
}
@Override
public boolean equals(Object o) {
if (!(o instanceof AccessTokenAppIdPair)) {
return false;
}
AccessTokenAppIdPair p = (AccessTokenAppIdPair) o;
return Utility.areObjectsEqual(p.accessToken, accessToken) &&
Utility.areObjectsEqual(p.applicationId, applicationId);
}
private static class SerializationProxyV1 implements Serializable {
private static final long serialVersionUID = -2488473066578201069L;
private final String accessToken;
private final String appId;
private SerializationProxyV1(String accessToken, String appId) {
this.accessToken = accessToken;
this.appId = appId;
}
private Object readResolve() {
return new AccessTokenAppIdPair(accessToken, appId);
}
}
private Object writeReplace() {
return new SerializationProxyV1(accessToken, applicationId);
}
}
/**
* Sets whether events sent to Facebook should be restricted from being used for purposes than analytics and
* conversions, such as remarketing for this user. Defaults to false. This value is stored on the device and
* persists across app launches.
*
* @param context Used to read the value.
*
* @beta
*/
public static boolean getLimitEventUsage(Context context) {
SharedPreferences preferences = context.getSharedPreferences(APP_EVENT_PREFERENCES, Context.MODE_PRIVATE);
return preferences.getBoolean("limitEventUsage", false);
}
/**
* Sets whether events sent to Facebook should be restricted from being used for purposes than analytics and
* conversions, such as remarketing for this user. Defaults to false. This value is stored on the device and
* persists across app launches. Changes to this setting will apply to any events currently queued to be
* flushed, as well as any subsequent events.
*
* @param context Used to persist this value across app runs.
*
* @beta
*/
public static void setLimitEventUsage(Context context, boolean limitEventUsage) {
SharedPreferences preferences = context.getSharedPreferences(APP_EVENT_PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences.Editor editor = preferences.edit();
editor.putBoolean("limitEventUsage", limitEventUsage);
editor.commit();
}
/**
* Notifies the events system that the app has launched & logs an activatedApp event. Should be called whenever
* your app becomes active, typically in the onResume() method of each long-running Activity of your app.
*
* Use this method if your application ID is stored in application metadata, otherwise see
* {@link AppEventsLogger#activateApp(android.content.Context, String)}.
*
* @param context Used to access the applicationId and the attributionId for non-authenticated users.
*
* @beta
*/
public static void activateApp(Context context) {
activateApp(context, Utility.getMetadataApplicationId(context));
}
/**
* Notifies the events system that the app has launched & logs an activatedApp event. Should be called whenever
* your app becomes active, typically in the onResume() method of each long-running Activity of your app.
*
* @param context Used to access the attributionId for non-authenticated users.
*
* @param applicationId The specific applicationId to report the activation for.
*
* @beta
*/
@SuppressWarnings("deprecation")
public static void activateApp(Context context, String applicationId) {
if (context == null || applicationId == null) {
throw new IllegalArgumentException("Both context and applicationId must be non-null");
}
// activateApp supercedes publishInstall in the public API, so we need to explicitly invoke it, since the server
// can't reliably infer install state for all conditions of an app activate.
Settings.publishInstallAsync(context, applicationId);
AppEventsLogger logger = new AppEventsLogger(context, applicationId, null);
logger.logEvent(AppEventsConstants.EVENT_NAME_ACTIVATED_APP);
}
/**
* Build an AppEventsLogger instance to log events through. The Facebook app that these events are targeted at
* comes from this application's metadata. The application ID used to log events will be determined from
* the app ID specified in the package metadata.
*
* @param context Used to access the applicationId and the attributionId for non-authenticated users.
*
* @return AppEventsLogger instance to invoke log* methods on.
*
* @beta
*/
public static AppEventsLogger newLogger(Context context) {
return new AppEventsLogger(context, null, null);
}
/**
* Build an AppEventsLogger instance to log events through.
*
* @param context Used to access the attributionId for non-authenticated users.
* @param session Explicitly specified Session to log events against. If null, the activeSession
* will be used if it's open, otherwise the logging will happen against the default
* app ID specified via the app ID specified in the package metadata.
*
* @return AppEventsLogger instance to invoke log* methods on.
*
* @beta
*/
public static AppEventsLogger newLogger(Context context, Session session) {
return new AppEventsLogger(context, null, session);
}
/**
* Build an AppEventsLogger instance to log events through.
*
* @param context Used to access the attributionId for non-authenticated users.
* @param applicationId Explicitly specified Facebook applicationId to log events against. If null, the default
* app ID specified in the package metadata will be used.
* @param session Explicitly specified Session to log events against. If null, the activeSession
* will be used if it's open, otherwise the logging will happen against the specified
* app ID.
*
* @return AppEventsLogger instance to invoke log* methods on.
*
* @beta
*/
public static AppEventsLogger newLogger(Context context, String applicationId, Session session) {
return new AppEventsLogger(context, applicationId, session);
}
/**
* Build an AppEventsLogger instance to log events that are attributed to the application but not to
* any particular Session.
*
* @param context Used to access the attributionId for non-authenticated users.
* @param applicationId Explicitly specified Facebook applicationId to log events against. If null, the default
* app ID specified
* in the package metadata will be used.
*
* @return AppEventsLogger instance to invoke log* methods on.
*
* @beta
*/
public static AppEventsLogger newLogger(Context context, String applicationId) {
return new AppEventsLogger(context, applicationId, null);
}
/**
* The action used to indicate that a flush of app events has occurred. This should
* be used as an action in an IntentFilter and BroadcastReceiver registered with
* the {@link android.support.v4.content.LocalBroadcastManager}.
*/
public static final String ACTION_APP_EVENTS_FLUSHED = "com.facebook.sdk.APP_EVENTS_FLUSHED";
public static final String APP_EVENTS_EXTRA_NUM_EVENTS_FLUSHED = "com.facebook.sdk.APP_EVENTS_NUM_EVENTS_FLUSHED";
public static final String APP_EVENTS_EXTRA_FLUSH_RESULT = "com.facebook.sdk.APP_EVENTS_FLUSH_RESULT";
/**
* Access the behavior that AppEventsLogger uses to determine when to flush logged events to the server. This
* setting applies to all instances of AppEventsLogger.
* @return specified flush behavior.
*
* @beta
*/
public static FlushBehavior getFlushBehavior() {
synchronized (staticLock) {
return flushBehavior;
}
}
/**
* Set the behavior that this AppEventsLogger uses to determine when to flush logged events to the server. This
* setting applies to all instances of AppEventsLogger.
* @param flushBehavior the desired behavior.
*
* @beta
*/
public static void setFlushBehavior(FlushBehavior flushBehavior) {
synchronized (staticLock) {
AppEventsLogger.flushBehavior = flushBehavior;
}
}
/**
* Log an app event with the specified name.
* @param eventName eventName used to denote the event. Choose amongst the EVENT_NAME_* constants in
* {@link AppEventsConstants} when possible. Or create your own if none of the EVENT_NAME_*
* constants are applicable.
* Event names should be 40 characters or less, alphanumeric, and can include spaces, underscores
* or hyphens, but mustn't have a space or hyphen as the first character. Any given app should
* have no more than ~300 distinct event names.
*
* @beta
*/
public void logEvent(String eventName) {
logEvent(eventName, null);
}
/**
* Log an app event with the specified name and the supplied value.
* @param eventName eventName used to denote the event. Choose amongst the EVENT_NAME_* constants in
* {@link AppEventsConstants} when possible. Or create your own if none of the EVENT_NAME_*
* constants are applicable.
* Event names should be 40 characters or less, alphanumeric, and can include spaces, underscores
* or hyphens, but mustn't have a space or hyphen as the first character. Any given app should
* have no more than ~300 distinct event names.
* * @param eventName
* @param valueToSum a value to associate with the event which will be summed up in Insights for across all
* instances of the event, so that average values can be determined, etc.
*
* @beta
*/
public void logEvent(String eventName, double valueToSum) {
logEvent(eventName, valueToSum, null);
}
/**
* Log an app event with the specified name and set of parameters.
* @param eventName eventName used to denote the event. Choose amongst the EVENT_NAME_* constants in
* {@link AppEventsConstants} when possible. Or create your own if none of the EVENT_NAME_*
* constants are applicable.
* Event names should be 40 characters or less, alphanumeric, and can include spaces, underscores
* or hyphens, but mustn't have a space or hyphen as the first character. Any given app should
* have no more than ~300 distinct event names.
* @param parameters A Bundle of parameters to log with the event. Insights will allow looking at the logs of these
* events via different parameter values. You can log on the order of 10 parameters with each
* distinct eventName. It's advisable to keep the number of unique values provided for each
* parameter in the, at most, thousands. As an example, don't attempt to provide a unique
* parameter value for each unique user in your app. You won't get meaningful aggregate reporting
* on so many parameter values. The values in the bundles should be Strings or numeric values.
*
* @beta
*/
public void logEvent(String eventName, Bundle parameters) {
logEvent(eventName, null, parameters, false);
}
/**
* Log an app event with the specified name, supplied value, and set of parameters.
* @param eventName eventName used to denote the event. Choose amongst the EVENT_NAME_* constants in
* {@link AppEventsConstants} when possible. Or create your own if none of the EVENT_NAME_*
* constants are applicable.
* Event names should be 40 characters or less, alphanumeric, and can include spaces, underscores
* or hyphens, but mustn't have a space or hyphen as the first character. Any given app should
* have no more than ~300 distinct event names.
* @param valueToSum a value to associate with the event which will be summed up in Insights for across all
* instances of the event, so that average values can be determined, etc.
* @param parameters A Bundle of parameters to log with the event. Insights will allow looking at the logs of these
* events via different parameter values. You can log on the order of 10 parameters with each
* distinct eventName. It's advisable to keep the number of unique values provided for each
* parameter in the, at most, thousands. As an example, don't attempt to provide a unique
* parameter value for each unique user in your app. You won't get meaningful aggregate reporting
* on so many parameter values. The values in the bundles should be Strings or numeric values.
*
* @beta
*/
public void logEvent(String eventName, double valueToSum, Bundle parameters) {
logEvent(eventName, valueToSum, parameters, false);
}
/**
* Logs a purchase event with Facebook, in the specified amount and with the specified currency.
*
* @param purchaseAmount Amount of purchase, in the currency specified by the 'currency' parameter. This value
* will be rounded to the thousandths place (e.g., 12.34567 becomes 12.346).
* @param currency Currency used to specify the amount.
*
* @beta
*/
public void logPurchase(BigDecimal purchaseAmount, Currency currency) {
logPurchase(purchaseAmount, currency, null);
}
/**
* Logs a purchase event with Facebook, in the specified amount and with the specified currency. Additional
* detail about the purchase can be passed in through the parameters bundle.
*
* @param purchaseAmount Amount of purchase, in the currency specified by the 'currency' parameter. This value
* will be rounded to the thousandths place (e.g., 12.34567 becomes 12.346).
* @param currency Currency used to specify the amount.
* @param parameters Arbitrary additional information for describing this event. Should have no more than
* 10 entries, and keys should be mostly consistent from one purchase event to the next.
*
* @beta
*/
public void logPurchase(BigDecimal purchaseAmount, Currency currency, Bundle parameters) {
if (purchaseAmount == null) {
notifyDeveloperError("purchaseAmount cannot be null");
return;
} else if (currency == null) {
notifyDeveloperError("currency cannot be null");
return;
}
if (parameters == null) {
parameters = new Bundle();
}
parameters.putString(AppEventsConstants.EVENT_PARAM_CURRENCY, currency.getCurrencyCode());
logEvent(AppEventsConstants.EVENT_NAME_PURCHASED, purchaseAmount.doubleValue(), parameters);
eagerFlush();
}
/**
* Explicitly flush any stored events to the server. Implicit flushes may happen depending on the value
* of getFlushBehavior. This method allows for explicit, app invoked flushing.
*
* @beta
*/
public void flush() {
flush(FlushReason.EXPLICIT);
}
/**
* Call this when the consuming Activity/Fragment receives an onStop() callback in order to persist any
* outstanding events to disk, so they may be flushed at a later time. The next flush (explicit or not)
* will check for any outstanding events and, if present, include them in that flush. Note that this call
* may trigger an I/O operation on the calling thread. Explicit use of this method is not necessary
* if the consumer is making use of {@link UiLifecycleHelper}, which will take care of making the call
* in its own onStop() callback.
*
* @beta
*/
public static void onContextStop() {
PersistedEvents.persistEvents(applicationContext, stateMap);
}
boolean isValidForSession(Session session) {
AccessTokenAppIdPair other = new AccessTokenAppIdPair(session);
return accessTokenAppId.equals(other);
}
/**
* This method is intended only for internal use by the Facebook SDK and other use is unsupported.
*/
public void logSdkEvent(String eventName, Double valueToSum, Bundle parameters) {
logEvent(eventName, valueToSum, parameters, true);
}
/**
* Returns the app ID this logger was configured to log to.
* @return the Facebook app ID
*/
public String getApplicationId() {
return accessTokenAppId.getApplicationId();
}
//
// Private implementation
//
private enum FlushReason {
EXPLICIT,
TIMER,
SESSION_CHANGE,
PERSISTED_EVENTS,
EVENT_THRESHOLD,
EAGER_FLUSHING_EVENT,
}
private enum FlushResult {
SUCCESS,
SERVER_ERROR,
NO_CONNECTIVITY,
UNKNOWN_ERROR
}
/**
* Constructor is private, newLogger() methods should be used to build an instance.
*/
private AppEventsLogger(Context context, String applicationId, Session session) {
Validate.notNull(context, "context");
this.context = context;
if (session == null) {
session = Session.getActiveSession();
}
if (session != null) {
accessTokenAppId = new AccessTokenAppIdPair(session);
} else {
if (applicationId == null) {
applicationId = Utility.getMetadataApplicationId(context);
}
accessTokenAppId = new AccessTokenAppIdPair(null, applicationId);
}
synchronized (staticLock) {
if (applicationContext == null) {
applicationContext = context.getApplicationContext();
}
}
initializeTimersIfNeeded();
}
private static void initializeTimersIfNeeded() {
synchronized (staticLock) {
if (flushTimer != null) {
return;
}
flushTimer = new Timer();
supportsAttributionRecheckTimer = new Timer();
}
flushTimer.schedule(
new TimerTask() {
@Override
public void run() {
if (getFlushBehavior() != FlushBehavior.EXPLICIT_ONLY) {
flushAndWait(FlushReason.TIMER);
}
}
},
0, // start immediately
FLUSH_PERIOD_IN_SECONDS * 1000);
supportsAttributionRecheckTimer.schedule(
new TimerTask() {
@Override
public void run() {
Set<String> applicationIds = new HashSet<String>();
synchronized (staticLock) {
for (AccessTokenAppIdPair accessTokenAppId : stateMap.keySet()) {
applicationIds.add(accessTokenAppId.getApplicationId());
}
}
for (String applicationId : applicationIds) {
Utility.queryAppSettings(applicationId, true);
}
}
},
0, // start immediately
APP_SUPPORTS_ATTRIBUTION_ID_RECHECK_PERIOD_IN_SECONDS * 1000);
}
private void logEvent(String eventName, Double valueToSum, Bundle parameters, boolean isImplicitlyLogged) {
AppEvent event = new AppEvent(eventName, valueToSum, parameters, isImplicitlyLogged);
logEvent(context, event, accessTokenAppId);
}
private static void logEvent(Context context, AppEvent event, AccessTokenAppIdPair accessTokenAppId) {
if(shouldSuppressEvent(event)) {
return;
}
SessionEventsState state = getSessionEventsState(context, accessTokenAppId);
state.addEvent(event);
flushIfNecessary();
}
// This will also update the timestamp based on specified behavior.
private static boolean shouldSuppressEvent(AppEvent event) {
EventSuppression suppressionInfo = mapEventNameToSuppress.get(event.getName());
if (suppressionInfo == null) {
return false;
}
Date timestamp = mapEventsToSuppressionTime.get(event.getName());
boolean suppressed;
if (timestamp == null) {
suppressed = false;
} else {
long delta = new Date().getTime() - timestamp.getTime();
suppressed = delta < (suppressionInfo.getTimeoutPeriod() * 1000);
}
// Update the time if we're not suppressed, OR if we are suppressed but the behavior is to reset even on
// suppressed events.
if (!suppressed ||
suppressionInfo.getBehavior() == SuppressionTimeoutBehavior.RESET_TIMEOUT_WHEN_LOG_ATTEMPTED) {
mapEventsToSuppressionTime.put(event.getName(), new Date());
}
return suppressed;
}
static void eagerFlush() {
if (getFlushBehavior() != FlushBehavior.EXPLICIT_ONLY) {
flush(FlushReason.EAGER_FLUSHING_EVENT);
}
}
private static void flushIfNecessary() {
synchronized (staticLock) {
if (getFlushBehavior() != FlushBehavior.EXPLICIT_ONLY) {
if (getAccumulatedEventCount() > NUM_LOG_EVENTS_TO_TRY_TO_FLUSH_AFTER) {
flush(FlushReason.EVENT_THRESHOLD);
}
}
}
}
private static int getAccumulatedEventCount() {
synchronized (staticLock) {
int result = 0;
for (SessionEventsState state : stateMap.values()) {
result += state.getAccumulatedEventCount();
}
return result;
}
}
// Creates a new SessionEventsState if not already in the map.
private static SessionEventsState getSessionEventsState(Context context, AccessTokenAppIdPair accessTokenAppId) {
synchronized (staticLock) {
SessionEventsState state = stateMap.get(accessTokenAppId);
if (state == null) {
// Retrieve attributionId, but we will only send it if attribution is supported for the app.
String attributionId = Settings.getAttributionId(context.getContentResolver());
state = new SessionEventsState(attributionId, context.getPackageName());
stateMap.put(accessTokenAppId, state);
}
return state;
}
}
private static SessionEventsState getSessionEventsState(AccessTokenAppIdPair accessTokenAppId) {
synchronized (staticLock) {
return stateMap.get(accessTokenAppId);
}
}
private static void flush(final FlushReason reason) {
Settings.getExecutor().execute(new Runnable() {
@Override
public void run() {
flushAndWait(reason);
}
});
}
private static void flushAndWait(final FlushReason reason) {
Set<AccessTokenAppIdPair> keysToFlush;
synchronized (staticLock) {
if (requestInFlight) {
return;
}
requestInFlight = true;
keysToFlush = new HashSet<AccessTokenAppIdPair>(stateMap.keySet());
}
accumulatePersistedEvents();
FlushStatistics flushResults = null;
try {
flushResults = buildAndExecuteRequests(reason, keysToFlush);
} catch (Exception e) {
Log.d(TAG, "Caught unexpected exception while flushing: " + e.toString());
}
synchronized (staticLock) {
requestInFlight = false;
}
if (flushResults != null) {
final Intent intent = new Intent(ACTION_APP_EVENTS_FLUSHED);
intent.putExtra(APP_EVENTS_EXTRA_NUM_EVENTS_FLUSHED, flushResults.numEvents);
intent.putExtra(APP_EVENTS_EXTRA_FLUSH_RESULT, flushResults.result);
LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(intent);
}
}
private static FlushStatistics buildAndExecuteRequests(FlushReason reason, Set<AccessTokenAppIdPair> keysToFlush) {
FlushStatistics flushResults = new FlushStatistics();
boolean limitEventUsage = getLimitEventUsage(applicationContext);
List<Request> requestsToExecute = new ArrayList<Request>();
for (AccessTokenAppIdPair accessTokenAppId : keysToFlush) {
SessionEventsState sessionEventsState = getSessionEventsState(accessTokenAppId);
if (sessionEventsState == null) {
continue;
}
Request request = buildRequestForSession(accessTokenAppId, sessionEventsState, limitEventUsage,
flushResults);
if (request != null) {
requestsToExecute.add(request);
}
}
if (requestsToExecute.size() > 0) {
Logger.log(LoggingBehavior.APP_EVENTS, TAG, "Flushing %d events due to %s.",
flushResults.numEvents,
reason.toString());
for (Request request : requestsToExecute) {
// Execute the request synchronously. Callbacks will take care of handling errors and updating
// our final overall result.
request.executeAndWait();
}
return flushResults;
}
return null;
}
private static class FlushStatistics {
public int numEvents = 0;
public FlushResult result = FlushResult.SUCCESS;
}
private static Request buildRequestForSession(final AccessTokenAppIdPair accessTokenAppId,
final SessionEventsState sessionEventsState, final boolean limitEventUsage,
final FlushStatistics flushState) {
String applicationId = accessTokenAppId.getApplicationId();
Utility.FetchedAppSettings fetchedAppSettings = Utility.queryAppSettings(applicationId, false);
final Request postRequest = Request.newPostRequest(
null,
String.format("%s/activities", applicationId),
null,
null);
Bundle requestParameters = postRequest.getParameters();
if (requestParameters == null) {
requestParameters = new Bundle();
}
requestParameters.putString("access_token", accessTokenAppId.getAccessToken());
postRequest.setParameters(requestParameters);
int numEvents = sessionEventsState.populateRequest(postRequest, fetchedAppSettings.supportsImplicitLogging(),
fetchedAppSettings.supportsAttribution(), limitEventUsage);
if (numEvents == 0) {
return null;
}
flushState.numEvents += numEvents;
postRequest.setCallback(new Request.Callback() {
@Override
public void onCompleted(Response response) {
handleResponse(accessTokenAppId, postRequest, response, sessionEventsState, flushState);
}
});
return postRequest;
}
private static void handleResponse(AccessTokenAppIdPair accessTokenAppId, Request request, Response response,
SessionEventsState sessionEventsState, FlushStatistics flushState) {
FacebookRequestError error = response.getError();
String resultDescription = "Success";
FlushResult flushResult = FlushResult.SUCCESS;
if (error != null) {
final int NO_CONNECTIVITY_ERROR_CODE = -1;
if (error.getErrorCode() == NO_CONNECTIVITY_ERROR_CODE) {
resultDescription = "Failed: No Connectivity";
flushResult = FlushResult.NO_CONNECTIVITY;
} else {
resultDescription = String.format("Failed:\n Response: %s\n Error %s",
response.toString(),
error.toString());
flushResult = FlushResult.SERVER_ERROR;
}
}
if (Settings.isLoggingBehaviorEnabled(LoggingBehavior.APP_EVENTS)) {
String eventsJsonString = (String) request.getTag();
String prettyPrintedEvents;
try {
JSONArray jsonArray = new JSONArray(eventsJsonString);
prettyPrintedEvents = jsonArray.toString(2);
} catch (JSONException exc) {
prettyPrintedEvents = "<Can't encode events for debug logging>";
}
Logger.log(LoggingBehavior.APP_EVENTS, TAG,
"Flush completed\nParams: %s\n Result: %s\n Events JSON: %s",
request.getGraphObject().toString(),
resultDescription,
prettyPrintedEvents);
}
sessionEventsState.clearInFlightAndStats(error != null);
if (flushResult == FlushResult.NO_CONNECTIVITY) {
// We may call this for multiple requests in a batch, which is slightly inefficient since in principle
// we could call it once for all failed requests, but the impact is likely to be minimal.
// We don't call this for other server errors, because if an event failed because it was malformed, etc.,
// continually retrying it will cause subsequent events to not be logged either.
PersistedEvents.persistEvents(applicationContext, accessTokenAppId, sessionEventsState);
}
if (flushResult != FlushResult.SUCCESS) {
// We assume that connectivity issues are more significant to report than server issues.
if (flushState.result != FlushResult.NO_CONNECTIVITY) {
flushState.result = flushResult;
}
}
}
private static int accumulatePersistedEvents() {
PersistedEvents persistedEvents = PersistedEvents.readAndClearStore(applicationContext);
int result = 0;
for(AccessTokenAppIdPair accessTokenAppId : persistedEvents.keySet()) {
SessionEventsState sessionEventsState = getSessionEventsState(applicationContext, accessTokenAppId);
List<AppEvent> events = persistedEvents.getEvents(accessTokenAppId);
sessionEventsState.accumulatePersistedEvents(events);
result += events.size();
}
return result;
}
/**
* Invoke this method, rather than throwing an Exception, for situations where user/server input might reasonably
* cause this to occur, and thus don't want an exception thrown at production time, but do want logging
* notification.
*/
private static void notifyDeveloperError(String message) {
Logger.log(LoggingBehavior.DEVELOPER_ERRORS, "AppEvents", message);
}
//
// Deprecated Stuff
//
static class SessionEventsState {
private List<AppEvent> accumulatedEvents = new ArrayList<AppEvent>();
private List<AppEvent> inFlightEvents = new ArrayList<AppEvent>();
private int numSkippedEventsDueToFullBuffer;
private String attributionId;
private String packageName;
public static final String EVENT_COUNT_KEY = "event_count";
public static final String ENCODED_EVENTS_KEY = "encoded_events";
public static final String NUM_SKIPPED_KEY = "num_skipped";
private final int MAX_ACCUMULATED_LOG_EVENTS = 1000;
public SessionEventsState(String attributionId, String packageName) {
this.attributionId = attributionId;
this.packageName = packageName;
}
// Synchronize here and in other methods on this class, because could be coming in from different
// AppEventsLoggers on different threads pointing at the same session.
public synchronized void addEvent(AppEvent event) {
if (accumulatedEvents.size() + inFlightEvents.size() >= MAX_ACCUMULATED_LOG_EVENTS) {
numSkippedEventsDueToFullBuffer++;
} else {
accumulatedEvents.add(event);
}
}
public synchronized int getAccumulatedEventCount() {
return accumulatedEvents.size();
}
public synchronized void clearInFlightAndStats(boolean moveToAccumulated) {
if (moveToAccumulated) {
accumulatedEvents.addAll(inFlightEvents);
}
inFlightEvents.clear();
numSkippedEventsDueToFullBuffer = 0;
}
public int populateRequest(Request request, boolean includeImplicitEvents,
boolean includeAttribution, boolean limitEventUsage) {
int numSkipped;
JSONArray jsonArray;
synchronized (this) {
numSkipped = numSkippedEventsDueToFullBuffer;
// move all accumulated events to inFlight.
inFlightEvents.addAll(accumulatedEvents);
accumulatedEvents.clear();
jsonArray = new JSONArray();
for (AppEvent event : inFlightEvents) {
if (includeImplicitEvents || !event.getIsImplicit()) {
jsonArray.put(event.getJSONObject());
}
}
if (jsonArray.length() == 0) {
return 0;
}
}
populateRequest(request, numSkipped, jsonArray, includeAttribution, limitEventUsage);
return jsonArray.length();
}
public synchronized List<AppEvent> getEventsToPersist() {
// We will only persist accumulated events, not ones currently in-flight. This means if an in-flight
// request fails, those requests will not be persisted and thus might be lost if the process terminates
// while the flush is in progress.
List<AppEvent> result = accumulatedEvents;
accumulatedEvents = new ArrayList<AppEvent>();
return result;
}
public synchronized void accumulatePersistedEvents(List<AppEvent> events) {
// We won't skip events due to a full buffer, since we already accumulated them once and persisted
// them. But they will count against the buffer size when further events are accumulated.
accumulatedEvents.addAll(events);
}
private void populateRequest(Request request, int numSkipped, JSONArray events, boolean includeAttribution,
boolean limitEventUsage) {
GraphObject publishParams = GraphObject.Factory.create();
publishParams.setProperty("event", "CUSTOM_APP_EVENTS");
if (numSkippedEventsDueToFullBuffer > 0) {
publishParams.setProperty("num_skipped_events", numSkipped);
}
if (includeAttribution && attributionId != null) {
publishParams.setProperty("attribution", attributionId);
}
publishParams.setProperty("application_tracking_enabled", !limitEventUsage);
publishParams.setProperty("application_package_name", packageName);
request.setGraphObject(publishParams);
Bundle requestParameters = request.getParameters();
if (requestParameters == null) {
requestParameters = new Bundle();
}
String jsonString = events.toString();
if (jsonString != null) {
requestParameters.putByteArray("custom_events_file", getStringAsByteArray(jsonString));
request.setTag(jsonString);
}
request.setParameters(requestParameters);
}
private byte[] getStringAsByteArray(String jsonString) {
byte[] jsonUtf8 = null;
try {
jsonUtf8 = jsonString.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
// shouldn't happen, but just in case:
Utility.logd("Encoding exception: ", e);
}
return jsonUtf8;
}
}
static class AppEvent implements Serializable {
private static final long serialVersionUID = 1L;
private JSONObject jsonObject;
private boolean isImplicit;
private static final HashSet<String> validatedIdentifiers = new HashSet<String>();
private String name;
public AppEvent(String eventName, Double valueToSum, Bundle parameters, boolean isImplicitlyLogged) {
validateIdentifier(eventName);
this.name = eventName;
isImplicit = isImplicitlyLogged;
jsonObject = new JSONObject();
try {
jsonObject.put("_eventName", eventName);
jsonObject.put("_logTime", System.currentTimeMillis() / 1000);
if (valueToSum != null) {
jsonObject.put("_valueToSum", valueToSum.doubleValue());
}
if (isImplicit) {
jsonObject.put("_implicitlyLogged", "1");
}
String appVersion = Settings.getAppVersion();
if (appVersion != null) {
jsonObject.put("_appVersion", appVersion);
}
if (parameters != null) {
for (String key : parameters.keySet()) {
validateIdentifier(key);
Object value = parameters.get(key);
if (!(value instanceof String) && !(value instanceof Number)) {
throw new FacebookException(
String.format("Parameter value '%s' for key '%s' should be a string or a numeric type.",
value, key));
}
jsonObject.put(key, value.toString());
}
}
if (!isImplicit) {
Logger.log(LoggingBehavior.APP_EVENTS, "AppEvents",
"Created app event '%s'", jsonObject.toString());
}
} catch (JSONException jsonException) {
// If any of the above failed, just consider this an illegal event.
Logger.log(LoggingBehavior.APP_EVENTS, "AppEvents",
"JSON encoding for app event failed: '%s'", jsonException.toString());
jsonObject = null;
}
}
public String getName() {
return name;
}
private AppEvent(String jsonString, boolean isImplicit) throws JSONException {
jsonObject = new JSONObject(jsonString);
this.isImplicit = isImplicit;
}
public boolean getIsImplicit() { return isImplicit; }
public JSONObject getJSONObject() {
return jsonObject;
}
// throw exception if not valid.
private void validateIdentifier(String identifier) {
// Identifier should be 40 chars or less, and only have 0-9A-Za-z, underscore, hyphen, and space (but no
// hyphen or space in the first position).
final String regex = "^[0-9a-zA-Z_]+[0-9a-zA-Z _-]*$";
final int MAX_IDENTIFIER_LENGTH = 40;
if (identifier == null || identifier.length() == 0 || identifier.length() > MAX_IDENTIFIER_LENGTH) {
if (identifier == null) {
identifier = "<None Provided>";
}
throw new FacebookException(
String.format("Identifier '%s' must be less than %d characters", identifier, MAX_IDENTIFIER_LENGTH));
}
boolean alreadyValidated = false;
synchronized (validatedIdentifiers) {
alreadyValidated = validatedIdentifiers.contains(identifier);
}
if (!alreadyValidated) {
if (identifier.matches(regex)) {
synchronized (validatedIdentifiers) {
validatedIdentifiers.add(identifier);
}
} else {
throw new FacebookException(
String.format("Skipping event named '%s' due to illegal name - must be under 40 chars " +
"and alphanumeric, _, - or space, and not start with a space or hyphen.",
identifier));
}
}
}
private static class SerializationProxyV1 implements Serializable {
private static final long serialVersionUID = -2488473066578201069L;
private final String jsonString;
private final boolean isImplicit;
private SerializationProxyV1(String jsonString, boolean isImplicit) {
this.jsonString = jsonString;
this.isImplicit = isImplicit;
}
private Object readResolve() throws JSONException {
return new AppEvent(jsonString, isImplicit);
}
}
private Object writeReplace() {
return new SerializationProxyV1(jsonObject.toString(), isImplicit);
}
@Override
public String toString() {
return String.format("\"%s\", implicit: %b, json: %s", jsonObject.optString("_eventName"),
isImplicit, jsonObject.toString());
}
}
// Read/write operations are thread-safe/atomic across all instances of PersistedEvents, but modifications
// to any individual instance are not thread-safe.
static class PersistedEvents {
static final String PERSISTED_EVENTS_FILENAME = "AppEventsLogger.persistedevents";
private static Object staticLock = new Object();
private Context context;
private HashMap<AccessTokenAppIdPair, List<AppEvent>> persistedEvents =
new HashMap<AccessTokenAppIdPair, List<AppEvent>>();
private PersistedEvents(Context context) {
this.context = context;
}
public static PersistedEvents readAndClearStore(Context context) {
synchronized (staticLock) {
PersistedEvents persistedEvents = new PersistedEvents(context);
persistedEvents.readAndClearStore();
return persistedEvents;
}
}
public static void persistEvents(Context context, AccessTokenAppIdPair accessTokenAppId,
SessionEventsState eventsToPersist) {
Map<AccessTokenAppIdPair, SessionEventsState> map = new HashMap<AccessTokenAppIdPair, SessionEventsState>();
map.put(accessTokenAppId, eventsToPersist);
persistEvents(context, map);
}
public static void persistEvents(Context context,
Map<AccessTokenAppIdPair, SessionEventsState> eventsToPersist) {
synchronized (staticLock) {
// Note that we don't track which instance of AppEventsLogger added a particular event to
// SessionEventsState; when a particular Context is being destroyed, we'll persist all accumulated
// events. More sophisticated tracking could be done to try to reduce unnecessary persisting of events,
// but the overall number of events is not expected to be large.
PersistedEvents persistedEvents = readAndClearStore(context);
for (Map.Entry<AccessTokenAppIdPair, SessionEventsState> entry : eventsToPersist.entrySet()) {
List<AppEvent> events = entry.getValue().getEventsToPersist();
if (events.size() == 0) {
continue;
}
persistedEvents.addEvents(entry.getKey(), events);
}
persistedEvents.write();
}
}
public Set<AccessTokenAppIdPair> keySet() {
return persistedEvents.keySet();
}
public List<AppEvent> getEvents(AccessTokenAppIdPair accessTokenAppId) {
return persistedEvents.get(accessTokenAppId);
}
private void write() {
ObjectOutputStream oos = null;
try {
oos = new ObjectOutputStream(
new BufferedOutputStream(context.openFileOutput(PERSISTED_EVENTS_FILENAME, 0)));
oos.writeObject(persistedEvents);
} catch (Exception e) {
Log.d(TAG, "Got unexpected exception: " + e.toString());
} finally {
Utility.closeQuietly(oos);
}
}
private void readAndClearStore() {
ObjectInputStream ois = null;
try {
ois = new ObjectInputStream(
new BufferedInputStream(context.openFileInput(PERSISTED_EVENTS_FILENAME)));
@SuppressWarnings("unchecked")
HashMap<AccessTokenAppIdPair, List<AppEvent>> obj =
(HashMap<AccessTokenAppIdPair, List<AppEvent>>) ois.readObject();
// Note: We delete the store before we store the events; this means we'd prefer to lose some
// events in the case of exception rather than potentially log them twice.
context.getFileStreamPath(PERSISTED_EVENTS_FILENAME).delete();
persistedEvents = obj;
} catch (FileNotFoundException e) {
// Expected if we never persisted any events.
} catch (Exception e) {
Log.d(TAG, "Got unexpected exception: " + e.toString());
} finally {
Utility.closeQuietly(ois);
}
}
public void addEvents(AccessTokenAppIdPair accessTokenAppId, List<AppEvent> eventsToPersist) {
if (!persistedEvents.containsKey(accessTokenAppId)) {
persistedEvents.put(accessTokenAppId, new ArrayList<AppEvent>());
}
persistedEvents.get(accessTokenAppId).addAll(eventsToPersist);
}
}
}