/** * 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); } }