package org.wordpress.android.ui.posts.services;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.media.ThumbnailUtils;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.IBinder;
import android.provider.MediaStore.Images;
import android.provider.MediaStore.Video;
import android.support.annotation.NonNull;
import android.text.TextUtils;
import android.util.SparseArray;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.wordpress.android.R;
import org.wordpress.android.WordPress;
import org.wordpress.android.analytics.AnalyticsTracker.Stat;
import org.wordpress.android.fluxc.Dispatcher;
import org.wordpress.android.fluxc.generated.MediaActionBuilder;
import org.wordpress.android.fluxc.generated.PostActionBuilder;
import org.wordpress.android.fluxc.model.MediaModel;
import org.wordpress.android.fluxc.model.MediaModel.UploadState;
import org.wordpress.android.fluxc.model.PostModel;
import org.wordpress.android.fluxc.model.SiteModel;
import org.wordpress.android.fluxc.model.post.PostStatus;
import org.wordpress.android.fluxc.store.MediaStore;
import org.wordpress.android.fluxc.store.MediaStore.MediaError;
import org.wordpress.android.fluxc.store.MediaStore.MediaPayload;
import org.wordpress.android.fluxc.store.MediaStore.OnMediaUploaded;
import org.wordpress.android.fluxc.store.PostStore.OnPostUploaded;
import org.wordpress.android.fluxc.store.PostStore.PostError;
import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload;
import org.wordpress.android.fluxc.store.SiteStore;
import org.wordpress.android.ui.posts.services.PostEvents.PostUploadStarted;
import org.wordpress.android.ui.prefs.AppPrefs;
import org.wordpress.android.util.AnalyticsUtils;
import org.wordpress.android.util.AppLog;
import org.wordpress.android.util.AppLog.T;
import org.wordpress.android.util.DisplayUtils;
import org.wordpress.android.util.FluxCUtils;
import org.wordpress.android.util.ImageUtils;
import org.wordpress.android.util.MediaUtils;
import org.wordpress.android.util.SqlUtils;
import org.wordpress.android.util.helpers.MediaFile;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.inject.Inject;
import de.greenrobot.event.EventBus;
public class PostUploadService extends Service {
private static final ArrayList<PostModel> mPostsList = new ArrayList<>();
private static PostModel mCurrentUploadingPost = null;
private static Map<String, Object> mCurrentUploadingPostAnalyticsProperties;
private static boolean mUseLegacyMode;
private UploadPostTask mCurrentTask = null;
private static final Set<Integer> mFirstPublishPosts = new HashSet<>();
private Context mContext;
private PostUploadNotifier mPostUploadNotifier;
private SparseArray<CountDownLatch> mMediaLatchMap = new SparseArray<>();
@Inject Dispatcher mDispatcher;
@Inject SiteStore mSiteStore;
@Inject MediaStore mMediaStore;
/**
* Adds a post to the queue.
*/
public static void addPostToUpload(PostModel post) {
synchronized (mPostsList) {
mPostsList.add(post);
}
}
/**
* Adds a post to the queue and tracks post analytics.
* To be used only the first time a post is uploaded, i.e. when its status changes from local draft or remote draft
* to published.
*/
public static void addPostToUploadAndTrackAnalytics(PostModel post) {
synchronized (mFirstPublishPosts) {
mFirstPublishPosts.add(post.getId());
}
synchronized (mPostsList) {
mPostsList.add(post);
}
}
public static void setLegacyMode(boolean enabled) {
mUseLegacyMode = enabled;
}
/**
* Returns true if the passed post is either uploading or waiting to be uploaded.
*/
public static boolean isPostUploading(PostModel post) {
// first check the currently uploading post
if (mCurrentUploadingPost != null && mCurrentUploadingPost.getId() == post.getId()) {
return true;
}
// then check the list of posts waiting to be uploaded
if (mPostsList.size() > 0) {
synchronized (mPostsList) {
for (PostModel queuedPost : mPostsList) {
if (queuedPost.getId() == post.getId()) {
return true;
}
}
}
}
return false;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
@Override
public void onCreate() {
super.onCreate();
((WordPress) getApplication()).component().inject(this);
mDispatcher.register(this);
mContext = this.getApplicationContext();
mPostUploadNotifier = new PostUploadNotifier(mContext, this);
}
@Override
public void onDestroy() {
super.onDestroy();
// Cancel current task, it will reset post from "uploading" to "local draft"
if (mCurrentTask != null) {
AppLog.d(T.POSTS, "cancelling current upload task");
mCurrentTask.cancel(true);
}
mDispatcher.unregister(this);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
synchronized (mPostsList) {
if (mPostsList.size() == 0 || mContext == null) {
stopSelf();
return START_NOT_STICKY;
}
}
uploadNextPost();
// We want this service to continue running until it is explicitly stopped, so return sticky.
return START_STICKY;
}
private void uploadNextPost() {
synchronized (mPostsList) {
if (mCurrentTask == null) { //make sure nothing is running
mCurrentUploadingPost = null;
mCurrentUploadingPostAnalyticsProperties = null;
if (mPostsList.size() > 0) {
mCurrentUploadingPost = mPostsList.remove(0);
mCurrentTask = new UploadPostTask();
mCurrentTask.executeOnExecutor(AsyncTask.SERIAL_EXECUTOR, mCurrentUploadingPost);
} else {
stopSelf();
}
}
}
}
private void finishUpload() {
synchronized (mPostsList) {
mCurrentTask = null;
mCurrentUploadingPost = null;
mCurrentUploadingPostAnalyticsProperties = null;
}
uploadNextPost();
}
private class UploadPostTask extends AsyncTask<PostModel, Boolean, Boolean> {
private PostModel mPost;
private SiteModel mSite;
private String mErrorMessage = "";
private boolean mIsMediaError = false;
private long featuredImageID = -1;
// Used for analytics
private boolean mHasImage, mHasVideo, mHasCategory;
@Override
protected void onPostExecute(Boolean pushActionWasDispatched) {
if (!pushActionWasDispatched) {
// This block only runs if the PUSH_POST action was never dispatched - if it was dispatched, any error
// will be handled in OnPostChanged instead of here
mPostUploadNotifier.updateNotificationError(mPost, mSite, mErrorMessage, mIsMediaError);
finishUpload();
}
}
@Override
protected Boolean doInBackground(PostModel... posts) {
mPost = posts[0];
String postTitle = TextUtils.isEmpty(mPost.getTitle()) ? getString(R.string.untitled) : mPost.getTitle();
String uploadingPostTitle = String.format(getString(R.string.posting_post), postTitle);
String uploadingPostMessage = String.format(
getString(R.string.sending_content),
mPost.isPage() ? getString(R.string.page).toLowerCase() : getString(R.string.post).toLowerCase()
);
mPostUploadNotifier.updateNotificationNewPost(mPost, uploadingPostTitle, uploadingPostMessage);
mSite = mSiteStore.getSiteByLocalId(mPost.getLocalSiteId());
if (mSite == null) {
mErrorMessage = mContext.getString(R.string.blog_not_found);
return false;
}
if (TextUtils.isEmpty(mPost.getStatus())) {
mPost.setStatus(PostStatus.PUBLISHED.toString());
}
String content = mPost.getContent();
// Get rid of ZERO WIDTH SPACE character that the Visual editor can insert
// at the beginning of the content.
// http://www.fileformat.info/info/unicode/char/200b/index.htm
// See: https://github.com/wordpress-mobile/WordPress-Android/issues/5009
if (content.length() > 0 && content.charAt(0) == '\u200B') {
content = content.substring(1, content.length());
}
content = processPostMedia(content);
mPost.setContent(content);
// If media file upload failed, let's stop here and prompt the user
if (mIsMediaError) {
return false;
}
if (mPost.getCategoryIdList().size() > 0) {
mHasCategory = true;
}
// Support for legacy editor - images are identified as featured as they're being uploaded with the post
if (mUseLegacyMode && featuredImageID != -1) {
mPost.setFeaturedImageId(featuredImageID);
}
// Track analytics only if the post is newly published
if (mFirstPublishPosts.contains(mPost.getId())) {
prepareUploadAnalytics(mPost.getContent());
}
EventBus.getDefault().post(new PostUploadStarted(mPost.getLocalSiteId()));
RemotePostPayload payload = new RemotePostPayload(mPost, mSite);
mDispatcher.dispatch(PostActionBuilder.newPushPostAction(payload));
return true;
}
private boolean hasGallery() {
Pattern galleryTester = Pattern.compile("\\[.*?gallery.*?\\]");
Matcher matcher = galleryTester.matcher(mPost.getContent());
return matcher.find();
}
private void prepareUploadAnalytics(String postContent) {
// Calculate the words count
mCurrentUploadingPostAnalyticsProperties = new HashMap<>();
mCurrentUploadingPostAnalyticsProperties.put("word_count", AnalyticsUtils.getWordCount(mPost.getContent()));
if (hasGallery()) {
mCurrentUploadingPostAnalyticsProperties.put("with_galleries", true);
}
if (!mHasImage) {
// Check if there is a img tag in the post. Media added in any editor other than legacy.
String imageTagsPattern = "<img[^>]+src\\s*=\\s*[\"]([^\"]+)[\"][^>]*>";
Pattern pattern = Pattern.compile(imageTagsPattern);
Matcher matcher = pattern.matcher(postContent);
mHasImage = matcher.find();
}
if (mHasImage) {
mCurrentUploadingPostAnalyticsProperties.put("with_photos", true);
}
if (!mHasVideo) {
// Check if there is a video tag in the post. Media added in any editor other than legacy.
String videoTagsPattern = "<video[^>]+src\\s*=\\s*[\"]([^\"]+)[\"][^>]*>|\\[wpvideo\\s+([^\\]]+)\\]";
Pattern pattern = Pattern.compile(videoTagsPattern);
Matcher matcher = pattern.matcher(postContent);
mHasVideo = matcher.find();
}
if (mHasVideo) {
mCurrentUploadingPostAnalyticsProperties.put("with_videos", true);
}
if (mHasCategory) {
mCurrentUploadingPostAnalyticsProperties.put("with_categories", true);
}
if (!mPost.getTagNameList().isEmpty()) {
mCurrentUploadingPostAnalyticsProperties.put("with_tags", true);
}
mCurrentUploadingPostAnalyticsProperties.put("via_new_editor", AppPrefs.isVisualEditorEnabled());
}
/**
* Finds media in post content, uploads them, and returns the HTML to insert in the post
*/
private String processPostMedia(String postContent) {
String imageTagsPattern = "<img[^>]+android-uri\\s*=\\s*['\"]([^'\"]+)['\"][^>]*>";
Pattern pattern = Pattern.compile(imageTagsPattern);
Matcher matcher = pattern.matcher(postContent);
int totalMediaItems = 0;
List<String> imageTags = new ArrayList<>();
while (matcher.find()) {
imageTags.add(matcher.group());
totalMediaItems++;
}
mPostUploadNotifier.setTotalMediaItems(mPost, totalMediaItems);
int mediaItemCount = 0;
for (String tag : imageTags) {
Pattern p = Pattern.compile("android-uri=\"([^\"]+)\"");
Matcher m = p.matcher(tag);
if (m.find()) {
String imageUri = m.group(1);
if (!imageUri.equals("")) {
MediaModel mediaModel = mMediaStore.getPostMediaWithPath(mPost.getId(), imageUri);
if (mediaModel == null) {
mIsMediaError = true;
continue;
}
MediaFile mediaFile = FluxCUtils.mediaFileFromMediaModel(mediaModel);
if (mediaFile != null) {
// Get image thumbnail for notification icon
Bitmap imageIcon = ImageUtils.getWPImageSpanThumbnailFromFilePath(
mContext,
imageUri,
DisplayUtils.dpToPx(mContext, 128)
);
// Crop the thumbnail to be squared in the center
if (imageIcon != null) {
int squaredSize = DisplayUtils.dpToPx(mContext, 64);
imageIcon = ThumbnailUtils.extractThumbnail(imageIcon, squaredSize, squaredSize);
}
mediaItemCount++;
mPostUploadNotifier.setCurrentMediaItem(mPost, mediaItemCount);
mPostUploadNotifier.updateNotificationIcon(mPost, imageIcon);
String mediaUploadOutput;
if (mediaFile.isVideo()) {
mHasVideo = true;
mediaUploadOutput = uploadVideo(mediaFile);
} else {
mHasImage = true;
mediaUploadOutput = uploadImage(mediaFile);
}
if (mediaUploadOutput != null) {
postContent = postContent.replace(tag, mediaUploadOutput);
} else {
postContent = postContent.replace(tag, "");
mIsMediaError = true;
}
}
}
}
}
return postContent;
}
private String uploadImage(MediaFile mediaFile) {
AppLog.d(T.POSTS, "uploadImage: " + mediaFile.getFilePath());
if (mediaFile.getFilePath() == null) {
return null;
}
Uri imageUri = Uri.parse(mediaFile.getFilePath());
File imageFile = null;
if (imageUri.toString().contains("content:")) {
String[] projection = new String[]{Images.Media._ID, Images.Media.DATA, Images.Media.MIME_TYPE};
Cursor cur = mContext.getContentResolver().query(imageUri, projection, null, null, null);
if (cur != null && cur.moveToFirst()) {
int dataColumn = cur.getColumnIndex(Images.Media.DATA);
String thumbData = cur.getString(dataColumn);
imageFile = new File(thumbData);
mediaFile.setFilePath(imageFile.getPath());
}
SqlUtils.closeCursor(cur);
} else { // file is not in media library
String path = imageUri.toString().replace("file://", "");
imageFile = new File(path);
mediaFile.setFilePath(path);
}
// check if the file exists
if (imageFile == null) {
mErrorMessage = mContext.getString(R.string.file_not_found);
return null;
}
String fullSizeUrl = uploadImageFile(mediaFile, mSite);
if (fullSizeUrl == null) {
mErrorMessage = mContext.getString(R.string.error_media_upload);
return null;
}
return mediaFile.getImageHtmlForUrls(fullSizeUrl, null, false);
}
private String uploadVideo(MediaFile mediaFile) {
// create temp file for media upload
String tempFileName = "wp-" + System.currentTimeMillis();
try {
mContext.openFileOutput(tempFileName, Context.MODE_PRIVATE);
} catch (FileNotFoundException e) {
mErrorMessage = getResources().getString(R.string.file_error_create);
return null;
}
if (mediaFile.getFilePath() == null) {
mErrorMessage = mContext.getString(R.string.error_media_upload);
return null;
}
Uri videoUri = Uri.parse(mediaFile.getFilePath());
File videoFile = null;
String mimeType = "", xRes = "", yRes = "";
if (videoUri.toString().contains("content:")) {
String[] projection = new String[]{Video.Media._ID, Video.Media.DATA, Video.Media.MIME_TYPE,
Video.Media.RESOLUTION};
Cursor cur = mContext.getContentResolver().query(videoUri, projection, null, null, null);
if (cur != null && cur.moveToFirst()) {
int dataColumn = cur.getColumnIndex(Video.Media.DATA);
int mimeTypeColumn = cur.getColumnIndex(Video.Media.MIME_TYPE);
int resolutionColumn = cur.getColumnIndex(Video.Media.RESOLUTION);
mediaFile = new MediaFile();
String thumbData = cur.getString(dataColumn);
mimeType = cur.getString(mimeTypeColumn);
videoFile = new File(thumbData);
mediaFile.setFilePath(videoFile.getPath());
String resolution = cur.getString(resolutionColumn);
if (resolution != null) {
String[] resolutions = resolution.split("x");
if (resolutions.length >= 2) {
xRes = resolutions[0];
yRes = resolutions[1];
}
} else {
// Default resolution
xRes = "640";
yRes = "480";
}
}
SqlUtils.closeCursor(cur);
} else { // file is not in media library
String filePath = videoUri.toString().replace("file://", "");
mediaFile.setFilePath(filePath);
videoFile = new File(filePath);
}
if (videoFile == null) {
mErrorMessage = mContext.getResources().getString(R.string.error_media_upload);
return null;
}
if (TextUtils.isEmpty(mimeType)) {
mimeType = MediaUtils.getMediaFileMimeType(videoFile);
}
CountDownLatch countDownLatch = new CountDownLatch(1);
MediaPayload payload = new MediaPayload(mSite, FluxCUtils.mediaModelFromMediaFile(mediaFile));
mDispatcher.dispatch(MediaActionBuilder.newUploadMediaAction(payload));
try {
mMediaLatchMap.put(mediaFile.getId(), countDownLatch);
countDownLatch.await();
} catch (InterruptedException e) {
AppLog.e(T.POSTS, "CountDownLatch await interrupted for media file: " + mediaFile.getId() + " - " + e);
mIsMediaError = true;
}
MediaModel finishedMedia = mMediaStore.getMediaWithLocalId(mediaFile.getId());
if (finishedMedia == null || finishedMedia.getUploadState() == null || !finishedMedia.getUploadState().equals(UploadState.UPLOADED.name())) {
mIsMediaError = true;
return null;
}
if (!TextUtils.isEmpty(finishedMedia.getVideoPressGuid())) {
return "[wpvideo " + finishedMedia.getVideoPressGuid() + "]\n";
} else {
return String.format(
"<video width=\"%s\" height=\"%s\" controls=\"controls\"><source src=\"%s\" type=\"%s\" /><a href=\"%s\">Click to view video</a>.</video>",
xRes, yRes, finishedMedia.getUrl(), mimeType, finishedMedia.getUrl());
}
}
private String uploadImageFile(MediaFile mediaFile, SiteModel site) {
CountDownLatch countDownLatch = new CountDownLatch(1);
MediaPayload payload = new MediaPayload(site, FluxCUtils.mediaModelFromMediaFile(mediaFile));
mDispatcher.dispatch(MediaActionBuilder.newUploadMediaAction(payload));
try {
mMediaLatchMap.put(mediaFile.getId(), countDownLatch);
countDownLatch.await();
} catch (InterruptedException e) {
AppLog.e(T.POSTS, "CountDownLatch await interrupted for media file: " + mediaFile.getId() + " - " + e);
mIsMediaError = true;
}
MediaModel finishedMedia = mMediaStore.getMediaWithLocalId(mediaFile.getId());
if (finishedMedia == null || finishedMedia.getUploadState() == null || !finishedMedia.getUploadState().equals(UploadState.UPLOADED.name())) {
mIsMediaError = true;
return null;
}
String pictureURL = finishedMedia.getUrl();
if (mediaFile.isFeatured()) {
featuredImageID = finishedMedia.getMediaId();
if (!mediaFile.isFeaturedInPost()) {
return "";
}
}
return pictureURL;
}
}
/**
* Returns an error message string for a failed post upload.
*/
private @NonNull String getErrorMessageFromPostError(PostModel post, PostError error) {
switch (error.type) {
case UNKNOWN_POST:
return getString(R.string.error_unknown_post);
case UNKNOWN_POST_TYPE:
return getString(R.string.error_unknown_post_type);
case UNAUTHORIZED:
return post.isPage() ? getString(R.string.error_refresh_unauthorized_pages) :
getString(R.string.error_refresh_unauthorized_posts);
}
// In case of a generic or uncaught error, return the message from the API response or the error type
return TextUtils.isEmpty(error.message) ? error.type.toString() : error.message;
}
private @NonNull String getErrorMessageFromMediaError(MediaError error) {
switch (error.type) {
case FS_READ_PERMISSION_DENIED:
return getString(R.string.error_media_insufficient_fs_permissions);
case NOT_FOUND:
return getString(R.string.error_media_not_found);
case AUTHORIZATION_REQUIRED:
return getString(R.string.error_media_unauthorized);
case PARSE_ERROR:
return getString(R.string.error_media_parse_error);
case REQUEST_TOO_LARGE:
return getString(R.string.error_media_request_too_large);
}
// In case of a generic or uncaught error, return the message from the API response or the error type
return TextUtils.isEmpty(error.message) ? error.type.toString() : error.message;
}
private @NonNull String getErrorMessage(PostModel post, String specificMessage) {
String postType = getString(post.isPage() ? R.string.page : R.string.post).toLowerCase();
return String.format(mContext.getResources().getText(R.string.error_upload_params).toString(), postType,
specificMessage);
}
@SuppressWarnings("unused")
@Subscribe(threadMode = ThreadMode.MAIN)
public void onPostUploaded(OnPostUploaded event) {
SiteModel site = mSiteStore.getSiteByLocalId(event.post.getLocalSiteId());
if (event.isError()) {
AppLog.e(T.POSTS, "Post upload failed. " + event.error.type + ": " + event.error.message);
String message = getErrorMessage(event.post, getErrorMessageFromPostError(event.post, event.error));
mPostUploadNotifier.updateNotificationError(event.post, site, message, false);
mFirstPublishPosts.remove(event.post.getId());
} else {
mPostUploadNotifier.cancelNotification(event.post);
boolean isFirstTimePublish = mFirstPublishPosts.remove(event.post.getId());
mPostUploadNotifier.updateNotificationSuccess(event.post, site, isFirstTimePublish);
if (isFirstTimePublish) {
if (mCurrentUploadingPostAnalyticsProperties != null){
mCurrentUploadingPostAnalyticsProperties.put("post_id", event.post.getRemotePostId());
}
AnalyticsUtils.trackWithSiteDetails(Stat.EDITOR_PUBLISHED_POST,
mSiteStore.getSiteByLocalId(event.post.getLocalSiteId()),
mCurrentUploadingPostAnalyticsProperties);
}
}
finishUpload();
}
@SuppressWarnings("unused")
@Subscribe(threadMode = ThreadMode.MAIN)
public void onMediaUploaded(OnMediaUploaded event) {
// Event for unknown media, ignoring
if (event.media == null || mCurrentUploadingPost == null || mMediaLatchMap.get(event.media.getId()) == null) {
AppLog.w(T.MEDIA, "Media event not recognized: " + event.media);
return;
}
if (event.isError()) {
AppLog.e(T.MEDIA, "Media upload failed. " + event.error.type + ": " + event.error.message);
SiteModel site = mSiteStore.getSiteByLocalId(mCurrentUploadingPost.getLocalSiteId());
String message = getErrorMessage(mCurrentUploadingPost, getErrorMessageFromMediaError(event.error));
mPostUploadNotifier.updateNotificationError(mCurrentUploadingPost, site, message, true);
mFirstPublishPosts.remove(mCurrentUploadingPost.getId());
finishUpload();
return;
}
if (event.canceled) {
// Not implemented
return;
}
if (event.completed) {
AppLog.i(T.MEDIA, "Media upload completed for post. Media id: " + event.media.getId()
+ ", post id: " + mCurrentUploadingPost.getId());
mMediaLatchMap.get(event.media.getId()).countDown();
mMediaLatchMap.remove(event.media.getId());
} else {
// Progress update
mPostUploadNotifier.updateNotificationProgress(mCurrentUploadingPost, event.progress);
}
}
}