Java tutorial
/** * 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.TagFu.facebook; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.FileNotFoundException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Currency; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ConcurrentHashMap; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.support.v4.content.LocalBroadcastManager; import android.util.Log; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import com.wTagFufacebook.internal.AttributionIdentifiers; import com.woTagFuacebook.internal.Logger; import com.wooTagFucebook.internal.Utility; import com.wootTagFuebook.internal.Validate; import com.wootag.facebook.model.GraphObject; /** * <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 25. 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> */ public class AppEventsLogger { // Enums // Constants protected 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; // Instance member variables private final Context context; private final AccessTokenAppIdPair accessTokenAppId; protected 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; protected static Object staticLock = new Object(); private static String hashedDeviceAndAppId; private static Map<String, Date> mapEventsToSuppressionTime = new HashMap<String, Date>(); @SuppressWarnings("serial") private static Map<String, EventSuppression> mapEventNameToSuppress = new HashMap<String, EventSuppression>() { { this.put(AppEventsConstants.EVENT_NAME_ACTIVATED_APP, new EventSuppression(APP_ACTIVATE_SUPPRESSION_PERIOD_IN_SECONDS, SuppressionTimeoutBehavior.RESET_TIMEOUT_WHEN_LOG_ATTEMPTED)); } }; /** * 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"; /** * Constructor is private, newLogger() methods should be used to build an instance. */ private AppEventsLogger(final Context context, String applicationId, Session session) { Validate.notNull(context, "context"); this.context = context; if (session == null) { session = Session.getActiveSession(); } if (session != null) { this.accessTokenAppId = new AccessTokenAppIdPair(session); } else { if (applicationId == null) { applicationId = Utility.getMetadataApplicationId(context); } this.accessTokenAppId = new AccessTokenAppIdPair(null, applicationId); } synchronized (staticLock) { if (hashedDeviceAndAppId == null) { hashedDeviceAndAppId = Utility.getHashedDeviceAndAppID(context, applicationId); } if (applicationContext == null) { applicationContext = context.getApplicationContext(); } } initializeTimersIfNeeded(); } /** * 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. */ public static void activateApp(final 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. */ @SuppressWarnings("deprecation") public static void activateApp(final Context context, final 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); final AppEventsLogger logger = new AppEventsLogger(context, applicationId, null); logger.logEvent(AppEventsConstants.EVENT_NAME_ACTIVATED_APP); } /** * 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. */ public static FlushBehavior getFlushBehavior() { synchronized (staticLock) { return flushBehavior; } } /** * 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. */ public static AppEventsLogger newLogger(final 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. */ public static AppEventsLogger newLogger(final Context context, final Session session) { return new AppEventsLogger(context, null, 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. */ public static AppEventsLogger newLogger(final Context context, final String applicationId) { return new AppEventsLogger(context, applicationId, null); } /** * 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. */ public static AppEventsLogger newLogger(final Context context, final String applicationId, final Session session) { return new AppEventsLogger(context, applicationId, session); } /** * 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. */ public static void onContextStop() { PersistedEvents.persistEvents(applicationContext, stateMap); } /** * 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. */ public static void setFlushBehavior(final FlushBehavior flushBehavior) { synchronized (staticLock) { AppEventsLogger.flushBehavior = flushBehavior; } } private static int accumulatePersistedEvents() { final PersistedEvents persistedEvents = PersistedEvents.readAndClearStore(applicationContext); int result = 0; for (final AccessTokenAppIdPair accessTokenAppId : persistedEvents.keySet()) { final SessionEventsState sessionEventsState = getSessionEventsState(applicationContext, accessTokenAppId); final List<AppEvent> events = persistedEvents.getEvents(accessTokenAppId); sessionEventsState.accumulatePersistedEvents(events); result += events.size(); } return result; } private static FlushStatistics buildAndExecuteRequests(final FlushReason reason, final Set<AccessTokenAppIdPair> keysToFlush) { final FlushStatistics flushResults = new FlushStatistics(); final boolean limitEventUsage = Settings.getLimitEventAndDataUsage(applicationContext); final List<Request> requestsToExecute = new ArrayList<Request>(); for (final AccessTokenAppIdPair accessTokenAppId : keysToFlush) { final SessionEventsState sessionEventsState = getSessionEventsState(accessTokenAppId); if (sessionEventsState == null) { continue; } final 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.", Integer.valueOf(flushResults.numEvents), reason.toString()); for (final 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 Request buildRequestForSession(final AccessTokenAppIdPair accessTokenAppId, final SessionEventsState sessionEventsState, final boolean limitEventUsage, final FlushStatistics flushState) { final String applicationId = accessTokenAppId.getApplicationId(); final 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); final 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(final Response response) { handleResponse(accessTokenAppId, postRequest, response, sessionEventsState, flushState); } }); return postRequest; } private static void flush(final FlushReason reason) { Settings.getExecutor().execute(new Runnable() { @Override public void run() { flushAndWait(reason); } }); } 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 (final SessionEventsState state : stateMap.values()) { result += state.getAccumulatedEventCount(); } return result; } } private static SessionEventsState getSessionEventsState(final AccessTokenAppIdPair accessTokenAppId) { synchronized (staticLock) { return stateMap.get(accessTokenAppId); } } // Creates a new SessionEventsState if not already in the map. private static SessionEventsState getSessionEventsState(final Context context, final 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. final AttributionIdentifiers attributionIdentifiers = AttributionIdentifiers .getAttributionIdentifiers(context); state = new SessionEventsState(attributionIdentifiers, context.getPackageName(), hashedDeviceAndAppId); stateMap.put(accessTokenAppId, state); } return state; } } 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() { final Set<String> applicationIds = new HashSet<String>(); synchronized (staticLock) { for (final AccessTokenAppIdPair accessTokenAppId : stateMap.keySet()) { applicationIds.add(accessTokenAppId.getApplicationId()); } } for (final String applicationId : applicationIds) { Utility.queryAppSettings(applicationId, true); } } }, 0, // start immediately APP_SUPPORTS_ATTRIBUTION_ID_RECHECK_PERIOD_IN_SECONDS * 1000); } private static void logEvent(final Context context, final AppEvent event, final AccessTokenAppIdPair accessTokenAppId) { if (shouldSuppressEvent(event)) { return; } final SessionEventsState state = getSessionEventsState(context, accessTokenAppId); state.addEvent(event); flushIfNecessary(); } /** * 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(final String message) { Logger.log(LoggingBehavior.DEVELOPER_ERRORS, "AppEvents", message); } // This will also update the timestamp based on specified behavior. private static boolean shouldSuppressEvent(final AppEvent event) { final EventSuppression suppressionInfo = mapEventNameToSuppress.get(event.getName()); if (suppressionInfo == null) { return false; } final Date timestamp = mapEventsToSuppressionTime.get(event.getName()); boolean suppressed; if (timestamp == null) { suppressed = false; } else { final 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 implementation // 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 (final 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); } } static void handleResponse(final AccessTokenAppIdPair accessTokenAppId, final Request request, final Response response, final SessionEventsState sessionEventsState, final FlushStatistics flushState) { final 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)) { final String eventsJsonString = (String) request.getTag(); String prettyPrintedEvents; try { final JSONArray jsonArray = new JSONArray(eventsJsonString); prettyPrintedEvents = jsonArray.toString(2); } catch (final 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; } } } /** * 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. */ public void flush() { flush(FlushReason.EXPLICIT); } /** * Returns the app ID this logger was configured to log to. * * @return the Facebook app ID */ public String getApplicationId() { return this.accessTokenAppId.getApplicationId(); } /** * 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. */ public void logEvent(final String eventName) { this.logEvent(eventName, 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. */ public void logEvent(final String eventName, final Bundle parameters) { this.logEvent(eventName, null, parameters, false); } /** * 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. */ public void logEvent(final String eventName, final double valueToSum) { this.logEvent(eventName, valueToSum, null); } /** * 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. */ public void logEvent(final String eventName, final double valueToSum, final Bundle parameters) { this.logEvent(eventName, Double.valueOf(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. */ public void logPurchase(final BigDecimal purchaseAmount, final Currency currency) { this.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. */ public void logPurchase(final BigDecimal purchaseAmount, final 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()); this.logEvent(AppEventsConstants.EVENT_NAME_PURCHASED, purchaseAmount.doubleValue(), parameters); eagerFlush(); } /** * This method is intended only for internal use by the Facebook SDK and other use is unsupported. */ public void logSdkEvent(final String eventName, final Double valueToSum, final Bundle parameters) { this.logEvent(eventName, valueToSum, parameters, true); } private void logEvent(final String eventName, final Double valueToSum, final Bundle parameters, final boolean isImplicitlyLogged) { final AppEvent event = new AppEvent(eventName, valueToSum, parameters, isImplicitlyLogged); logEvent(this.context, event, this.accessTokenAppId); } boolean isValidForSession(final Session session) { final AccessTokenAppIdPair other = new AccessTokenAppIdPair(session); return this.accessTokenAppId.equals(other); } /** * 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, } // 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(final Session session) { this(session.getAccessToken(), session.getApplicationId()); } AccessTokenAppIdPair(final String accessToken, final String applicationId) { this.accessToken = Utility.isNullOrEmpty(accessToken) ? null : accessToken; this.applicationId = applicationId; } @Override public boolean equals(final Object o) { if (!(o instanceof AccessTokenAppIdPair)) { return false; } final AccessTokenAppIdPair p = (AccessTokenAppIdPair) o; return Utility.areObjectsEqual(p.accessToken, this.accessToken) && Utility.areObjectsEqual(p.applicationId, this.applicationId); } @Override public int hashCode() { return (this.accessToken == null ? 0 : this.accessToken.hashCode()) ^ (this.applicationId == null ? 0 : this.applicationId.hashCode()); } private Object writeReplace() { return new SerializationProxyV1(this.accessToken, this.applicationId); } String getAccessToken() { return this.accessToken; } String getApplicationId() { return this.applicationId; } private static class SerializationProxyV1 implements Serializable { private static final long serialVersionUID = -2488473066578201069L; private final String accessToken; private final String appId; SerializationProxyV1(final String accessToken, final String appId) { this.accessToken = accessToken; this.appId = appId; } private Object readResolve() { return new AccessTokenAppIdPair(this.accessToken, this.appId); } } } private static class EventSuppression { // Timeout period in seconds private final int timeoutPeriod; private final SuppressionTimeoutBehavior behavior; EventSuppression(final int timeoutPeriod, final SuppressionTimeoutBehavior behavior) { this.timeoutPeriod = timeoutPeriod; this.behavior = behavior; } SuppressionTimeoutBehavior getBehavior() { return this.behavior; } int getTimeoutPeriod() { return this.timeoutPeriod; } } private enum FlushReason { EXPLICIT, TIMER, SESSION_CHANGE, PERSISTED_EVENTS, EVENT_THRESHOLD, EAGER_FLUSHING_EVENT, } private enum FlushResult { SUCCESS, SERVER_ERROR, NO_CONNECTIVITY, UNKNOWN_ERROR } private static class FlushStatistics { public int numEvents; public FlushResult result = FlushResult.SUCCESS; } 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, } // // Deprecated Stuff // static class AppEvent implements Serializable { private static final String JSON_ENCODING_FOR_APP_EVENT_FAILED_S = "JSON encoding for app event failed: '%s'"; private static final String APP_EVENTS = "AppEvents"; private static final String _EVENT_NAME = "_eventName"; private static final String S_IMPLICIT_B_JSON_S = "\"%s\", implicit: %b, json: %s"; private static final long serialVersionUID = 1L; private JSONObject jsonObject; private final boolean isImplicit; private static final HashSet<String> validatedIdentifiers = new HashSet<String>(); private String name; public AppEvent(final String eventName, final Double valueToSum, final Bundle parameters, final boolean isImplicitlyLogged) { this.validateIdentifier(eventName); this.name = eventName; this.isImplicit = isImplicitlyLogged; this.jsonObject = new JSONObject(); try { this.jsonObject.put(_EVENT_NAME, eventName); this.jsonObject.put("_logTime", System.currentTimeMillis() / 1000); if (valueToSum != null) { this.jsonObject.put("_valueToSum", valueToSum.doubleValue()); } if (this.isImplicit) { this.jsonObject.put("_implicitlyLogged", "1"); } final String appVersion = Settings.getAppVersion(); if (appVersion != null) { this.jsonObject.put("_appVersion", appVersion); } if (parameters != null) { for (final String key : parameters.keySet()) { this.validateIdentifier(key); final 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)); } this.jsonObject.put(key, value.toString()); } } if (!this.isImplicit) { Logger.log(LoggingBehavior.APP_EVENTS, APP_EVENTS, "Created app event '%s'", this.jsonObject.toString()); } } catch (final JSONException jsonException) { // If any of the above failed, just consider this an illegal event. Logger.log(LoggingBehavior.APP_EVENTS, APP_EVENTS, JSON_ENCODING_FOR_APP_EVENT_FAILED_S, jsonException.toString()); this.jsonObject = null; } } protected AppEvent(final String jsonString, final boolean isImplicit) throws JSONException { this.jsonObject = new JSONObject(jsonString); this.isImplicit = isImplicit; } public boolean getIsImplicit() { return this.isImplicit; } public JSONObject getJSONObject() { return this.jsonObject; } public String getName() { return this.name; } @Override public String toString() { return String.format(S_IMPLICIT_B_JSON_S, this.jsonObject.optString(_EVENT_NAME), Boolean.valueOf(this.isImplicit), this.jsonObject.toString()); } // 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, Integer.valueOf(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 Object writeReplace() { return new SerializationProxyV1(this.jsonObject.toString(), this.isImplicit); } private static class SerializationProxyV1 implements Serializable { private static final long serialVersionUID = -2488473066578201069L; private final String jsonString; private final boolean isImplicit; SerializationProxyV1(final String jsonString, final boolean isImplicit) { this.jsonString = jsonString; this.isImplicit = isImplicit; } private Object readResolve() throws JSONException { return new AppEvent(this.jsonString, this.isImplicit); } } } // 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 final Context context; private Map<AccessTokenAppIdPair, List<AppEvent>> persistedEvents = new HashMap<AccessTokenAppIdPair, List<AppEvent>>(); private PersistedEvents(final Context context) { this.context = context; } public static void persistEvents(final Context context, final AccessTokenAppIdPair accessTokenAppId, final SessionEventsState eventsToPersist) { final Map<AccessTokenAppIdPair, SessionEventsState> map = new HashMap<AccessTokenAppIdPair, SessionEventsState>(); map.put(accessTokenAppId, eventsToPersist); persistEvents(context, map); } public static void persistEvents(final Context context, final 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. final PersistedEvents persistedEvents = readAndClearStore(context); for (final Map.Entry<AccessTokenAppIdPair, SessionEventsState> entry : eventsToPersist.entrySet()) { final List<AppEvent> events = entry.getValue().getEventsToPersist(); if (events.size() == 0) { continue; } persistedEvents.addEvents(entry.getKey(), events); } persistedEvents.write(); } } public static PersistedEvents readAndClearStore(final Context context) { synchronized (staticLock) { final PersistedEvents persistedEvents = new PersistedEvents(context); persistedEvents.readAndClearStore(); return persistedEvents; } } public void addEvents(final AccessTokenAppIdPair accessTokenAppId, final List<AppEvent> eventsToPersist) { if (!this.persistedEvents.containsKey(accessTokenAppId)) { this.persistedEvents.put(accessTokenAppId, new ArrayList<AppEvent>()); } this.persistedEvents.get(accessTokenAppId).addAll(eventsToPersist); } public List<AppEvent> getEvents(final AccessTokenAppIdPair accessTokenAppId) { return this.persistedEvents.get(accessTokenAppId); } public Set<AccessTokenAppIdPair> keySet() { return this.persistedEvents.keySet(); } private void readAndClearStore() { ObjectInputStream ois = null; try { ois = new ObjectInputStream( new BufferedInputStream(this.context.openFileInput(PERSISTED_EVENTS_FILENAME))); @SuppressWarnings("unchecked") final 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. this.context.getFileStreamPath(PERSISTED_EVENTS_FILENAME).delete(); this.persistedEvents = obj; } catch (final FileNotFoundException e) { // Expected if we never persisted any events. } catch (final Exception e) { Log.d(TAG, "Got unexpected exception: " + e.toString()); } finally { Utility.closeQuietly(ois); } } private void write() { ObjectOutputStream oos = null; try { oos = new ObjectOutputStream( new BufferedOutputStream(this.context.openFileOutput(PERSISTED_EVENTS_FILENAME, 0))); oos.writeObject(this.persistedEvents); } catch (final Exception e) { Log.d(TAG, "Got unexpected exception: " + e.toString()); } finally { Utility.closeQuietly(oos); } } } static class SessionEventsState { private List<AppEvent> accumulatedEvents = new ArrayList<AppEvent>(); private final List<AppEvent> inFlightEvents = new ArrayList<AppEvent>(); private int numSkippedEventsDueToFullBuffer; private final AttributionIdentifiers attributionIdentifiers; private final String packageName; private final String hashedDeviceAndAppId; 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 static final int MAX_ACCUMULATED_LOG_EVENTS = 1000; public SessionEventsState(final AttributionIdentifiers identifiers, final String packageName, final String hashedDeviceAndAppId) { this.attributionIdentifiers = identifiers; this.packageName = packageName; this.hashedDeviceAndAppId = hashedDeviceAndAppId; } public synchronized void accumulatePersistedEvents(final 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. this.accumulatedEvents.addAll(events); } // 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(final AppEvent event) { if ((this.accumulatedEvents.size() + this.inFlightEvents.size()) >= SessionEventsState.MAX_ACCUMULATED_LOG_EVENTS) { this.numSkippedEventsDueToFullBuffer++; } else { this.accumulatedEvents.add(event); } } public synchronized void clearInFlightAndStats(final boolean moveToAccumulated) { if (moveToAccumulated) { this.accumulatedEvents.addAll(this.inFlightEvents); } this.inFlightEvents.clear(); this.numSkippedEventsDueToFullBuffer = 0; } public synchronized int getAccumulatedEventCount() { return this.accumulatedEvents.size(); } 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. final List<AppEvent> result = this.accumulatedEvents; this.accumulatedEvents = new ArrayList<AppEvent>(); return result; } public int populateRequest(final Request request, final boolean includeImplicitEvents, final boolean includeAttribution, final boolean limitEventUsage) { int numSkipped; JSONArray jsonArray; synchronized (this) { numSkipped = this.numSkippedEventsDueToFullBuffer; // move all accumulated events to inFlight. this.inFlightEvents.addAll(this.accumulatedEvents); this.accumulatedEvents.clear(); jsonArray = new JSONArray(); for (final AppEvent event : this.inFlightEvents) { if (includeImplicitEvents || !event.getIsImplicit()) { jsonArray.put(event.getJSONObject()); } } if (jsonArray.length() == 0) { return 0; } } this.populateRequest(request, numSkipped, jsonArray, includeAttribution, limitEventUsage); return jsonArray.length(); } private byte[] getStringAsByteArray(final String jsonString) { byte[] jsonUtf8 = null; try { jsonUtf8 = jsonString.getBytes("UTF-8"); } catch (final UnsupportedEncodingException e) { // Utility.logd("Encoding exception: ", e); } return jsonUtf8; } private void populateRequest(final Request request, final int numSkipped, final JSONArray events, final boolean includeAttribution, final boolean limitEventUsage) { final GraphObject publishParams = GraphObject.Factory.create(); publishParams.setProperty("event", "CUSTOM_APP_EVENTS"); if (this.numSkippedEventsDueToFullBuffer > 0) { publishParams.setProperty("num_skipped_events", Integer.valueOf(numSkipped)); } if (includeAttribution) { Utility.setAppEventAttributionParameters(publishParams, this.attributionIdentifiers, this.hashedDeviceAndAppId, limitEventUsage); } publishParams.setProperty("application_package_name", this.packageName); request.setGraphObject(publishParams); Bundle requestParameters = request.getParameters(); if (requestParameters == null) { requestParameters = new Bundle(); } final String jsonString = events.toString(); if (jsonString != null) { requestParameters.putByteArray("custom_events_file", this.getStringAsByteArray(jsonString)); request.setTag(jsonString); } request.setParameters(requestParameters); } } }