/** * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. * * You are hereby granted a non-exclusive, worldwide, royalty-free license to use, * copy, modify, and distribute this software in source code or binary form for use * in connection with the web services and APIs provided by Facebook. * * As with any software that integrates with the Facebook platform, your use of * this software is subject to the Facebook Developer Principles and Policies * [http://developers.facebook.com/policy/]. This copyright notice shall be * included in all copies or substantial portions of the software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package com.facebook.appevents; import android.app.Activity; import android.content.ComponentName; 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 bolts.AppLinks; import com.facebook.AccessToken; import com.facebook.FacebookException; import com.facebook.FacebookRequestError; import com.facebook.FacebookSdk; import com.facebook.GraphRequest; import com.facebook.GraphResponse; import com.facebook.LoggingBehavior; import com.facebook.internal.AttributionIdentifiers; import com.facebook.internal.Logger; import com.facebook.internal.Utility; import com.facebook.internal.Validate; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; 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.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; /** * <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: * </p> * <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> * <p> * 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 15 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 by * calling FacebookSdk.addLoggingBehavior(LoggingBehavior.APP_EVENTS); * </li> * </ul> * </p> * <p> * 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> * </p> */ public class AppEventsLogger { // Enums /** * Controls when an AppEventsLogger sends log events to the server */ public enum FlushBehavior { /** * Flush automatically: periodically (every 15 seconds 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, } // 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 = 15; private static final int APP_SUPPORTS_ATTRIBUTION_ID_RECHECK_PERIOD_IN_SECONDS = 60 * 60 * 24; private static final int FLUSH_APP_SESSION_INFO_IN_SECONDS = 30; public static final String APP_EVENT_PREFERENCES = "com.facebook.sdk.appEventPreferences"; private static final String SOURCE_APPLICATION_HAS_BEEN_SET_BY_THIS_INTENT = "_fbSourceApplicationHasBeenSet"; // Instance member variables private final Context context; private final AccessTokenAppIdPair accessTokenAppId; private static Map<AccessTokenAppIdPair, SessionEventsState> stateMap = new ConcurrentHashMap<AccessTokenAppIdPair, SessionEventsState>(); private static ScheduledThreadPoolExecutor backgroundExecutor; private static FlushBehavior flushBehavior = FlushBehavior.AUTO; private static boolean requestInFlight; private static Context applicationContext; private static Object staticLock = new Object(); private static String anonymousAppDeviceGUID; private static String sourceApplication; private static boolean isOpenedByApplink; private static class AccessTokenAppIdPair implements Serializable { private static final long serialVersionUID = 1L; private final String accessTokenString; private final String applicationId; AccessTokenAppIdPair(AccessToken accessToken) { this(accessToken.getToken(), FacebookSdk.getApplicationId()); } AccessTokenAppIdPair(String accessTokenString, String applicationId) { this.accessTokenString = Utility.isNullOrEmpty(accessTokenString) ? null : accessTokenString; this.applicationId = applicationId; } String getAccessTokenString() { return accessTokenString; } String getApplicationId() { return applicationId; } @Override public int hashCode() { return (accessTokenString == null ? 0 : accessTokenString.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.accessTokenString, accessTokenString) && Utility.areObjectsEqual(p.applicationId, applicationId); } private static class SerializationProxyV1 implements Serializable { private static final long serialVersionUID = -2488473066578201069L; private final String accessTokenString; private final String appId; private SerializationProxyV1(String accessTokenString, String appId) { this.accessTokenString = accessTokenString; this.appId = appId; } private Object readResolve() { return new AccessTokenAppIdPair(accessTokenString, appId); } } private Object writeReplace() { return new SerializationProxyV1(accessTokenString, applicationId); } } /** * 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. * <p/> * 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(Context context) { FacebookSdk.sdkInitialize(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. */ public static void activateApp(Context context, String applicationId) { if (context == null || applicationId == null) { throw new IllegalArgumentException("Both context and applicationId must be non-null"); } if ((context instanceof Activity)) { setSourceApplication((Activity) context); } else { // If context is not an Activity, we cannot get intent nor calling activity. resetSourceApplication(); Log.d(AppEventsLogger.class.getName(), "To set source application the context of activateApp must be an instance of" + " Activity"); } // activateApp supersedes 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. FacebookSdk.publishInstallAsync(context, applicationId); final AppEventsLogger logger = new AppEventsLogger(context, applicationId, null); final long eventTime = System.currentTimeMillis(); final String sourceApplicationInfo = getSourceApplication(); backgroundExecutor.execute(new Runnable() { @Override public void run() { logger.logAppSessionResumeEvent(eventTime, sourceApplicationInfo); } }); } /** * Notifies the events system that the app has been deactivated (put in the background) and * tracks the application session information. Should be called whenever your app becomes * inactive, typically in the onPause() 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#deactivateApp(android.content.Context, String)}. * * @param context Used to access the applicationId and the attributionId for non-authenticated * users. */ public static void deactivateApp(Context context) { deactivateApp(context, Utility.getMetadataApplicationId(context)); } /** * Notifies the events system that the app has been deactivated (put in the background) and * tracks the application session information. Should be called whenever your app becomes * inactive, typically in the onPause() 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 track session information for. */ public static void deactivateApp(Context context, String applicationId) { if (context == null || applicationId == null) { throw new IllegalArgumentException("Both context and applicationId must be non-null"); } resetSourceApplication(); final AppEventsLogger logger = new AppEventsLogger(context, applicationId, null); final long eventTime = System.currentTimeMillis(); backgroundExecutor.execute(new Runnable() { @Override public void run() { logger.logAppSessionSuspendEvent(eventTime); } }); } private void logAppSessionResumeEvent(long eventTime, String sourceApplicationInfo) { PersistedAppSessionInfo.onResume( applicationContext, accessTokenAppId, this, eventTime, sourceApplicationInfo); } private void logAppSessionSuspendEvent(long eventTime) { PersistedAppSessionInfo.onSuspend(applicationContext, accessTokenAppId, this, eventTime); } /** * 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(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 accessToken Access token to use for logging events. If null, the active access token * will be used, if any; if not the logging will happen against the default * app ID specified in the package metadata. */ public static AppEventsLogger newLogger(Context context, AccessToken accessToken) { return new AppEventsLogger(context, null, accessToken); } /** * 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 accessToken Access token to use for logging events. If null, the active access token * will be used, if any; if not the logging will happen against the default * app ID specified in the package metadata. * @return AppEventsLogger instance to invoke log* methods on. */ public static AppEventsLogger newLogger( Context context, String applicationId, AccessToken accessToken) { return new AppEventsLogger(context, applicationId, accessToken); } /** * 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(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. */ 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. */ 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 must not have a space or hyphen as the first character. Any * given app should have no more than ~300 distinct event names. */ 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 must not 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(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 must not 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 * limit the number of unique values provided for each parameter in the * 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(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 must not 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 * limit the number of unique values provided for each parameter in the * 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(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. */ 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. This should * have no more than 10 entries, and keys should be mostly consistent from * one purchase event to the next. */ 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. */ 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 necessary. */ public static void onContextStop() { // TODO: (v4) add onContextStop() to samples that use the logger. PersistedEvents.persistEvents(applicationContext, stateMap); } /** * Determines if the logger is valid for the given access token. * @param accessToken The access token to check. * @return True if the access token is valid for this logger. */ public boolean isValidForAccessToken(AccessToken accessToken) { AccessTokenAppIdPair other = new AccessTokenAppIdPair(accessToken); 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 // @SuppressWarnings("UnusedDeclaration") private enum FlushReason { EXPLICIT, TIMER, SESSION_CHANGE, PERSISTED_EVENTS, EVENT_THRESHOLD, EAGER_FLUSHING_EVENT, } @SuppressWarnings("UnusedDeclaration") 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, AccessToken accessToken) { Validate.notNull(context, "context"); this.context = context; if (accessToken == null) { accessToken = AccessToken.getCurrentAccessToken(); } // If we have a session and the appId passed is null or matches the session's app ID: if (accessToken != null && (applicationId == null || applicationId.equals(accessToken.getApplicationId())) ) { accessTokenAppId = new AccessTokenAppIdPair(accessToken); } else { // If no app ID passed, get it from the manifest: 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 (backgroundExecutor != null) { return; } backgroundExecutor = new ScheduledThreadPoolExecutor(1); } final Runnable flushRunnable = new Runnable() { @Override public void run() { if (getFlushBehavior() != FlushBehavior.EXPLICIT_ONLY) { flushAndWait(FlushReason.TIMER); } } }; backgroundExecutor.scheduleAtFixedRate( flushRunnable, 0, FLUSH_PERIOD_IN_SECONDS, TimeUnit.SECONDS ); final Runnable attributionRecheckRunnable = new Runnable() { @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); } } }; backgroundExecutor.scheduleAtFixedRate( attributionRecheckRunnable, 0, APP_SUPPORTS_ATTRIBUTION_ID_RECHECK_PERIOD_IN_SECONDS, TimeUnit.SECONDS ); } private void logEvent( String eventName, Double valueToSum, Bundle parameters, boolean isImplicitlyLogged) { AppEvent event = new AppEvent( this.context, eventName, valueToSum, parameters, isImplicitlyLogged); logEvent(context, event, accessTokenAppId); } private static void logEvent(final Context context, final AppEvent event, final AccessTokenAppIdPair accessTokenAppId) { FacebookSdk.getExecutor().execute(new Runnable() { @Override public void run() { SessionEventsState state = getSessionEventsState(context, accessTokenAppId); state.addEvent(event); flushIfNecessary(); } }); } 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) { // Do this work outside of the lock to prevent deadlocks in implementation of // AdvertisingIdClient.getAdvertisingIdInfo, because that implementation blocks waiting on // the main thread, which may also grab this staticLock. SessionEventsState state = stateMap.get(accessTokenAppId); AttributionIdentifiers attributionIdentifiers = null; if (state == null) { // Retrieve attributionId, but we will only send it if attribution is supported for the // app. attributionIdentifiers = AttributionIdentifiers.getAttributionIdentifiers(context); } synchronized (staticLock) { // Check state again while we're locked. state = stateMap.get(accessTokenAppId); if (state == null) { state = new SessionEventsState( attributionIdentifiers, context.getPackageName(), getAnonymousAppDeviceGUID(context)); 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) { FacebookSdk.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) { Utility.logd(TAG, "Caught unexpected exception while flushing: ", e); } 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 = FacebookSdk.getLimitEventAndDataUsage(applicationContext); List<GraphRequest> requestsToExecute = new ArrayList<GraphRequest>(); for (AccessTokenAppIdPair accessTokenAppId : keysToFlush) { SessionEventsState sessionEventsState = getSessionEventsState(accessTokenAppId); if (sessionEventsState == null) { continue; } GraphRequest 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 (GraphRequest 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 GraphRequest 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 GraphRequest postRequest = GraphRequest.newPostRequest( null, String.format("%s/activities", applicationId), null, null); Bundle requestParameters = postRequest.getParameters(); if (requestParameters == null) { requestParameters = new Bundle(); } requestParameters.putString("access_token", accessTokenAppId.getAccessTokenString()); postRequest.setParameters(requestParameters); if (fetchedAppSettings == null) { return null; } int numEvents = sessionEventsState.populateRequest( postRequest, fetchedAppSettings.supportsImplicitLogging(), limitEventUsage); if (numEvents == 0) { return null; } flushState.numEvents += numEvents; postRequest.setCallback(new GraphRequest.Callback() { @Override public void onCompleted(GraphResponse response) { handleResponse(accessTokenAppId, postRequest, response, sessionEventsState, flushState); } }); return postRequest; } private static void handleResponse( AccessTokenAppIdPair accessTokenAppId, GraphRequest request, GraphResponse 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 (FacebookSdk.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); } /** * Source Application setters and getters */ private static void setSourceApplication(Activity activity) { ComponentName callingApplication = activity.getCallingActivity(); if (callingApplication != null) { String callingApplicationPackage = callingApplication.getPackageName(); if (callingApplicationPackage.equals(activity.getPackageName())) { // open by own app. resetSourceApplication(); return; } sourceApplication = callingApplicationPackage; } // Tap icon to open an app will still get the old intent if the activity was opened by an // intent before. Introduce an extra field in the intent to force clear the // sourceApplication. Intent openIntent = activity.getIntent(); if (openIntent == null || openIntent.getBooleanExtra(SOURCE_APPLICATION_HAS_BEEN_SET_BY_THIS_INTENT, false)) { resetSourceApplication(); return; } Bundle applinkData = AppLinks.getAppLinkData(openIntent); if (applinkData == null) { resetSourceApplication(); return; } isOpenedByApplink = true; Bundle applinkReferrerData = applinkData.getBundle("referer_app_link"); if (applinkReferrerData == null) { sourceApplication = null; return; } String applinkReferrerPackage = applinkReferrerData.getString("package"); sourceApplication = applinkReferrerPackage; // Mark this intent has been used to avoid use this intent again and again. openIntent.putExtra(SOURCE_APPLICATION_HAS_BEEN_SET_BY_THIS_INTENT, true); return; } static void setSourceApplication(String applicationPackage, boolean openByAppLink) { sourceApplication = applicationPackage; isOpenedByApplink = openByAppLink; } static String getSourceApplication() { String openType = "Unclassified"; if (isOpenedByApplink) { openType = "Applink"; } if (sourceApplication != null) { return openType + "(" + sourceApplication + ")"; } return openType; } static void resetSourceApplication() { sourceApplication = null; isOpenedByApplink = false; } /** * Each app/device pair gets an GUID that is sent back with App Events and persisted with this * app/device pair. * @param context The application context. * @return The GUID for this app/device pair. */ public static String getAnonymousAppDeviceGUID(Context context) { if (anonymousAppDeviceGUID == null) { synchronized (staticLock) { if (anonymousAppDeviceGUID == null) { SharedPreferences preferences = context.getSharedPreferences( APP_EVENT_PREFERENCES, Context.MODE_PRIVATE); anonymousAppDeviceGUID = preferences.getString("anonymousAppDeviceGUID", null); if (anonymousAppDeviceGUID == null) { // Arbitrarily prepend XZ to distinguish from device supplied identifiers. anonymousAppDeviceGUID = "XZ" + UUID.randomUUID().toString(); context.getSharedPreferences(APP_EVENT_PREFERENCES, Context.MODE_PRIVATE) .edit() .putString("anonymousAppDeviceGUID", anonymousAppDeviceGUID) .apply(); } } } } return anonymousAppDeviceGUID; } // // Deprecated Stuff // static class SessionEventsState { private List<AppEvent> accumulatedEvents = new ArrayList<AppEvent>(); private List<AppEvent> inFlightEvents = new ArrayList<AppEvent>(); private int numSkippedEventsDueToFullBuffer; private AttributionIdentifiers attributionIdentifiers; private String packageName; private String anonymousAppDeviceGUID; 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( AttributionIdentifiers identifiers, String packageName, String anonymousGUID) { this.attributionIdentifiers = identifiers; this.packageName = packageName; this.anonymousAppDeviceGUID = anonymousGUID; } // 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(GraphRequest request, boolean includeImplicitEvents, 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, 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(GraphRequest request, int numSkipped, JSONArray events, boolean limitEventUsage) { JSONObject publishParams = new JSONObject(); try { publishParams.put("event", "CUSTOM_APP_EVENTS"); if (numSkippedEventsDueToFullBuffer > 0) { publishParams.put("num_skipped_events", numSkipped); } Utility.setAppEventAttributionParameters(publishParams, attributionIdentifiers, anonymousAppDeviceGUID, limitEventUsage); // The code to get all the Extended info is safe but just in case we can wrap the // whole call in its own try/catch block since some of the things it does might // cause unexpected exceptions on rooted/funky devices: try { Utility.setAppEventExtendedDeviceInfoParameters( publishParams, applicationContext); } catch (Exception e) { // Swallow } publishParams.put("application_package_name", packageName); } catch (JSONException e) { // Swallow } 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( Context context, String eventName, Double valueToSum, Bundle parameters, boolean isImplicitlyLogged ) { try { validateIdentifier(eventName); this.name = eventName; isImplicit = isImplicitlyLogged; jsonObject = new JSONObject(); jsonObject.put("_eventName", eventName); jsonObject.put("_logTime", System.currentTimeMillis() / 1000); jsonObject.put("_ui", Utility.getActivityName(context)); if (valueToSum != null) { jsonObject.put("_valueToSum", valueToSum.doubleValue()); } if (isImplicit) { jsonObject.put("_implicitlyLogged", "1"); } 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; } catch (FacebookException e) { // If any of the above failed, just consider this an illegal event. Logger.log(LoggingBehavior.APP_EVENTS, "AppEvents", "Invalid app event name or parameter:", e.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) throws FacebookException { // 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( Locale.ROOT, "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()); } } static class PersistedAppSessionInfo { private static final String PERSISTED_SESSION_INFO_FILENAME = "AppEventsLogger.persistedsessioninfo"; private static final Object staticLock = new Object(); private static boolean hasChanges = false; private static boolean isLoaded = false; private static Map<AccessTokenAppIdPair, FacebookTimeSpentData> appSessionInfoMap; private static final Runnable appSessionInfoFlushRunnable = new Runnable() { @Override public void run() { PersistedAppSessionInfo.saveAppSessionInformation(applicationContext); } }; @SuppressWarnings("unchecked") private static void restoreAppSessionInformation(Context context) { ObjectInputStream ois = null; synchronized (staticLock) { if (!isLoaded) { try { ois = new ObjectInputStream( context.openFileInput(PERSISTED_SESSION_INFO_FILENAME)); appSessionInfoMap = (HashMap<AccessTokenAppIdPair, FacebookTimeSpentData>) ois.readObject(); Logger.log( LoggingBehavior.APP_EVENTS, "AppEvents", "App session info loaded"); } catch (FileNotFoundException fex) { } catch (Exception e) { Log.d(TAG, "Got unexpected exception: " + e.toString()); } finally { Utility.closeQuietly(ois); context.deleteFile(PERSISTED_SESSION_INFO_FILENAME); if (appSessionInfoMap == null) { appSessionInfoMap = new HashMap<AccessTokenAppIdPair, FacebookTimeSpentData>(); } // Regardless of the outcome of the load, the session information cache // is always deleted. Therefore, always treat the session information cache // as loaded isLoaded = true; hasChanges = false; } } } } static void saveAppSessionInformation(Context context) { ObjectOutputStream oos = null; synchronized (staticLock) { if (hasChanges) { try { oos = new ObjectOutputStream( new BufferedOutputStream( context.openFileOutput( PERSISTED_SESSION_INFO_FILENAME, Context.MODE_PRIVATE) ) ); oos.writeObject(appSessionInfoMap); hasChanges = false; Logger.log( LoggingBehavior.APP_EVENTS, "AppEvents", "App session info saved"); } catch (Exception e) { Log.d(TAG, "Got unexpected exception: " + e.toString()); } finally { Utility.closeQuietly(oos); } } } } static void onResume( Context context, AccessTokenAppIdPair accessTokenAppId, AppEventsLogger logger, long eventTime, String sourceApplicationInfo ) { synchronized (staticLock) { FacebookTimeSpentData timeSpentData = getTimeSpentData(context, accessTokenAppId); timeSpentData.onResume(logger, eventTime, sourceApplicationInfo); onTimeSpentDataUpdate(); } } static void onSuspend( Context context, AccessTokenAppIdPair accessTokenAppId, AppEventsLogger logger, long eventTime ) { synchronized (staticLock) { FacebookTimeSpentData timeSpentData = getTimeSpentData(context, accessTokenAppId); timeSpentData.onSuspend(logger, eventTime); onTimeSpentDataUpdate(); } } private static FacebookTimeSpentData getTimeSpentData( Context context, AccessTokenAppIdPair accessTokenAppId ) { restoreAppSessionInformation(context); FacebookTimeSpentData result = null; result = appSessionInfoMap.get(accessTokenAppId); if (result == null) { result = new FacebookTimeSpentData(); appSessionInfoMap.put(accessTokenAppId, result); } return result; } private static void onTimeSpentDataUpdate() { if (!hasChanges) { hasChanges = true; backgroundExecutor.schedule( appSessionInfoFlushRunnable, FLUSH_APP_SESSION_INFO_IN_SECONDS, TimeUnit.SECONDS); } } } // 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); } } }