/**
* 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.share.internal;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import android.util.Log;
import com.facebook.AccessToken;
import com.facebook.AccessTokenTracker;
import com.facebook.FacebookCallback;
import com.facebook.FacebookException;
import com.facebook.FacebookGraphResponseException;
import com.facebook.FacebookRequestError;
import com.facebook.FacebookSdk;
import com.facebook.GraphRequest;
import com.facebook.GraphResponse;
import com.facebook.HttpMethod;
import com.facebook.internal.Utility;
import com.facebook.internal.Validate;
import com.facebook.internal.WorkQueue;
import com.facebook.share.Sharer;
import com.facebook.share.model.ShareVideo;
import com.facebook.share.model.ShareVideoContent;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* com.facebook.share.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 VideoUploader {
private static final String TAG = "VideoUploader";
private static final String PARAM_UPLOAD_PHASE = "upload_phase";
private static final String PARAM_VALUE_UPLOAD_START_PHASE = "start";
private static final String PARAM_VALUE_UPLOAD_TRANSFER_PHASE = "transfer";
private static final String PARAM_VALUE_UPLOAD_FINISH_PHASE = "finish";
private static final String PARAM_TITLE = "title";
private static final String PARAM_DESCRIPTION = "description";
private static final String PARAM_REF = "ref";
private static final String PARAM_FILE_SIZE = "file_size";
private static final String PARAM_SESSION_ID = "upload_session_id";
private static final String PARAM_VIDEO_ID = "video_id";
private static final String PARAM_START_OFFSET = "start_offset";
private static final String PARAM_END_OFFSET = "end_offset";
private static final String PARAM_VIDEO_FILE_CHUNK = "video_file_chunk";
private static final String ERROR_UPLOAD = "Video upload failed";
private static final String ERROR_BAD_SERVER_RESPONSE = "Unexpected error in server response";
private static final int UPLOAD_QUEUE_MAX_CONCURRENT = WorkQueue.DEFAULT_MAX_CONCURRENT;
private static final int MAX_RETRIES_PER_PHASE = 2;
private static final int RETRY_DELAY_UNIT_MS = 5000;
private static final int RETRY_DELAY_BACK_OFF_FACTOR = 3;
private static boolean initialized;
private static Handler handler;
private static WorkQueue uploadQueue = new WorkQueue(UPLOAD_QUEUE_MAX_CONCURRENT);
private static Set<UploadContext> pendingUploads = new HashSet<>();
private static AccessTokenTracker accessTokenTracker;
public static synchronized void uploadAsync(
ShareVideoContent videoContent,
FacebookCallback<Sharer.Result> callback)
throws FileNotFoundException {
uploadAsync(videoContent, "me", callback);
}
public static synchronized void uploadAsync(
ShareVideoContent videoContent,
String graphNode,
FacebookCallback<Sharer.Result> callback)
throws FileNotFoundException {
if (!initialized) {
registerAccessTokenTracker();
initialized = true;
}
Validate.notNull(videoContent, "videoContent");
Validate.notNull(graphNode, "graphNode");
ShareVideo video = videoContent.getVideo();
Validate.notNull(video, "videoContent.video");
Uri videoUri = video.getLocalUrl();
Validate.notNull(videoUri, "videoContent.video.localUrl");
UploadContext uploadContext = new UploadContext(videoContent, graphNode, callback);
uploadContext.initialize();
pendingUploads.add(uploadContext);
enqueueUploadStart(
uploadContext,
0);
}
private static synchronized void cancelAllRequests() {
for (UploadContext uploadContext : pendingUploads) {
uploadContext.isCanceled = true;
}
}
private static synchronized void removePendingUpload(
UploadContext uploadContext) {
pendingUploads.remove(uploadContext);
}
private static synchronized Handler getHandler() {
if (handler == null) {
handler = new Handler(Looper.getMainLooper());
}
return handler;
}
private static void issueResponse(
final UploadContext uploadContext,
final FacebookException error,
final String videoId) {
// Remove the UploadContext synchronously
// Once the UploadContext is removed, this is the only reference to it.
removePendingUpload(uploadContext);
Utility.closeQuietly(uploadContext.videoStream);
if (uploadContext.callback != null) {
if (error != null) {
ShareInternalUtility.invokeOnErrorCallback(uploadContext.callback, error);
} else if (uploadContext.isCanceled) {
ShareInternalUtility.invokeOnCancelCallback(uploadContext.callback);
} else {
ShareInternalUtility.invokeOnSuccessCallback(uploadContext.callback, videoId);
}
}
}
private static void enqueueUploadStart(UploadContext uploadContext, int completedRetries) {
enqueueRequest(
uploadContext,
new StartUploadWorkItem(
uploadContext,
completedRetries));
}
private static void enqueueUploadChunk(
UploadContext uploadContext,
String chunkStart,
String chunkEnd,
int completedRetries) {
enqueueRequest(
uploadContext,
new TransferChunkWorkItem(
uploadContext,
chunkStart,
chunkEnd,
completedRetries));
}
private static void enqueueUploadFinish(UploadContext uploadContext, int completedRetries) {
enqueueRequest(
uploadContext,
new FinishUploadWorkItem(
uploadContext,
completedRetries));
}
private static synchronized void enqueueRequest(
UploadContext uploadContext,
Runnable workItem) {
uploadContext.workItem = uploadQueue.addActiveWorkItem(workItem);
}
private static byte[] getChunk(
UploadContext uploadContext,
String chunkStart,
String chunkEnd)
throws IOException {
if (!Utility.areObjectsEqual(chunkStart, uploadContext.chunkStart)) {
// Something went wrong in the book-keeping here.
logError(
null,
"Error reading video chunk. Expected chunk '%s'. Requested chunk '%s'.",
uploadContext.chunkStart,
chunkStart);
return null;
}
long chunkStartLong = Long.parseLong(chunkStart);
long chunkEndLong = Long.parseLong(chunkEnd);
int chunkSize = (int) (chunkEndLong - chunkStartLong);
ByteArrayOutputStream byteBufferStream = new ByteArrayOutputStream();
int bufferSize = Math.min(8192, chunkSize);
byte[] buffer = new byte[bufferSize];
int len;
while ((len = uploadContext.videoStream.read(buffer)) != -1) {
byteBufferStream.write(buffer, 0, len);
chunkSize -= len;
if (chunkSize == 0) {
// Done!
break;
} else if (chunkSize < 0) {
// This should not happen. Signal an error.
logError(
null,
"Error reading video chunk. Expected buffer length - '%d'. Actual - '%d'.",
chunkSize + len,
len);
return null;
}
}
uploadContext.chunkStart = chunkEnd;
return byteBufferStream.toByteArray();
}
private static void registerAccessTokenTracker() {
accessTokenTracker = new AccessTokenTracker() {
@Override
protected void onCurrentAccessTokenChanged(
AccessToken oldAccessToken,
AccessToken currentAccessToken) {
if (oldAccessToken == null) {
// If we never had an access token, then there would be no pending uploads.
return;
}
if (currentAccessToken == null ||
!Utility.areObjectsEqual(
currentAccessToken.getUserId(),
oldAccessToken.getUserId())) {
// Cancel any pending uploads since the user changed.
cancelAllRequests();
}
}
};
}
private static void logError(
Exception e,
String format,
Object... args) {
Log.e(TAG, String.format(Locale.ROOT, format, args), e);
}
private static class UploadContext {
public final Uri videoUri;
public final String title;
public final String description;
public final String ref;
public final String graphNode;
public final AccessToken accessToken;
public final FacebookCallback<Sharer.Result> callback;
public String sessionId;
public String videoId;
public InputStream videoStream;
public long videoSize;
public String chunkStart = "0";
public boolean isCanceled;
public WorkQueue.WorkItem workItem;
public Bundle params;
private UploadContext(
ShareVideoContent videoContent,
String graphNode,
FacebookCallback<Sharer.Result> callback) {
// Store off the access token right away so that under no circumstances will we
// end up with different tokens between phases. We will rely on the access token tracker
// to cancel pending uploads.
this.accessToken = AccessToken.getCurrentAccessToken();
this.videoUri = videoContent.getVideo().getLocalUrl();
this.title = videoContent.getContentTitle();
this.description = videoContent.getContentDescription();
this.ref = videoContent.getRef();
this.graphNode = graphNode;
this.callback = callback;
this.params = videoContent.getVideo().getParameters();
if (!Utility.isNullOrEmpty(videoContent.getPeopleIds())) {
this.params.putString("tags", TextUtils.join(", ", videoContent.getPeopleIds()));
}
if (!Utility.isNullOrEmpty(videoContent.getPlaceId())) {
this.params.putString("place", videoContent.getPlaceId());
}
if (!Utility.isNullOrEmpty(videoContent.getRef())) {
this.params.putString("ref", videoContent.getRef());
}
}
private void initialize()
throws FileNotFoundException {
ParcelFileDescriptor fileDescriptor;
try {
if (Utility.isFileUri(videoUri)) {
fileDescriptor = ParcelFileDescriptor.open(
new File(videoUri.getPath()),
ParcelFileDescriptor.MODE_READ_ONLY);
videoSize = fileDescriptor.getStatSize();
videoStream = new ParcelFileDescriptor.AutoCloseInputStream(fileDescriptor);
} else if (Utility.isContentUri(videoUri)) {
videoSize = Utility.getContentSize(videoUri);
videoStream = FacebookSdk
.getApplicationContext()
.getContentResolver()
.openInputStream(videoUri);
} else {
throw new FacebookException("Uri must be a content:// or file:// uri");
}
} catch (FileNotFoundException e) {
Utility.closeQuietly(videoStream);
throw e;
}
}
}
private static class StartUploadWorkItem extends UploadWorkItemBase {
static final Set<Integer> transientErrorCodes = new HashSet<Integer>() {{
add(6000);
}};
public StartUploadWorkItem(UploadContext uploadContext, int completedRetries) {
super(uploadContext, completedRetries);
}
@Override
public Bundle getParameters() {
Bundle parameters = new Bundle();
parameters.putString(PARAM_UPLOAD_PHASE, PARAM_VALUE_UPLOAD_START_PHASE);
parameters.putLong(PARAM_FILE_SIZE, uploadContext.videoSize);
return parameters;
}
@Override
protected void handleSuccess(JSONObject jsonObject)
throws JSONException {
uploadContext.sessionId = jsonObject.getString(PARAM_SESSION_ID);
uploadContext.videoId = jsonObject.getString(PARAM_VIDEO_ID);
String startOffset = jsonObject.getString(PARAM_START_OFFSET);
String endOffset = jsonObject.getString(PARAM_END_OFFSET);
enqueueUploadChunk(
uploadContext,
startOffset,
endOffset,
0);
}
@Override
protected void handleError(FacebookException error) {
logError(error, "Error starting video upload");
endUploadWithFailure(error);
}
@Override
protected Set<Integer> getTransientErrorCodes() {
return transientErrorCodes;
}
@Override
protected void enqueueRetry(int retriesCompleted) {
enqueueUploadStart(uploadContext, retriesCompleted);
}
}
private static class TransferChunkWorkItem extends UploadWorkItemBase {
static final Set<Integer> transientErrorCodes = new HashSet<Integer>() {{
add(1363019);
add(1363021);
add(1363030);
add(1363033);
add(1363041);
}};
private String chunkStart;
private String chunkEnd;
public TransferChunkWorkItem(
UploadContext uploadContext,
String chunkStart,
String chunkEnd,
int completedRetries) {
super(uploadContext, completedRetries);
this.chunkStart = chunkStart;
this.chunkEnd = chunkEnd;
}
@Override
public Bundle getParameters()
throws IOException {
Bundle parameters = new Bundle();
parameters.putString(PARAM_UPLOAD_PHASE, PARAM_VALUE_UPLOAD_TRANSFER_PHASE);
parameters.putString(PARAM_SESSION_ID, uploadContext.sessionId);
parameters.putString(PARAM_START_OFFSET, chunkStart);
byte[] chunk = getChunk(uploadContext, chunkStart, chunkEnd);
if (chunk != null) {
parameters.putByteArray(PARAM_VIDEO_FILE_CHUNK, chunk);
} else {
throw new FacebookException("Error reading video");
}
return parameters;
}
@Override
protected void handleSuccess(JSONObject jsonObject)
throws JSONException {
String startOffset = jsonObject.getString(PARAM_START_OFFSET);
String endOffset = jsonObject.getString(PARAM_END_OFFSET);
if (Utility.areObjectsEqual(startOffset, endOffset)) {
enqueueUploadFinish(
uploadContext,
0);
} else {
enqueueUploadChunk(
uploadContext,
startOffset,
endOffset,
0);
}
}
@Override
protected void handleError(FacebookException error) {
logError(error, "Error uploading video '%s'", uploadContext.videoId);
endUploadWithFailure(error);
}
@Override
protected Set<Integer> getTransientErrorCodes() {
return transientErrorCodes;
}
@Override
protected void enqueueRetry(int retriesCompleted) {
enqueueUploadChunk(uploadContext, chunkStart, chunkEnd, retriesCompleted);
}
}
private static class FinishUploadWorkItem extends UploadWorkItemBase {
static final Set<Integer> transientErrorCodes = new HashSet<Integer>() {{
add(1363011);
}};
public FinishUploadWorkItem(UploadContext uploadContext, int completedRetries) {
super(uploadContext, completedRetries);
}
@Override
public Bundle getParameters() {
Bundle parameters = new Bundle();
if (uploadContext.params != null) {
parameters.putAll(uploadContext.params);
}
parameters.putString(PARAM_UPLOAD_PHASE, PARAM_VALUE_UPLOAD_FINISH_PHASE);
parameters.putString(PARAM_SESSION_ID, uploadContext.sessionId);
Utility.putNonEmptyString(parameters, PARAM_TITLE, uploadContext.title);
Utility.putNonEmptyString(parameters, PARAM_DESCRIPTION, uploadContext.description);
Utility.putNonEmptyString(parameters, PARAM_REF, uploadContext.ref);
return parameters;
}
@Override
protected void handleSuccess(JSONObject jsonObject)
throws JSONException {
if (jsonObject.getBoolean("success")) {
issueResponseOnMainThread(null, uploadContext.videoId);
} else {
handleError(new FacebookException(ERROR_BAD_SERVER_RESPONSE));
}
}
@Override
protected void handleError(FacebookException error) {
logError(error, "Video '%s' failed to finish uploading", uploadContext.videoId);
endUploadWithFailure(error);
}
@Override
protected Set<Integer> getTransientErrorCodes() {
return transientErrorCodes;
}
@Override
protected void enqueueRetry(int retriesCompleted) {
enqueueUploadFinish(uploadContext, retriesCompleted);
}
}
private static abstract class UploadWorkItemBase implements Runnable {
protected UploadContext uploadContext;
protected int completedRetries;
protected UploadWorkItemBase(
UploadContext uploadContext,
int completedRetries) {
this.uploadContext = uploadContext;
this.completedRetries = completedRetries;
}
@Override
public void run() {
if (!uploadContext.isCanceled) {
try {
executeGraphRequestSynchronously(getParameters());
} catch (FacebookException fe) {
endUploadWithFailure(fe);
} catch (Exception e) {
endUploadWithFailure(new FacebookException(ERROR_UPLOAD, e));
}
} else {
// No specific failure here.
endUploadWithFailure(null);
}
}
protected void executeGraphRequestSynchronously(Bundle parameters) {
GraphRequest request = new GraphRequest(
uploadContext.accessToken,
String.format(Locale.ROOT, "%s/videos", uploadContext.graphNode),
parameters,
HttpMethod.POST,
null);
GraphResponse response = request.executeAndWait();
if (response != null) {
FacebookRequestError error = response.getError();
JSONObject responseJSON = response.getJSONObject();
if (error != null) {
if (!attemptRetry(error.getSubErrorCode())) {
handleError(new FacebookGraphResponseException(response, ERROR_UPLOAD));
}
} else if (responseJSON != null) {
try {
handleSuccess(responseJSON);
} catch (JSONException e) {
endUploadWithFailure(new FacebookException(ERROR_BAD_SERVER_RESPONSE, e));
}
} else {
handleError(new FacebookException(ERROR_BAD_SERVER_RESPONSE));
}
} else {
handleError(new FacebookException(ERROR_BAD_SERVER_RESPONSE));
}
}
private boolean attemptRetry(int errorCode) {
if (completedRetries < MAX_RETRIES_PER_PHASE &&
getTransientErrorCodes().contains(errorCode)) {
int delay = RETRY_DELAY_UNIT_MS * (int) Math.pow(
RETRY_DELAY_BACK_OFF_FACTOR, completedRetries);
// Enqueuing the retry from the main thread which should be a lightweight
// action with no I/O.
getHandler().postDelayed(new Runnable() {
@Override
public void run() {
enqueueRetry(completedRetries + 1);
}
}, delay);
return true;
} else {
return false;
}
}
protected void endUploadWithFailure(FacebookException error) {
issueResponseOnMainThread(error, null);
}
protected void issueResponseOnMainThread(
final FacebookException error,
final String videoId) {
getHandler().post(new Runnable() {
@Override
public void run() {
issueResponse(uploadContext, error, videoId);
}
});
}
protected abstract Bundle getParameters()
throws Exception;
protected abstract void handleSuccess(JSONObject jsonObject)
throws JSONException;
protected abstract void handleError(FacebookException error);
protected abstract Set<Integer> getTransientErrorCodes();
protected abstract void enqueueRetry(int retriesCompleted);
}
}