/**
* Copyright 2010-present Facebook.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.facebook.internal;
import android.app.Activity;
import android.content.*;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.v4.content.LocalBroadcastManager;
import android.util.Log;
import com.facebook.*;
import com.facebook.widget.FacebookDialog;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* com.facebook.internal is solely for the use of other packages within the Facebook SDK for Android. Use of
* any of the classes in this package is unsupported, and they may be modified or removed without warning at
* any time.
*/
public class LikeActionController {
public static final String ACTION_LIKE_ACTION_CONTROLLER_UPDATED = "com.facebook.sdk.LikeActionController.UPDATED";
public static final String ACTION_LIKE_ACTION_CONTROLLER_DID_ERROR = "com.facebook.sdk.LikeActionController.DID_ERROR";
public static final String ACTION_LIKE_ACTION_CONTROLLER_DID_RESET = "com.facebook.sdk.LikeActionController.DID_RESET";
public static final String ACTION_OBJECT_ID_KEY = "com.facebook.sdk.LikeActionController.OBJECT_ID";
public static final String ERROR_INVALID_OBJECT_ID = "Invalid Object Id";
private static final String TAG = LikeActionController.class.getSimpleName();
private static final int LIKE_ACTION_CONTROLLER_VERSION = 2;
private static final int MAX_CACHE_SIZE = 128;
// MAX_OBJECT_SUFFIX basically accommodates for 1000 session-state changes before the async disk-cache-clear
// finishes. The value is reasonably arbitrary.
private static final int MAX_OBJECT_SUFFIX = 1000;
private static final String LIKE_ACTION_CONTROLLER_STORE = "com.facebook.LikeActionController.CONTROLLER_STORE_KEY";
private static final String LIKE_ACTION_CONTROLLER_STORE_PENDING_OBJECT_ID_KEY = "PENDING_CONTROLLER_KEY";
private static final String LIKE_ACTION_CONTROLLER_STORE_OBJECT_SUFFIX_KEY = "OBJECT_SUFFIX";
private static final String JSON_INT_VERSION_KEY = "com.facebook.internal.LikeActionController.version";
private static final String JSON_STRING_OBJECT_ID_KEY = "object_id";
private static final String JSON_STRING_LIKE_COUNT_WITH_LIKE_KEY = "like_count_string_with_like";
private static final String JSON_STRING_LIKE_COUNT_WITHOUT_LIKE_KEY = "like_count_string_without_like";
private static final String JSON_STRING_SOCIAL_SENTENCE_WITH_LIKE_KEY = "social_sentence_with_like";
private static final String JSON_STRING_SOCIAL_SENTENCE_WITHOUT_LIKE_KEY = "social_sentence_without_like";
private static final String JSON_BOOL_IS_OBJECT_LIKED_KEY = "is_object_liked";
private static final String JSON_STRING_UNLIKE_TOKEN_KEY = "unlike_token";
private static final String JSON_STRING_PENDING_CALL_ID_KEY = "pending_call_id";
private static final String JSON_BUNDLE_PENDING_CALL_ANALYTICS_BUNDLE = "pending_call_analytics_bundle";
private static final String LIKE_DIALOG_RESPONSE_OBJECT_IS_LIKED_KEY = "object_is_liked";
private static final String LIKE_DIALOG_RESPONSE_LIKE_COUNT_STRING_KEY = "like_count_string";
private static final String LIKE_DIALOG_RESPONSE_SOCIAL_SENTENCE_KEY = "social_sentence";
private static final String LIKE_DIALOG_RESPONSE_UNLIKE_TOKEN_KEY = "unlike_token";
private static final int ERROR_CODE_OBJECT_ALREADY_LIKED = 3501;
private static FileLruCache controllerDiskCache;
private static final ConcurrentHashMap<String, LikeActionController> cache =
new ConcurrentHashMap<String, LikeActionController>();
private static WorkQueue mruCacheWorkQueue = new WorkQueue(1); // This MUST be 1 for proper synchronization
private static WorkQueue diskIOWorkQueue = new WorkQueue(1); // This MUST be 1 for proper synchronization
private static Handler handler;
private static String objectIdForPendingController;
private static boolean isPendingBroadcastReset;
private static boolean isInitialized;
private static volatile int objectSuffix;
private Session session;
private Context context;
private String objectId;
private boolean isObjectLiked;
private String likeCountStringWithLike;
private String likeCountStringWithoutLike;
private String socialSentenceWithLike;
private String socialSentenceWithoutLike;
private String unlikeToken;
private String verifiedObjectId;
private boolean objectIsPage;
private boolean isObjectLikedOnServer;
private boolean isPendingLikeOrUnlike;
private UUID pendingCallId;
private Bundle pendingCallAnalyticsBundle;
private AppEventsLogger appEventsLogger;
/**
* Called from UiLifecycleHelper to process any pending likes that had resulted in the Like dialog
* being displayed
*
* @param context Hosting context
* @param requestCode From the originating call to onActivityResult
* @param resultCode From the originating call to onActivityResult
* @param data From the originating call to onActivityResult
* @return Indication of whether the Intent was handled
*/
public static boolean handleOnActivityResult(Context context,
final int requestCode,
final int resultCode,
final Intent data) {
final UUID callId = NativeProtocol.getCallIdFromIntent(data);
if (callId == null) {
return false;
}
// See if we were waiting on a Like dialog completion.
if (Utility.isNullOrEmpty(objectIdForPendingController)) {
SharedPreferences sharedPreferences = context.getSharedPreferences(
LIKE_ACTION_CONTROLLER_STORE,
Context.MODE_PRIVATE);
objectIdForPendingController = sharedPreferences.getString(
LIKE_ACTION_CONTROLLER_STORE_PENDING_OBJECT_ID_KEY,
null);
}
if (Utility.isNullOrEmpty(objectIdForPendingController)) {
// Doesn't look like we were waiting on a Like dialog completion
return false;
}
getControllerForObjectId(
context,
objectIdForPendingController,
new CreationCallback() {
@Override
public void onComplete(LikeActionController likeActionController) {
likeActionController.onActivityResult(requestCode, resultCode, data, callId);
}
});
return true;
}
/**
* Called by the LikeView when an object-id is set on it.
* @param context context
* @param objectId Object Id
* @return A LikeActionController for the specified object id
*/
public static void getControllerForObjectId(
Context context,
String objectId,
CreationCallback callback) {
if (!isInitialized) {
performFirstInitialize(context);
}
LikeActionController controllerForObject = getControllerFromInMemoryCache(objectId);
if (controllerForObject != null) {
// Direct object-cache hit
invokeCallbackWithController(callback, controllerForObject);
} else {
diskIOWorkQueue.addActiveWorkItem(new CreateLikeActionControllerWorkItem(context, objectId, callback));
}
}
/**
* NOTE: This MUST be called ONLY via the CreateLikeActionControllerWorkItem class to ensure that it happens on the
* right thread, at the right time.
*/
private static void createControllerForObjectId(
Context context,
String objectId,
CreationCallback callback) {
// Check again to see if the controller was created before attempting to deserialize/create one.
// Need to check this in the case where multiple LikeViews are looking for a controller for the same object
// and all got queued up to create one. We only want the first one to go through with the creation, and the
// rest should get the same instance from the object-cache.
LikeActionController controllerForObject = getControllerFromInMemoryCache(objectId);
if (controllerForObject != null) {
// Direct object-cache hit
invokeCallbackWithController(callback, controllerForObject);
return;
}
// Try deserialize from disk
controllerForObject = deserializeFromDiskSynchronously(context, objectId);
if (controllerForObject == null) {
controllerForObject = new LikeActionController(context, Session.getActiveSession(), objectId);
serializeToDiskAsync(controllerForObject);
}
// Update object-cache.
putControllerInMemoryCache(objectId, controllerForObject);
// Refresh the controller on the Main thread.
final LikeActionController controllerToRefresh = controllerForObject;
handler.post(new Runnable() {
@Override
public void run() {
controllerToRefresh.refreshStatusAsync();
}
});
invokeCallbackWithController(callback, controllerToRefresh);
}
private synchronized static void performFirstInitialize(Context context) {
if (isInitialized) {
return;
}
handler = new Handler(Looper.getMainLooper());
SharedPreferences sharedPreferences = context.getSharedPreferences(
LIKE_ACTION_CONTROLLER_STORE,
Context.MODE_PRIVATE);
objectSuffix = sharedPreferences.getInt(LIKE_ACTION_CONTROLLER_STORE_OBJECT_SUFFIX_KEY, 1);
controllerDiskCache = new FileLruCache(context, TAG, new FileLruCache.Limits());
registerSessionBroadcastReceivers(context);
isInitialized = true;
}
private static void invokeCallbackWithController(final CreationCallback callback, final LikeActionController controller) {
if (callback == null) {
return;
}
handler.post(new Runnable() {
@Override
public void run() {
callback.onComplete(controller);
}
});
}
//
// In-memory mru-caching code
//
private static void registerSessionBroadcastReceivers(Context context) {
LocalBroadcastManager broadcastManager = LocalBroadcastManager.getInstance(context);
IntentFilter filter = new IntentFilter();
filter.addAction(Session.ACTION_ACTIVE_SESSION_UNSET);
filter.addAction(Session.ACTION_ACTIVE_SESSION_CLOSED);
filter.addAction(Session.ACTION_ACTIVE_SESSION_OPENED);
broadcastManager.registerReceiver(new BroadcastReceiver() {
@Override
public void onReceive(Context receiverContext, Intent intent) {
if (isPendingBroadcastReset) {
return;
}
String action = intent.getAction();
final boolean shouldClearDisk =
Utility.areObjectsEqual(Session.ACTION_ACTIVE_SESSION_UNSET, action) ||
Utility.areObjectsEqual(Session.ACTION_ACTIVE_SESSION_CLOSED, action);
isPendingBroadcastReset = true;
// Delaying sending the broadcast to reset, because we might get many successive calls from Session
// (to UNSET, SET & OPEN) and a delay would prevent excessive chatter.
final Context broadcastContext = receiverContext;
handler.postDelayed(new Runnable() {
@Override
public void run() {
// Bump up the objectSuffix so that we don't have a filename collision between a cache-clear and
// and a cache-read/write.
//
// NOTE: We know that onReceive() was called on the main thread. This means that even this code
// is running on the main thread, and therefore, there aren't synchronization issues with
// incrementing the objectSuffix and clearing the caches here.
if (shouldClearDisk) {
objectSuffix = (objectSuffix + 1) % MAX_OBJECT_SUFFIX;
broadcastContext.getSharedPreferences(LIKE_ACTION_CONTROLLER_STORE, Context.MODE_PRIVATE)
.edit()
.putInt(LIKE_ACTION_CONTROLLER_STORE_OBJECT_SUFFIX_KEY, objectSuffix)
.apply();
// Only clearing the actual caches. The MRU index will self-clean with usage.
// Clearing the caches is necessary to prevent leaking like-state across sessions.
cache.clear();
controllerDiskCache.clearCache();
}
broadcastAction(broadcastContext, null, ACTION_LIKE_ACTION_CONTROLLER_DID_RESET);
isPendingBroadcastReset = false;
}
}, 100);
}
}, filter);
}
private static void putControllerInMemoryCache(String objectId, LikeActionController controllerForObject) {
String cacheKey = getCacheKeyForObjectId(objectId);
// Move this object to the front. Also trim cache if necessary
mruCacheWorkQueue.addActiveWorkItem(new MRUCacheWorkItem(cacheKey, true));
cache.put(cacheKey, controllerForObject);
}
private static LikeActionController getControllerFromInMemoryCache(String objectId) {
String cacheKey = getCacheKeyForObjectId(objectId);
LikeActionController controller = cache.get(cacheKey);
if (controller != null) {
// Move this object to the front
mruCacheWorkQueue.addActiveWorkItem(new MRUCacheWorkItem(cacheKey, false));
}
return controller;
}
//
// Disk caching code
//
private static void serializeToDiskAsync(LikeActionController controller) {
String controllerJson = serializeToJson(controller);
String cacheKey = getCacheKeyForObjectId(controller.objectId);
if (!Utility.isNullOrEmpty(controllerJson) && !Utility.isNullOrEmpty(cacheKey)) {
diskIOWorkQueue.addActiveWorkItem(new SerializeToDiskWorkItem(cacheKey, controllerJson));
}
}
/**
* NOTE: This MUST be called ONLY via the SerializeToDiskWorkItem class to ensure that it happens on the
* right thread, at the right time.
*/
private static void serializeToDiskSynchronously(String cacheKey, String controllerJson) {
OutputStream outputStream = null;
try {
outputStream = controllerDiskCache.openPutStream(cacheKey);
outputStream.write(controllerJson.getBytes());
} catch (IOException e) {
Log.e(TAG, "Unable to serialize controller to disk", e);
} finally {
if (outputStream != null) {
Utility.closeQuietly(outputStream);
}
}
}
/**
* NOTE: This MUST be called ONLY via the CreateLikeActionControllerWorkItem class to ensure that it happens on the
* right thread, at the right time.
*/
private static LikeActionController deserializeFromDiskSynchronously(
Context context,
String objectId) {
LikeActionController controller = null;
InputStream inputStream = null;
try {
String cacheKey = getCacheKeyForObjectId(objectId);
inputStream = controllerDiskCache.get(cacheKey);
if (inputStream != null) {
String controllerJsonString = Utility.readStreamToString(inputStream);
if (!Utility.isNullOrEmpty(controllerJsonString)) {
controller = deserializeFromJson(context, controllerJsonString);
}
}
} catch (IOException e) {
Log.e(TAG, "Unable to deserialize controller from disk", e);
controller = null;
} finally {
if (inputStream != null) {
Utility.closeQuietly(inputStream);
}
}
return controller;
}
private static LikeActionController deserializeFromJson(Context context, String controllerJsonString) {
LikeActionController controller;
try {
JSONObject controllerJson = new JSONObject(controllerJsonString);
int version = controllerJson.optInt(JSON_INT_VERSION_KEY, -1);
if (version != LIKE_ACTION_CONTROLLER_VERSION) {
// Don't attempt to deserialize a controller that might be serialized differently than expected.
return null;
}
controller = new LikeActionController(
context,
Session.getActiveSession(),
controllerJson.getString(JSON_STRING_OBJECT_ID_KEY));
// Make sure to default to null and not empty string, to keep the logic elsewhere functioning properly.
controller.likeCountStringWithLike = controllerJson.optString(JSON_STRING_LIKE_COUNT_WITH_LIKE_KEY, null) ;
controller.likeCountStringWithoutLike = controllerJson.optString(JSON_STRING_LIKE_COUNT_WITHOUT_LIKE_KEY, null) ;
controller.socialSentenceWithLike = controllerJson.optString(JSON_STRING_SOCIAL_SENTENCE_WITH_LIKE_KEY, null);
controller.socialSentenceWithoutLike = controllerJson.optString(JSON_STRING_SOCIAL_SENTENCE_WITHOUT_LIKE_KEY, null);
controller.isObjectLiked = controllerJson.optBoolean(JSON_BOOL_IS_OBJECT_LIKED_KEY);
controller.unlikeToken = controllerJson.optString(JSON_STRING_UNLIKE_TOKEN_KEY, null);
String pendingCallIdString = controllerJson.optString(JSON_STRING_PENDING_CALL_ID_KEY, null);
if (!Utility.isNullOrEmpty(pendingCallIdString)) {
controller.pendingCallId = UUID.fromString(pendingCallIdString);
}
JSONObject analyticsJSON = controllerJson.optJSONObject(JSON_BUNDLE_PENDING_CALL_ANALYTICS_BUNDLE);
if (analyticsJSON != null) {
controller.pendingCallAnalyticsBundle = BundleJSONConverter.convertToBundle(analyticsJSON);
}
} catch (JSONException e) {
Log.e(TAG, "Unable to deserialize controller from JSON", e);
controller = null;
}
return controller;
}
private static String serializeToJson(LikeActionController controller) {
JSONObject controllerJson = new JSONObject();
try {
controllerJson.put(JSON_INT_VERSION_KEY, LIKE_ACTION_CONTROLLER_VERSION);
controllerJson.put(JSON_STRING_OBJECT_ID_KEY, controller.objectId);
controllerJson.put(JSON_STRING_LIKE_COUNT_WITH_LIKE_KEY, controller.likeCountStringWithLike);
controllerJson.put(JSON_STRING_LIKE_COUNT_WITHOUT_LIKE_KEY, controller.likeCountStringWithoutLike);
controllerJson.put(JSON_STRING_SOCIAL_SENTENCE_WITH_LIKE_KEY, controller.socialSentenceWithLike);
controllerJson.put(JSON_STRING_SOCIAL_SENTENCE_WITHOUT_LIKE_KEY, controller.socialSentenceWithoutLike);
controllerJson.put(JSON_BOOL_IS_OBJECT_LIKED_KEY, controller.isObjectLiked);
controllerJson.put(JSON_STRING_UNLIKE_TOKEN_KEY, controller.unlikeToken);
if (controller.pendingCallId != null) {
controllerJson.put(JSON_STRING_PENDING_CALL_ID_KEY, controller.pendingCallId.toString());
}
if (controller.pendingCallAnalyticsBundle != null) {
JSONObject analyticsJSON = BundleJSONConverter.convertToJSON(controller.pendingCallAnalyticsBundle);
if (analyticsJSON != null) {
controllerJson.put(JSON_BUNDLE_PENDING_CALL_ANALYTICS_BUNDLE, analyticsJSON);
}
}
} catch (JSONException e) {
Log.e(TAG, "Unable to serialize controller to JSON", e);
return null;
}
return controllerJson.toString();
}
private static String getCacheKeyForObjectId(String objectId) {
String accessTokenPortion = null;
Session activeSession = Session.getActiveSession();
if (activeSession != null && activeSession.isOpened()) {
accessTokenPortion = activeSession.getAccessToken();
}
if (accessTokenPortion != null) {
// Cache-key collisions are not something to worry about here, since we only store state for
// one session. Even in the case where the previous session's serialized files have not been deleted yet,
// the objectSuffix will be different due to the session-change, thus making the key different.
accessTokenPortion = Utility.md5hash(accessTokenPortion);
}
return String.format(
"%s|%s|com.fb.sdk.like|%d",
objectId,
Utility.coerceValueIfNullOrEmpty(accessTokenPortion, ""),
objectSuffix);
}
//
// Broadcast handling code
//
private static void broadcastAction(Context context, LikeActionController controller, String action) {
broadcastAction(context, controller, action, null);
}
private static void broadcastAction(Context context, LikeActionController controller, String action, Bundle data) {
Intent broadcastIntent = new Intent(action);
if (controller != null) {
if (data == null) {
data = new Bundle();
}
data.putString(ACTION_OBJECT_ID_KEY, controller.getObjectId());
}
if (data != null) {
broadcastIntent.putExtras(data);
}
LocalBroadcastManager.getInstance(context.getApplicationContext()).sendBroadcast(broadcastIntent);
}
/**
* Constructor
*/
private LikeActionController(Context context, Session session, String objectId) {
this.context = context;
this.session = session;
this.objectId = objectId;
appEventsLogger = AppEventsLogger.newLogger(context, session);
}
/**
* Gets the the associated object id
* @return object id
*/
public String getObjectId() {
return objectId;
}
/**
* Gets the String representation of the like-count for the associated object
* @return String representation of the like-count for the associated object
*/
public String getLikeCountString() {
return isObjectLiked ? likeCountStringWithLike : likeCountStringWithoutLike;
}
/**
* Gets the String representation of the like-count for the associated object
* @return String representation of the like-count for the associated object
*/
public String getSocialSentence() {
return isObjectLiked ? socialSentenceWithLike : socialSentenceWithoutLike;
}
/**
* Indicates whether the associated object is liked
* @return Indication of whether the associated object is liked
*/
public boolean isObjectLiked() {
return isObjectLiked;
}
/**
* Entry-point to the code that performs the like/unlike action.
*/
public void toggleLike(Activity activity, Bundle analyticsParameters) {
appEventsLogger.logSdkEvent(AnalyticsEvents.EVENT_LIKE_VIEW_DID_TAP, null, analyticsParameters);
boolean shouldLikeObject = !this.isObjectLiked;
if (canUseOGPublish(shouldLikeObject)) {
// Update UI state optimistically
updateState(shouldLikeObject,
this.likeCountStringWithLike,
this.likeCountStringWithoutLike,
this.socialSentenceWithLike,
this.socialSentenceWithoutLike,
this.unlikeToken);
if (isPendingLikeOrUnlike) {
// If the user toggled the button quickly, and there is still a publish underway, don't fire off
// another request. Also log this behavior.
appEventsLogger.logSdkEvent(AnalyticsEvents.EVENT_LIKE_VIEW_DID_UNDO_QUICKLY, null, analyticsParameters);
return;
}
}
performLikeOrUnlike(activity, shouldLikeObject, analyticsParameters);
}
private void performLikeOrUnlike(Activity activity, boolean shouldLikeObject, Bundle analyticsParameters) {
if (canUseOGPublish(shouldLikeObject)) {
if (shouldLikeObject) {
publishLikeAsync(activity, analyticsParameters);
} else {
publishUnlikeAsync(activity, analyticsParameters);
}
} else {
presentLikeDialog(activity, analyticsParameters);
}
}
private void updateState(boolean isObjectLiked,
String likeCountStringWithLike,
String likeCountStringWithoutLike,
String socialSentenceWithLike,
String socialSentenceWithoutLike,
String unlikeToken) {
// Normalize all empty strings to null, so that we don't have any problems with comparison.
likeCountStringWithLike = Utility.coerceValueIfNullOrEmpty(likeCountStringWithLike, null);
likeCountStringWithoutLike = Utility.coerceValueIfNullOrEmpty(likeCountStringWithoutLike, null);
socialSentenceWithLike = Utility.coerceValueIfNullOrEmpty(socialSentenceWithLike, null);
socialSentenceWithoutLike = Utility.coerceValueIfNullOrEmpty(socialSentenceWithoutLike, null);
unlikeToken = Utility.coerceValueIfNullOrEmpty(unlikeToken, null);
boolean stateChanged = isObjectLiked != this.isObjectLiked ||
!Utility.areObjectsEqual(likeCountStringWithLike, this.likeCountStringWithLike) ||
!Utility.areObjectsEqual(likeCountStringWithoutLike, this.likeCountStringWithoutLike) ||
!Utility.areObjectsEqual(socialSentenceWithLike, this.socialSentenceWithLike) ||
!Utility.areObjectsEqual(socialSentenceWithoutLike, this.socialSentenceWithoutLike) ||
!Utility.areObjectsEqual(unlikeToken, this.unlikeToken);
if (!stateChanged) {
return;
}
this.isObjectLiked = isObjectLiked;
this.likeCountStringWithLike = likeCountStringWithLike;
this.likeCountStringWithoutLike = likeCountStringWithoutLike;
this.socialSentenceWithLike = socialSentenceWithLike;
this.socialSentenceWithoutLike = socialSentenceWithoutLike;
this.unlikeToken = unlikeToken;
serializeToDiskAsync(this);
broadcastAction(context, this, ACTION_LIKE_ACTION_CONTROLLER_UPDATED);
}
private void presentLikeDialog(Activity activity, Bundle analyticsParameters) {
LikeDialogBuilder likeDialogBuilder = new LikeDialogBuilder(activity, objectId);
if (likeDialogBuilder.canPresent()) {
trackPendingCall(likeDialogBuilder.build().present(), analyticsParameters);
appEventsLogger.logSdkEvent(AnalyticsEvents.EVENT_LIKE_VIEW_DID_PRESENT_DIALOG, null, analyticsParameters);
} else {
String webFallbackUrl = likeDialogBuilder.getWebFallbackUrl();
if (!Utility.isNullOrEmpty(webFallbackUrl)) {
boolean webFallbackShown = FacebookWebFallbackDialog.presentWebFallback(
activity,
webFallbackUrl,
likeDialogBuilder.getApplicationId(),
likeDialogBuilder.getAppCall(),
getFacebookDialogCallback(analyticsParameters));
if (webFallbackShown) {
appEventsLogger.logSdkEvent(
AnalyticsEvents.EVENT_LIKE_VIEW_DID_PRESENT_FALLBACK, null, analyticsParameters);
}
}
}
}
private boolean onActivityResult(int requestCode, int resultCode, Intent data, UUID callId) {
if (pendingCallId == null || !pendingCallId.equals(callId)) {
return false;
}
// See if we were waiting for a dialog completion
FacebookDialog.PendingCall pendingCall = PendingCallStore.getInstance().getPendingCallById(pendingCallId);
if (pendingCall == null) {
return false;
}
// Look for results
FacebookDialog.handleActivityResult(
context,
pendingCall,
requestCode,
data,
getFacebookDialogCallback(pendingCallAnalyticsBundle));
// The handlers from above will run synchronously. So by the time we get here, it should be safe to
// stop tracking this call and also serialize the controller to disk
stopTrackingPendingCall();
return true;
}
private FacebookDialog.Callback getFacebookDialogCallback(final Bundle analyticsParameters) {
return new FacebookDialog.Callback() {
@Override
public void onComplete(FacebookDialog.PendingCall pendingCall, Bundle data) {
if (!data.containsKey(LIKE_DIALOG_RESPONSE_OBJECT_IS_LIKED_KEY)) {
// This is an empty result that we can't handle. Don't lose like state.
return;
}
boolean isObjectLiked = data.getBoolean(LIKE_DIALOG_RESPONSE_OBJECT_IS_LIKED_KEY);
String likeCountString = data.getString(LIKE_DIALOG_RESPONSE_LIKE_COUNT_STRING_KEY);
String socialSentence = data.getString(LIKE_DIALOG_RESPONSE_SOCIAL_SENTENCE_KEY);
String unlikeToken = data.getString(LIKE_DIALOG_RESPONSE_UNLIKE_TOKEN_KEY);
Bundle logParams = (analyticsParameters == null) ? new Bundle() : analyticsParameters;
logParams.putString(AnalyticsEvents.PARAMETER_CALL_ID, pendingCall.getCallId().toString());
appEventsLogger.logSdkEvent(AnalyticsEvents.EVENT_LIKE_VIEW_DIALOG_DID_SUCCEED, null, logParams);
updateState(
isObjectLiked,
likeCountString,
likeCountString,
socialSentence,
socialSentence,
unlikeToken);
}
@Override
public void onError(FacebookDialog.PendingCall pendingCall, Exception error, Bundle data) {
Logger.log(LoggingBehavior.REQUESTS, TAG, "Like Dialog failed with error : %s", error);
Bundle logParams = analyticsParameters == null ? new Bundle() : analyticsParameters;
logParams.putString(AnalyticsEvents.PARAMETER_CALL_ID, pendingCall.getCallId().toString());
// Log the error and AppEvent
logAppEventForError("present_dialog", logParams);
broadcastAction(context, LikeActionController.this, ACTION_LIKE_ACTION_CONTROLLER_DID_ERROR, data);
}
};
}
private void trackPendingCall(FacebookDialog.PendingCall pendingCall, Bundle analyticsParameters) {
PendingCallStore.getInstance().trackPendingCall(pendingCall);
// Save off the call id for processing the response
pendingCallId = pendingCall.getCallId();
storeObjectIdForPendingController(objectId);
// Store off the analytics parameters as well, for completion-logging
pendingCallAnalyticsBundle = analyticsParameters;
// Serialize to disk, in case we get terminated while waiting for the dialog to complete
serializeToDiskAsync(this);
}
private void stopTrackingPendingCall() {
PendingCallStore.getInstance().stopTrackingPendingCall(pendingCallId);
pendingCallId = null;
pendingCallAnalyticsBundle = null;
storeObjectIdForPendingController(null);
}
private void storeObjectIdForPendingController(String objectId) {
objectIdForPendingController = objectId;
context.getSharedPreferences(LIKE_ACTION_CONTROLLER_STORE, Context.MODE_PRIVATE)
.edit()
.putString(LIKE_ACTION_CONTROLLER_STORE_PENDING_OBJECT_ID_KEY, objectIdForPendingController)
.apply();
}
private boolean canUseOGPublish(boolean willPerformLike) {
// Verify that the object isn't a Page, that we have permissions and that, if we're unliking, then
// we have an unlike token.
return !objectIsPage &&
verifiedObjectId != null &&
session != null &&
session.getPermissions() != null &&
session.getPermissions().contains("publish_actions") &&
(willPerformLike || !Utility.isNullOrEmpty(unlikeToken));
}
private void publishLikeAsync(final Activity activity, final Bundle analyticsParameters) {
isPendingLikeOrUnlike = true;
fetchVerifiedObjectId(new RequestCompletionCallback() {
@Override
public void onComplete() {
if (Utility.isNullOrEmpty(verifiedObjectId)) {
// Could not get a verified id
Bundle errorBundle = new Bundle();
errorBundle.putString(NativeProtocol.STATUS_ERROR_DESCRIPTION, ERROR_INVALID_OBJECT_ID);
broadcastAction(context, LikeActionController.this, ACTION_LIKE_ACTION_CONTROLLER_DID_ERROR, errorBundle);
return;
}
// Perform the Like.
RequestBatch requestBatch = new RequestBatch();
final PublishLikeRequestWrapper likeRequest = new PublishLikeRequestWrapper(verifiedObjectId);
likeRequest.addToBatch(requestBatch);
requestBatch.addCallback(new RequestBatch.Callback() {
@Override
public void onBatchCompleted(RequestBatch batch) {
isPendingLikeOrUnlike = false;
if (likeRequest.error != null) {
// We already updated the UI to show button in the Liked state. Since this failed, let's
// revert back to the Unliked state and show the dialog. We need to do this because the
// dialog-flow expects the button to only be updated once the dialog returns
updateState(
false,
likeCountStringWithLike,
likeCountStringWithoutLike,
socialSentenceWithLike,
socialSentenceWithoutLike,
unlikeToken);
presentLikeDialog(activity, analyticsParameters);
} else {
unlikeToken = Utility.coerceValueIfNullOrEmpty(likeRequest.unlikeToken, null);
isObjectLikedOnServer = true;
appEventsLogger.logSdkEvent(AnalyticsEvents.EVENT_LIKE_VIEW_DID_LIKE, null, analyticsParameters);
toggleAgainIfNeeded(activity, analyticsParameters);
}
}
});
requestBatch.executeAsync();
}
});
}
private void publishUnlikeAsync(final Activity activity, final Bundle analyticsParameters) {
isPendingLikeOrUnlike = true;
// Perform the Unlike.
RequestBatch requestBatch = new RequestBatch();
final PublishUnlikeRequestWrapper unlikeRequest = new PublishUnlikeRequestWrapper(unlikeToken);
unlikeRequest.addToBatch(requestBatch);
requestBatch.addCallback(new RequestBatch.Callback() {
@Override
public void onBatchCompleted(RequestBatch batch) {
isPendingLikeOrUnlike = false;
if (unlikeRequest.error != null) {
// We already updated the UI to show button in the Unliked state. Since this failed, let's
// revert back to the Liked state and show the dialog. We need to do this because the
// dialog-flow expects the button to only be updated once the dialog returns
updateState(
true,
likeCountStringWithLike,
likeCountStringWithoutLike,
socialSentenceWithLike,
socialSentenceWithoutLike,
unlikeToken);
presentLikeDialog(activity, analyticsParameters);
} else {
unlikeToken = null;
isObjectLikedOnServer = false;
appEventsLogger.logSdkEvent(AnalyticsEvents.EVENT_LIKE_VIEW_DID_UNLIKE, null, analyticsParameters);
toggleAgainIfNeeded(activity, analyticsParameters);
}
}
});
requestBatch.executeAsync();
}
private void refreshStatusAsync() {
if (session == null || session.isClosed() || SessionState.CREATED.equals(session.getState())) {
// Only when we know that there is no active session, or if there is, it is not open OR being opened,
// should we attempt getting like state from the service. Otherwise, use the access token of the session
// to make sure we get the correct like state.
refreshStatusViaService();
return;
}
fetchVerifiedObjectId(new RequestCompletionCallback() {
@Override
public void onComplete() {
final GetOGObjectLikesRequestWrapper objectLikesRequest =
new GetOGObjectLikesRequestWrapper(verifiedObjectId);
final GetEngagementRequestWrapper engagementRequest =
new GetEngagementRequestWrapper(verifiedObjectId);
RequestBatch requestBatch = new RequestBatch();
objectLikesRequest.addToBatch(requestBatch);
engagementRequest.addToBatch(requestBatch);
requestBatch.addCallback(new RequestBatch.Callback() {
@Override
public void onBatchCompleted(RequestBatch batch) {
if (objectLikesRequest.error != null ||
engagementRequest.error != null) {
// Refreshing is best-effort. If the refresh fails, don't lose old state.
Logger.log(
LoggingBehavior.REQUESTS,
TAG,
"Unable to refresh like state for id: '%s'", objectId);
return;
}
updateState(
objectLikesRequest.objectIsLiked,
engagementRequest.likeCountStringWithLike,
engagementRequest.likeCountStringWithoutLike,
engagementRequest.socialSentenceStringWithLike,
engagementRequest.socialSentenceStringWithoutLike,
objectLikesRequest.unlikeToken);
}
});
requestBatch.executeAsync();
}
});
}
private void refreshStatusViaService() {
LikeStatusClient likeStatusClient = new LikeStatusClient(
context,
Settings.getApplicationId(),
objectId);
if (!likeStatusClient.start()) {
return;
}
LikeStatusClient.CompletedListener callback = new LikeStatusClient.CompletedListener() {
@Override
public void completed(Bundle result) {
if (result == null || !result.containsKey(NativeProtocol.EXTRA_OBJECT_IS_LIKED)) {
// Don't lose old state if the service response is incomplete.
return;
}
boolean objectIsLiked = result.getBoolean(NativeProtocol.EXTRA_OBJECT_IS_LIKED);
String likeCountWithLike = result.getString(NativeProtocol.EXTRA_LIKE_COUNT_STRING_WITH_LIKE);
String likeCountWithoutLike = result.getString(NativeProtocol.EXTRA_LIKE_COUNT_STRING_WITHOUT_LIKE);
String socialSentenceWithLike = result.getString(NativeProtocol.EXTRA_SOCIAL_SENTENCE_WITH_LIKE);
String socialSentenceWithoutLike = result.getString(NativeProtocol.EXTRA_SOCIAL_SENTENCE_WITHOUT_LIKE);
String unlikeToken = result.getString(NativeProtocol.EXTRA_UNLIKE_TOKEN);
updateState(
objectIsLiked,
likeCountWithLike,
likeCountWithoutLike,
socialSentenceWithLike,
socialSentenceWithoutLike,
unlikeToken);
}
};
likeStatusClient.setCompletedListener(callback);
}
private void toggleAgainIfNeeded(Activity activity, Bundle analyticsParameters) {
if (isObjectLiked != isObjectLikedOnServer) {
performLikeOrUnlike(activity, isObjectLiked, analyticsParameters);
}
}
private void fetchVerifiedObjectId(final RequestCompletionCallback completionHandler) {
if (!Utility.isNullOrEmpty(verifiedObjectId)) {
if (completionHandler != null) {
completionHandler.onComplete();
}
return;
}
final GetOGObjectIdRequestWrapper objectIdRequest = new GetOGObjectIdRequestWrapper(objectId);
final GetPageIdRequestWrapper pageIdRequest = new GetPageIdRequestWrapper(objectId);
RequestBatch requestBatch = new RequestBatch();
objectIdRequest.addToBatch(requestBatch);
pageIdRequest.addToBatch(requestBatch);
requestBatch.addCallback(new RequestBatch.Callback() {
@Override
public void onBatchCompleted(RequestBatch batch) {
verifiedObjectId = objectIdRequest.verifiedObjectId;
if (Utility.isNullOrEmpty(verifiedObjectId)) {
verifiedObjectId = pageIdRequest.verifiedObjectId;
objectIsPage = pageIdRequest.objectIsPage;
}
if (Utility.isNullOrEmpty(verifiedObjectId)) {
Logger.log(LoggingBehavior.DEVELOPER_ERRORS,
TAG,
"Unable to verify the FB id for '%s'. Verify that it is a valid FB object or page", objectId);
logAppEventForError("get_verified_id",
pageIdRequest.error != null ? pageIdRequest.error : objectIdRequest.error);
}
if (completionHandler != null) {
completionHandler.onComplete();
}
}
});
requestBatch.executeAsync();
}
private void logAppEventForError(String action, Bundle parameters) {
Bundle logParams = new Bundle(parameters);
logParams.putString(AnalyticsEvents.PARAMETER_LIKE_VIEW_OBJECT_ID, objectId);
logParams.putString(AnalyticsEvents.PARAMETER_LIKE_VIEW_CURRENT_ACTION, action);
appEventsLogger.logSdkEvent(AnalyticsEvents.EVENT_LIKE_VIEW_ERROR, null, logParams);
}
private void logAppEventForError(String action, FacebookRequestError error) {
Bundle logParams = new Bundle();
if (error != null) {
JSONObject requestResult = error.getRequestResult();
if (requestResult != null) {
logParams.putString(AnalyticsEvents.PARAMETER_LIKE_VIEW_ERROR_JSON, requestResult.toString());
}
}
logAppEventForError(action, logParams);
}
//
// Interfaces
//
/**
* Used by the call to getControllerForObjectId()
*/
public interface CreationCallback {
public void onComplete(LikeActionController likeActionController);
}
/**
* Used by all the request wrappers
*/
private interface RequestCompletionCallback {
void onComplete();
}
//
// Inner classes
//
private class GetOGObjectIdRequestWrapper extends AbstractRequestWrapper {
String verifiedObjectId;
GetOGObjectIdRequestWrapper(String objectId) {
super(objectId);
Bundle objectIdRequestParams = new Bundle();
objectIdRequestParams.putString("fields", "og_object.fields(id)");
objectIdRequestParams.putString("ids", objectId);
setRequest(new Request(session, "", objectIdRequestParams, HttpMethod.GET));
}
@Override
protected void processError(FacebookRequestError error) {
// If this object Id is for a Page, an error will be received for this request
// We will then rely on the other request to come through.
if (error.getErrorMessage().contains("og_object")) {
this.error = null;
} else {
Logger.log(LoggingBehavior.REQUESTS,
TAG,
"Error getting the FB id for object '%s' : %s", objectId, error);
}
}
@Override
protected void processSuccess(Response response) {
JSONObject results = Utility.tryGetJSONObjectFromResponse(response.getGraphObject(), objectId);
if (results != null) {
// See if we can get the OG object Id out
JSONObject ogObject = results.optJSONObject("og_object");
if (ogObject != null) {
verifiedObjectId = ogObject.optString("id");
}
}
}
}
private class GetPageIdRequestWrapper extends AbstractRequestWrapper {
String verifiedObjectId;
boolean objectIsPage;
GetPageIdRequestWrapper(String objectId) {
super(objectId);
Bundle pageIdRequestParams = new Bundle();
pageIdRequestParams.putString("fields", "id");
pageIdRequestParams.putString("ids", objectId);
setRequest(new Request(session, "", pageIdRequestParams, HttpMethod.GET));
}
@Override
protected void processSuccess(Response response) {
JSONObject results = Utility.tryGetJSONObjectFromResponse(response.getGraphObject(), objectId);
if (results != null) {
verifiedObjectId = results.optString("id");
objectIsPage = !Utility.isNullOrEmpty(verifiedObjectId);
}
}
@Override
protected void processError(FacebookRequestError error) {
Logger.log(LoggingBehavior.REQUESTS,
TAG,
"Error getting the FB id for object '%s' : %s", objectId, error);
}
}
private class PublishLikeRequestWrapper extends AbstractRequestWrapper {
String unlikeToken;
PublishLikeRequestWrapper(String objectId) {
super(objectId);
Bundle likeRequestParams = new Bundle();
likeRequestParams.putString("object", objectId);
setRequest(new Request(session, "me/og.likes", likeRequestParams, HttpMethod.POST));
}
@Override
protected void processSuccess(Response response) {
unlikeToken = Utility.safeGetStringFromResponse(response.getGraphObject(), "id");
}
@Override
protected void processError(FacebookRequestError error) {
int errorCode = error.getErrorCode();
if (errorCode == ERROR_CODE_OBJECT_ALREADY_LIKED) {
// This isn't an error for us. Client was just out of sync with server
// This will prevent us from showing the dialog for this.
// However, there is no unliketoken. So a subsequent unlike WILL show the dialog
this.error = null;
} else {
Logger.log(LoggingBehavior.REQUESTS,
TAG,
"Error liking object '%s' : %s", objectId, error);
logAppEventForError("publish_like", error);
}
}
}
private class PublishUnlikeRequestWrapper extends AbstractRequestWrapper {
private String unlikeToken;
PublishUnlikeRequestWrapper(String unlikeToken) {
super(null);
this.unlikeToken = unlikeToken;
setRequest(new Request(session, unlikeToken, null, HttpMethod.DELETE));
}
@Override
protected void processSuccess(Response response) {
}
@Override
protected void processError(FacebookRequestError error) {
Logger.log(LoggingBehavior.REQUESTS,
TAG,
"Error unliking object with unlike token '%s' : %s", unlikeToken, error);
logAppEventForError("publish_unlike", error);
}
}
private class GetOGObjectLikesRequestWrapper extends AbstractRequestWrapper {
boolean objectIsLiked;
String unlikeToken;
GetOGObjectLikesRequestWrapper(String objectId) {
super(objectId);
Bundle requestParams = new Bundle();
requestParams.putString("fields", "id,application");
requestParams.putString("object", objectId);
setRequest(new Request(session, "me/og.likes", requestParams, HttpMethod.GET));
}
@Override
protected void processSuccess(Response response) {
JSONArray dataSet = Utility.tryGetJSONArrayFromResponse(response.getGraphObject(), "data");
if (dataSet != null) {
for (int i = 0; i < dataSet.length(); i++) {
JSONObject data = dataSet.optJSONObject(i);
if (data != null) {
objectIsLiked = true;
JSONObject appData = data.optJSONObject("application");
if (appData != null) {
if (Utility.areObjectsEqual(session.getApplicationId(), appData.optString("id"))) {
unlikeToken = data.optString("id");
}
}
}
}
}
}
@Override
protected void processError(FacebookRequestError error) {
Logger.log(LoggingBehavior.REQUESTS,
TAG,
"Error fetching like status for object '%s' : %s", objectId, error);
logAppEventForError("get_og_object_like", error);
}
}
private class GetEngagementRequestWrapper extends AbstractRequestWrapper {
String likeCountStringWithLike;
String likeCountStringWithoutLike;
String socialSentenceStringWithLike;
String socialSentenceStringWithoutLike;
GetEngagementRequestWrapper(String objectId) {
super(objectId);
Bundle requestParams = new Bundle();
requestParams.putString(
"fields",
"engagement.fields(" +
"count_string_with_like," +
"count_string_without_like," +
"social_sentence_with_like," +
"social_sentence_without_like)");
setRequest(new Request(session, objectId, requestParams, HttpMethod.GET));
}
@Override
protected void processSuccess(Response response) {
JSONObject engagementResults = Utility.tryGetJSONObjectFromResponse(response.getGraphObject(), "engagement");
if (engagementResults != null) {
likeCountStringWithLike = engagementResults.optString("count_string_with_like");
likeCountStringWithoutLike = engagementResults.optString("count_string_without_like");
socialSentenceStringWithLike = engagementResults.optString("social_sentence_with_like");
socialSentenceStringWithoutLike = engagementResults.optString("social_sentence_without_like");
}
}
@Override
protected void processError(FacebookRequestError error) {
Logger.log(LoggingBehavior.REQUESTS,
TAG,
"Error fetching engagement for object '%s' : %s", objectId, error);
logAppEventForError("get_engagement", error);
}
}
private abstract class AbstractRequestWrapper {
private Request request;
protected String objectId;
FacebookRequestError error;
protected AbstractRequestWrapper(String objectId) {
this.objectId = objectId;
}
void addToBatch(RequestBatch batch) {
batch.add(request);
}
protected void setRequest(Request request) {
this.request = request;
// Make sure that our requests are hitting the latest version of the API known to this sdk.
request.setVersion(ServerProtocol.GRAPH_API_VERSION);
request.setCallback(new Request.Callback() {
@Override
public void onCompleted(Response response) {
error = response.getError();
if (error != null) {
processError(error);
} else {
processSuccess(response);
}
}
});
}
protected void processError(FacebookRequestError error) {
Logger.log(LoggingBehavior.REQUESTS,
TAG,
"Error running request for object '%s' : %s", objectId, error);
}
protected abstract void processSuccess(Response response);
}
private enum LikeDialogFeature implements FacebookDialog.DialogFeature {
LIKE_DIALOG(NativeProtocol.PROTOCOL_VERSION_20140701);
private int minVersion;
private LikeDialogFeature(int minVersion) {
this.minVersion = minVersion;
}
public String getAction() {
return NativeProtocol.ACTION_LIKE_DIALOG;
}
public int getMinVersion() {
return minVersion;
}
}
private static class LikeDialogBuilder extends FacebookDialog.Builder<LikeDialogBuilder> {
private String objectId;
public LikeDialogBuilder(Activity activity, String objectId) {
super(activity);
this.objectId = objectId;
}
@Override
protected EnumSet<? extends FacebookDialog.DialogFeature> getDialogFeatures() {
return EnumSet.of(LikeDialogFeature.LIKE_DIALOG);
}
@Override
protected Bundle getMethodArguments() {
Bundle methodArgs = new Bundle();
methodArgs.putString(NativeProtocol.METHOD_ARGS_OBJECT_ID, objectId);
return methodArgs;
}
public FacebookDialog.PendingCall getAppCall() {
return appCall;
}
public String getApplicationId() {
return applicationId;
}
public String getWebFallbackUrl() {
return getWebFallbackUrlInternal();
}
}
// Performs cache re-ordering/trimming to keep most-recently-used items up front
// ** NOTE ** It is expected that only _ONE_ MRUCacheWorkItem is ever running. This is enforced by
// setting the concurrency of the WorkQueue to 1. Changing the concurrency will most likely lead to errors.
private static class MRUCacheWorkItem implements Runnable {
private static ArrayList<String> mruCachedItems = new ArrayList<String>();
private String cacheItem;
private boolean shouldTrim;
MRUCacheWorkItem(String cacheItem, boolean shouldTrim) {
this.cacheItem = cacheItem;
this.shouldTrim = shouldTrim;
}
@Override
public void run() {
if (cacheItem != null) {
mruCachedItems.remove(cacheItem);
mruCachedItems.add(0, cacheItem);
}
if (shouldTrim && mruCachedItems.size() >= MAX_CACHE_SIZE) {
int targetSize = MAX_CACHE_SIZE / 2; // Optimize for fewer trim-passes.
while (targetSize < mruCachedItems.size()) {
String cacheKey = mruCachedItems.remove(mruCachedItems.size() - 1);
// Here is where we actually remove from the cache of LikeActionControllers.
cache.remove(cacheKey);
}
}
}
}
private static class SerializeToDiskWorkItem implements Runnable {
private String cacheKey;
private String controllerJson;
SerializeToDiskWorkItem(String cacheKey, String controllerJson) {
this.cacheKey = cacheKey;
this.controllerJson = controllerJson;
}
@Override
public void run() {
serializeToDiskSynchronously(cacheKey, controllerJson);
}
}
private static class CreateLikeActionControllerWorkItem implements Runnable {
private Context context;
private String objectId;
private CreationCallback callback;
CreateLikeActionControllerWorkItem(Context context, String objectId, CreationCallback callback) {
this.context = context;
this.objectId = objectId;
this.callback = callback;
}
@Override
public void run() {
createControllerForObjectId(context, objectId, callback);
}
}
}