From 36dfcce32be72c0caa1cfcad3992ea4b35e11c12 Mon Sep 17 00:00:00 2001 From: Ravi Date: Fri, 8 Dec 2023 16:00:16 +1100 Subject: [PATCH 1/3] Create UploadPostWorker.kt Decompose UploadService to use WorkManager in place of Service --- .../android/ui/uploads/UploadPostWorker.kt | 870 ++++++++++++++++++ 1 file changed, 870 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/uploads/UploadPostWorker.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/uploads/UploadPostWorker.kt b/WordPress/src/main/java/org/wordpress/android/ui/uploads/UploadPostWorker.kt new file mode 100644 index 000000000000..729acfda21e3 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/uploads/UploadPostWorker.kt @@ -0,0 +1,870 @@ +package org.wordpress.android.ui.uploads + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.WorkManager +import androidx.work.WorkRequest +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import org.greenrobot.eventbus.EventBus +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 +import org.wordpress.android.editor.AztecEditorFragment +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.generated.UploadActionBuilder +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged +import org.wordpress.android.fluxc.model.MediaModel +import org.wordpress.android.fluxc.model.PostImmutableModel +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.PostUploadModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.persistence.UploadSqlUtils +import org.wordpress.android.fluxc.store.MediaStore +import org.wordpress.android.fluxc.store.PostStore +import org.wordpress.android.fluxc.store.PostStore.OnPostChanged +import org.wordpress.android.fluxc.store.PostStore.OnPostUploaded +import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.fluxc.store.UploadStore +import org.wordpress.android.ui.media.services.MediaUploadReadyListener +import org.wordpress.android.ui.notifications.SystemNotificationsTracker +import org.wordpress.android.ui.posts.PostUtils +import org.wordpress.android.ui.posts.PostUtilsWrapper +import org.wordpress.android.ui.prefs.AppPrefs +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.POSTS +import org.wordpress.android.util.DateTimeUtils +import org.wordpress.android.util.FluxCUtils +import org.wordpress.android.util.NetworkUtils +import org.wordpress.android.util.StringUtils +import org.wordpress.android.util.ToastUtils +import org.wordpress.android.util.UploadWorker +import org.wordpress.android.util.WPMediaUtils + +class UploadPostWorker( + val appContext: Context, + workerParameters: WorkerParameters, + private val siteStore: SiteStore, + private val postStore: PostStore, + private val uploadStore: UploadStore, + private val mediaStore: MediaStore, + private val dispatcher: Dispatcher, + private val systemNotificationsTracker: SystemNotificationsTracker, + private val postUtilsWrapper: PostUtilsWrapper +) : CoroutineWorker(appContext, workerParameters) { + private lateinit var mediaUploadHandler: MediaUploadHandler + private lateinit var postUploadHandler: PostUploadHandler + private lateinit var postUploadNotifier: PostUploadNotifier + private lateinit var uploadService: UploadService // temporarily replace UploadService in PostUploadNotifier + + init { + instance = this + dispatcher.register(this) + } + + override suspend fun doWork(): Result { + return try { + unpackPostIntent() + + Result.success() + } catch (e: Exception) { + AppLog.e(POSTS, "Post upload failed: ", e) + Result.failure() + } + } + + class Factory( + private val siteStore: SiteStore, + private val postStore: PostStore, + private val uploadStore: UploadStore, + private val mediaStore: MediaStore, + private val dispatcher: Dispatcher, + private val systemNotificationsTracker: SystemNotificationsTracker, + private val postUtilsWrapper: PostUtilsWrapper + ) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + return if (workerClassName == UploadPostWorker::class.java.name) { + UploadPostWorker( + appContext, + workerParameters, + siteStore, + postStore, + uploadStore, + mediaStore, + dispatcher, + systemNotificationsTracker, + postUtilsWrapper + ) + } else { + null + } + } + } + + /** + * Has lower priority than the PostUploadHandler, which ensures that the handler has already received and + * processed this OnPostUploaded event. This means we can safely rely on its internal state being up to date. + */ + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN, priority = 7) + fun onPostUploaded(event: OnPostUploaded) { + stopServiceIfUploadsComplete(event.isError, event.post) + } + + /** + * Has lower priority than the PostUploadHandler, which ensures that the handler has already received and + * processed this OnPostChanged event. This means we can safely rely on its internal state being up to date. + */ + @Subscribe(threadMode = ThreadMode.MAIN, priority = 7) + fun onPostChanged(event: OnPostChanged) { + if (event.causeOfChange is CauseOfOnPostChanged.RemoteAutoSavePost) { + val post = + postStore.getPostByLocalPostId((event.causeOfChange as CauseOfOnPostChanged.RemoteAutoSavePost).localPostId) + stopServiceIfUploadsComplete(event.isError, post) + } + } + + @Synchronized + private fun stopServiceIfUploadsComplete(isError: Boolean?, post: PostModel?) { + if (postUploadHandler.hasInProgressUploads()) { + return + } + if (mediaUploadHandler.hasInProgressUploads()) { + return + } else { + verifyMediaOnlyUploadsAndNotify() + } + if (doFinalProcessingOfPosts(isError, post)) { + // when more Posts have been re-enqueued, don't stop the service just yet. + return + } + if (uploadStore.getPendingPosts().isNotEmpty()) { + return + } + AppLog.i(AppLog.T.MAIN, "UploadService > Completed") +// stopSelf() + } + + private fun unpackPostIntent() { + mediaUploadHandler = MediaUploadHandler() + // replace UploadService in PostUploadNotifier + uploadService = UploadService() + postUploadNotifier = PostUploadNotifier(applicationContext, uploadService, systemNotificationsTracker) + postUploadHandler = PostUploadHandler(postUploadNotifier) + + val post = postStore.getPostByLocalPostId(inputData.getInt(KEY_LOCAL_POST_ID, 0)) + if (post != null) { + val shouldTrackAnalytics = inputData.getBoolean(KEY_SHOULD_TRACK_ANALYTICS, false) + if (shouldTrackAnalytics) { + postUploadHandler.registerPostForAnalyticsTracking(post.id) + } + + // cancel any outstanding "end" notification for this Post before we start processing it again + // i.e. dismiss success or error notification for the post. + PostUploadNotifier.cancelFinalNotification(appContext, post) + + // if the user tapped on the PUBLISH quick action, make this Post publishable and track + // analytics before starting the upload process. + if (inputData.getBoolean(KEY_CHANGE_STATUS_TO_PUBLISH, false)) { + val site = siteStore.getSiteByLocalId(post.localSiteId) + makePostPublishable(post, site) + PostUtils.trackSavePostAnalytics(post, site) + } + if (inputData.getBoolean(KEY_SHOULD_RETRY, false)) { + if (AppPrefs.isAztecEditorEnabled() || AppPrefs.isGutenbergEditorEnabled()) { + if (!NetworkUtils.isNetworkAvailable(appContext)) { + rebuildNotificationError(post, appContext.getString(R.string.no_network_message)) + return + } + val postHasGutenbergBlocks = + PostUtils.contentContainsGutenbergBlocks(post.content) + retryUpload(post, !postHasGutenbergBlocks) + } else { + ToastUtils.showToast(appContext, R.string.retry_needs_aztec) + } + return + } + + // is this a new post? only add count to the notification when the post is totally new + // i.e. it still doesn't have any tracked state in the UploadStore + // or it's a failed one the user is actively retrying. + if (isThisPostTotallyNewOrFailed(post) && !PostUploadHandler.isPostUploadingOrQueued(post)) { + postUploadNotifier.addPostInfoToForegroundNotification(post, null) + } + if (getAllFailedMediaForPost(post).isNotEmpty()) { + val postHasGutenbergBlocks = + PostUtils.contentContainsGutenbergBlocks(post.content) + retryUpload(post, !postHasGutenbergBlocks) + } else if (UploadService.hasPendingOrInProgressMediaUploadsForPost(post)) { + // Register the post (as PENDING) in the UploadStore, along with all media currently in progress for it + // If the post is already registered, the new media will be added to its list + val activeMedia = MediaUploadHandler.getPendingOrInProgressMediaUploadsForPost(post) + uploadStore.registerPostModel(post, activeMedia) + } else { + postUploadHandler.upload(post) + } + } + } + + /** + * Do not use this method unless the user explicitly confirmed changes - eg. clicked on publish button or + * similar. + */ + private fun makePostPublishable(post: PostModel, site: SiteModel?) { + PostUtils.preparePostForPublish(post, site) + dispatcher.dispatch(PostActionBuilder.newUpdatePostAction(post)) + } + + private fun isThisPostTotallyNewOrFailed(post: PostImmutableModel): Boolean { + // if we have any tracks for this Post's UploadState, this means this Post is not new. + // Conditions under which the UploadStore would contain traces of this Post's UploadState are: + // - it's been cancelled by entering/exiting/entering the editor thus cancelling the queued post upload + // to allow for the user to keep editing it before sending to the server + // - it's a failed upload (due to some network issue, for example) + // - it's a pending upload (it is currently registered for upload once the associated media finishes + // uploading). + return !uploadStore.isRegisteredPostModel(post) || uploadStore.isFailedPost(post) || uploadStore + .isPendingPost(post) + } + + private fun rebuildNotificationError(post: PostModel, errorMessage: String) { + val failedMedia = uploadStore.getFailedMediaForPost(post) + postUploadNotifier.setTotalMediaItems(post, failedMedia.size) + val site = siteStore.getSiteByLocalId(post.localSiteId) + if (site != null) { + postUploadNotifier.updateNotificationErrorForPost(post, site, errorMessage, 0) + } else { + AppLog.e(AppLog.T.POSTS, "Trying to rebuild notification error without a site") + } + } + + private fun retryUpload(post: PostModel, processWithAztec: Boolean) { + if (uploadStore.isPendingPost(post)) { + // The post is already pending upload so there is no need to manually retry it. Actually, the retry might + // result in the post being uploaded without its media. As if the media upload is in progress, the + // `getAllFailedMediaForPost()` methods returns an empty set. If we invoke `mPostUploadHandler.upload()` + // the post will be uploaded ignoring its media (we could upload content with paths to local storage). + return + } + AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_UPLOAD_POST_ERROR_RETRY) + if (processWithAztec) { + aztecRegisterFailedMediaForThisPost(post) + } + val mediaToRetry = getAllFailedMediaForPost(post) + if (mediaToRetry.isNotEmpty()) { + // reset these media items to QUEUED + for (media in mediaToRetry) { + media.setUploadState(MediaModel.MediaUploadState.QUEUED) + dispatcher.dispatch(MediaActionBuilder.newUpdateMediaAction(media)) + } + if (processWithAztec) { + val changesConfirmed = + post.contentHashcode() == post.changesConfirmedContentHashcode + + // do the same within the Post content itself + val postContentWithRestartedUploads = + AztecEditorFragment.restartFailedMediaToUploading(appContext, post.content) + post.setContent(postContentWithRestartedUploads) + if (changesConfirmed) { + /* + * We are updating media upload status, but we don't make any undesired changes to the post. We + * need to make sure to retain the confirmation state. + */ + post.setChangesConfirmedContentHashcode(post.contentHashcode()) + } + dispatcher.dispatch(PostActionBuilder.newUpdatePostAction(post)) + } + + // retry uploading media items + for (media in mediaToRetry) { + mediaUploadHandler.upload(media) + } + + // Register the post (as PENDING) in the UploadStore, along with all media currently in progress for it + // If the post is already registered, the new media will be added to its list + uploadStore.registerPostModel(post, mediaToRetry) + postUploadNotifier.addPostInfoToForegroundNotification(post, mediaToRetry) + + // send event so Editors can handle clearing Failed statuses properly if Post is being edited right now + EventBus.getDefault().post(UploadService.UploadMediaRetryEvent(mediaToRetry)) + } else { + postUploadNotifier.addPostInfoToForegroundNotification(post, null) + // retry uploading the Post + postUploadHandler.upload(post) + } + } + + private fun aztecRegisterFailedMediaForThisPost(post: PostModel) { + // there could be failed media in the post, that has not been registered in the UploadStore because + // the media was being uploaded separately (i.e. the user included media, started uploading within + // the editor, and such media failed _before_ exiting the eidtor, thus the registration never happened. + // We're recovering the information here so we make sure to rebuild the status only when the user taps + // on Retry. + val mediaIds = + AztecEditorFragment.getMediaMarkedFailedInPostContent(appContext, post.content) + if (mediaIds != null && !mediaIds.isEmpty()) { + val mediaList = ArrayList() + for (mediaId in mediaIds) { + val media = mediaStore.getMediaWithLocalId(StringUtils.stringToInt(mediaId)) + if (media != null) { + mediaList.add(media) + // if this media item didn't have the Postid set, let's set it as we found it + // in the Post body anyway. So let's fix that now. + if (media.localPostId == 0) { + media.localPostId = post.id + dispatcher.dispatch(MediaActionBuilder.newUpdateMediaAction(media)) + } + } + } + if (mediaList.isNotEmpty()) { + // given we found failed media within this Post, let's also cancel the media error + siteStore.getSiteByLocalId(post.localSiteId)?.let { + PostUploadNotifier.cancelFinalNotificationForMedia( + appContext, + it + ) + } + + // now we have a list. Let' register this list. + uploadStore.registerPostModel(post, mediaList) + } + } + } + + private fun getAllFailedMediaForPost(postModel: PostModel): List { + val failedMedia = uploadStore.getFailedMediaForPost(postModel) + return filterOutRecentlyDeletedMedia(failedMedia) + } + + private fun filterOutRecentlyDeletedMedia(failedMedia: Set): List { + val mediaToRetry: MutableList = ArrayList() + for (mediaModel in failedMedia) { + val mediaIdToCompare = mediaModel.id.toString() + if (!userDeletedMediaItemIds.contains(mediaIdToCompare)) { + mediaToRetry.add(mediaModel) + } + } + return mediaToRetry + } + + private fun verifyMediaOnlyUploadsAndNotify() { + // check if all are successful uploads, then notify the user about it + if (mediaBatchUploaded.isNotEmpty()) { + val standAloneMediaItems = ArrayList() + for (media in mediaBatchUploaded) { + // we need to obtain the latest copy from the Store, as it's got the remote mediaId field + val currentMedia = mediaStore.getMediaWithLocalId(media.id) + if (currentMedia != null && currentMedia.localPostId == 0 && (MediaModel.MediaUploadState.fromString( + currentMedia.uploadState + ) + == MediaModel.MediaUploadState.UPLOADED) + ) { + standAloneMediaItems.add(currentMedia) + } + } + if (standAloneMediaItems.isNotEmpty()) { + val site = siteStore.getSiteByLocalId(standAloneMediaItems[0].localSiteId) + postUploadNotifier.updateNotificationSuccessForMedia( + standAloneMediaItems, + site!! + ) + mediaBatchUploaded.clear() + } + } + } + + /* + * This method will make sure to keep the bodies of all Posts registered (*) in the UploadStore + * up-to-date with their corresponding media item upload statuses (i.e. marking them failed or + * successfully uploaded in the actual Post content to reflect what the UploadStore says). + * + * Finally, it will either cancel the Post upload from the queue and create an error notification + * for the user if there are any failed media items for such a Post, or upload the Post if it's + * in good shape. + * + * This method returns: + * - `false` if all registered posts have no in-progress items, and at least one or more retriable + * (failed) items are found in them (this, in other words, means all registered posts are found + * in a `finalized` state other than "UPLOADED"). + * - `true` if at least one registered Post is found that is in good conditions to be uploaded. + * + * + * (*)`Registered` posts are posts that had media in them and are waiting to be uploaded once + * their corresponding associated media is uploaded first. + */ + private fun doFinalProcessingOfPosts(isError: Boolean?, post: PostModel?): Boolean { + // If this was the last media upload a post was waiting for, update the post content + // This done for pending as well as cancelled and failed posts + for (postModel in uploadStore.getAllRegisteredPosts()) { + if (postUtilsWrapper.isPostCurrentlyBeingEdited(postModel)) { + // Don't upload a Post that is being currently open in the Editor. + // This fixes the issue on self-hosted sites when you have a queued post which couldn't be + // remote autosaved. When you try to leave the editor without saving it will get stuck in queued + // upload state. In case of the post still being edited we cancel any ongoing upload post action. + dispatcher.dispatch(UploadActionBuilder.newCancelPostAction(post)) + continue + } + if (!UploadService.hasPendingOrInProgressMediaUploadsForPost(postModel)) { + // Replace local with remote media in the post content + val updatedPost = updateOnePostModelWithCompletedAndFailedUploads(postModel) + if (updatedPost != null) { + // here let's check if there are any failed media + val failedMedia = uploadStore.getFailedMediaForPost(postModel) + if (failedMedia.isNotEmpty()) { + // this Post has failed media, don't upload it just yet, + // but tell the user about the error + UploadService.cancelQueuedPostUpload(postModel) + + // update error notification for Post, unless the media is in the user-deleted media set + if (!isAllFailedMediaUserDeleted(failedMedia)) { + val site = siteStore.getSiteByLocalId(postModel.localSiteId) + val message = UploadUtils + .getErrorMessage( + appContext, + postModel.isPage, + appContext.getString(R.string.error_generic_error), + true + ) + if (site != null) { + postUploadNotifier.updateNotificationErrorForPost( + postModel, + site, + message, + 0 + ) + } else { + AppLog.e( + AppLog.T.POSTS, + "Error notification cannot be updated without a post" + ) + } + } + postUploadHandler.unregisterPostForAnalyticsTracking(postModel.id) + EventBus.getDefault().post( + PostEvents.PostUploadCanceled(postModel) + ) + } else { + // Do not re-enqueue a post that has already failed + if (isError != null && isError && uploadStore.isFailedPost(updatedPost)) { + continue + } + // TODO Should do some extra validation here + // e.g. what if the post has local media URLs but no pending media uploads? + postUploadHandler.upload(updatedPost) + return true + } + } + } + } + return false + } + + private fun isAllFailedMediaUserDeleted(failedMediaSet: Set?): Boolean { + if (failedMediaSet != null && failedMediaSet.size == userDeletedMediaItemIds.size) { + var numberOfMatches = 0 + for (media in failedMediaSet) { + val mediaIdToCompare = media.id.toString() + if (userDeletedMediaItemIds.contains(mediaIdToCompare)) { + numberOfMatches++ + } + } + if (numberOfMatches == userDeletedMediaItemIds.size) { + return true + } + } + return false + } + + private fun updateOnePostModelWithCompletedAndFailedUploads(postModel: PostModel): PostModel? { + var updatedPost = UploadService.updatePostWithCurrentlyCompletedUploads(postModel) + // also do the same now with failed uploads + updatedPost = updatePostWithCurrentlyFailedUploads(updatedPost) + // finally, save the PostModel + if (updatedPost != null) { + dispatcher.dispatch(PostActionBuilder.newUpdatePostAction(updatedPost)) + } + return updatedPost + } + + private fun updatePostWithCurrentlyFailedUploads(postModel: PostModel?): PostModel? { + var post = postModel + if (post != null) { + // now get the list of failed media for this post, so we can make post content + // updates in one go and save only once + val processor: MediaUploadReadyListener = MediaUploadReadyProcessor() + val failedMedia = uploadStore.getFailedMediaForPost(post) + for (media in failedMedia) { + post = updatePostWithFailedMedia(post, media, processor) + } + // Unlike completed media, we won't remove the failed media references, so we can look up their errors later + } + return post + } + + @Synchronized + private fun updatePostWithFailedMedia( + post: PostModel?, media: MediaModel?, + processor: MediaUploadReadyListener? + ): PostModel? { + if (media != null && post != null && processor != null) { + val changesConfirmed = + post.contentHashcode() == post.changesConfirmedContentHashcode + // actually mark the media failed within the Post + processor.markMediaUploadFailedInPost( + post, media.id.toString(), + FluxCUtils.mediaFileFromMediaModel(media) + ) + + // we changed the post, so let’s mark this down + if (!post.isLocalDraft) { + post.setIsLocallyChanged(true) + } + post.setDateLocallyChanged(DateTimeUtils.iso8601UTCFromTimestamp(System.currentTimeMillis() / 1000)) + if (changesConfirmed) { + /* + * We are updating media upload status, but we don't make any undesired changes to the post. We need to + * make sure to retain the confirmation state. + */ + post.setChangesConfirmedContentHashcode(post.contentHashcode()) + } + } + return post + } + + companion object { + private const val KEY_CHANGE_STATUS_TO_PUBLISH = "shouldPublish" + private const val KEY_SHOULD_RETRY = "shouldRetry" + private const val KEY_LOCAL_POST_ID = "localPostId" + private const val KEY_SHOULD_TRACK_ANALYTICS = "shouldTrackPostAnalytics" + + private var instance: UploadPostWorker? = null + + // we keep this list so we don't tell the user an error happened when we find a FAILED media item + // for media that the user actively cancelled uploads for + private val userDeletedMediaItemIds = HashSet() + + // we hold this reference here for the success notification for Media uploads + private val mediaBatchUploaded: MutableList = ArrayList() + + fun enqueueUploadPostWorkRequest(postId: Int, isFirstTimePublish: Boolean): Pair { + val request = OneTimeWorkRequestBuilder() + .setConstraints(getUploadConstraints()) + .setInputData(workDataOf( + KEY_LOCAL_POST_ID to postId, + KEY_SHOULD_TRACK_ANALYTICS to isFirstTimePublish + )) + .build() + val operation = WorkManager.getInstance(WordPress.getContext()).enqueueUniqueWork( + "upload-post-$postId", + ExistingWorkPolicy.KEEP, request + ) + return Pair(request, operation) + } + + fun enqueueRetryUploadPostWorkRequest(postId: Int, isFirstTimePublish: Boolean): Pair { + val request = OneTimeWorkRequestBuilder() + .setConstraints(getUploadConstraints()) + .setInputData(workDataOf( + KEY_LOCAL_POST_ID to postId, + KEY_SHOULD_TRACK_ANALYTICS to isFirstTimePublish, + KEY_SHOULD_RETRY to true + )) + .build() + val operation = WorkManager.getInstance(WordPress.getContext()).enqueueUniqueWork( + "upload-post-$postId", + ExistingWorkPolicy.KEEP, request + ) + return Pair(request, operation) + } + + fun enqueuePublishPostWorkRequest(postId: Int, isFirstTimePublish: Boolean): Pair { + val request = OneTimeWorkRequestBuilder() + .setConstraints(getUploadConstraints()) + .setInputData(workDataOf( + KEY_LOCAL_POST_ID to postId, + KEY_SHOULD_TRACK_ANALYTICS to isFirstTimePublish, + KEY_CHANGE_STATUS_TO_PUBLISH to true + )) + .build() + val operation = WorkManager.getInstance(WordPress.getContext()).enqueueUniqueWork( + "upload-post-$postId", + ExistingWorkPolicy.KEEP, request + ) + return Pair(request, operation) + } + + fun setDeletedMediaItemIds(mediaIds: List) { + userDeletedMediaItemIds.clear() + userDeletedMediaItemIds.addAll( + mediaIds + ) + } + + private fun getUploadConstraints(): Constraints { + return Constraints.Builder() + .setRequiredNetworkType(NetworkType.NOT_ROAMING) + .build() + } + + @JvmStatic + fun cancelFinalNotification(context: Context?, post: PostImmutableModel?) { + // cancel any outstanding "end" notification for this Post before we start processing it again + // i.e. dismiss success or error notification for the post. + PostUploadNotifier.cancelFinalNotification(context, post!!) + } + + @JvmStatic + fun cancelFinalNotificationForMedia(context: Context?, site: SiteModel?) { + PostUploadNotifier.cancelFinalNotificationForMedia(context, site!!) + } + + /** + * Returns true if the passed post is either currently uploading or waiting to be uploaded. + * Except for legacy mode, a post counts as 'uploading' if the post content itself is being uploaded - a post + * waiting for media to finish uploading counts as 'waiting to be uploaded' until the media uploads complete. + */ + @JvmStatic + fun isPostUploadingOrQueued(post: PostImmutableModel): Boolean { + // First check for posts uploading or queued inside the PostUploadManager + return if (PostUploadHandler.isPostUploadingOrQueued(post)) { + true + } else isPendingPost(post) + + // Then check the list of posts waiting for media to complete + } + + private fun isPendingPost(post: PostImmutableModel): Boolean { + val postUploadModel = UploadSqlUtils.getPostUploadModelForLocalId(post.id) + return postUploadModel != null && postUploadModel.uploadState == PostUploadModel.PENDING + } + + fun isPostQueued(post: PostImmutableModel?): Boolean { + // Check for posts queued inside the PostUploadManager + return PostUploadHandler.isPostQueued(post) + } + + /** + * Returns true if the passed post is currently uploading. + * Except for legacy mode, a post counts as 'uploading' if the post content itself is being uploaded - a post + * waiting for media to finish uploading counts as 'waiting to be uploaded' until the media uploads complete. + */ + fun isPostUploading(post: PostImmutableModel?): Boolean { + return PostUploadHandler.isPostUploading(post) + } + + fun cancelQueuedPostUploadAndRelatedMedia(post: PostModel?) { + if (post != null) { + PostUploadNotifier.cancelFinalNotification(instance?.appContext, post) + instance?.postUploadNotifier?.removePostInfoFromForegroundNotification( + post, instance?.mediaStore?.getMediaForPost(post) + ) + cancelQueuedPostUpload(post) + EventBus.getDefault().post(PostEvents.PostMediaCanceled(post)) + } + } + + fun cancelQueuedPostUpload(post: PostModel?) { + if (post != null) { + // Mark the post as CANCELLED in the UploadStore + instance?.dispatcher?.dispatch(UploadActionBuilder.newCancelPostAction(post)) + } + } + + @JvmStatic + fun updatePostWithCurrentlyCompletedUploads(postModel: PostModel?): PostModel? { + var post = postModel + if (post != null) { + // now get the list of completed media for this post, so we can make post content + // updates in one go and save only once + val processor: MediaUploadReadyListener = MediaUploadReadyProcessor() + val completedMedia = instance?.uploadStore?.getCompletedMediaForPost(post) + if (completedMedia != null) { + for (media in completedMedia) { + post = if (media.markedLocallyAsFeatured) { + updatePostWithNewFeaturedImg(post, media.mediaId) + } else { + updatePostWithMediaUrl(post, media, processor) + } + } + } + if (completedMedia != null) { + if (completedMedia.isNotEmpty()) { + // finally remove all completed uploads for this post, as they've been taken care of + val clearMediaPayload = UploadStore.ClearMediaPayload(post, completedMedia) + instance?.dispatcher?.dispatch( + UploadActionBuilder.newClearMediaForPostAction( + clearMediaPayload + ) + ) + } + } + } + return post + } + + @JvmStatic + fun hasInProgressMediaUploadsForPost(postModel: PostImmutableModel?): Boolean { + return postModel != null && MediaUploadHandler.hasInProgressMediaUploadsForPost( + postModel.id + ) + } + + fun hasPendingMediaUploadsForPost(postModel: PostImmutableModel?): Boolean { + return postModel != null && MediaUploadHandler.hasPendingMediaUploadsForPost(postModel.id) + } + + @JvmStatic + fun hasPendingOrInProgressMediaUploadsForPost(postModel: PostImmutableModel?): Boolean { + return postModel != null && MediaUploadHandler.hasPendingOrInProgressMediaUploadsForPost( + postModel.id + ) + } + + fun getPendingOrInProgressFeaturedImageUploadForPost(postModel: PostImmutableModel?): MediaModel? { + return MediaUploadHandler.getPendingOrInProgressFeaturedImageUploadForPost(postModel) + } + + @JvmStatic + fun getPendingOrInProgressMediaUploadsForPost(post: PostImmutableModel?): List { + return MediaUploadHandler.getPendingOrInProgressMediaUploadsForPost(post) + } + + fun getMediaUploadProgressForPost(postModel: PostModel?): Float { + if (postModel == null) { + // If the UploadService isn't running, there's no progress for this post + return 0F + } + val pendingMediaList = instance?.uploadStore?.getUploadingMediaForPost(postModel) + if (pendingMediaList?.size == 0) { + return 1F + } + var overallProgress = 0f + for (pendingMedia in pendingMediaList!!) { + overallProgress += UploadService.getUploadProgressForMedia(pendingMedia) + } + overallProgress /= pendingMediaList.size.toFloat() + return overallProgress + } + + @JvmStatic + fun getUploadProgressForMedia(mediaModel: MediaModel?): Float { + if (mediaModel == null) { + // If the UploadService isn't running, there's no progress for this media + return 0F + } + val uploadProgress = instance?.uploadStore?.getUploadProgressForMedia(mediaModel) + + // If this is a video and video optimization is enabled, include the optimization progress in the outcome + return if (mediaModel.isVideo && WPMediaUtils.isVideoOptimizationEnabled()) { + MediaUploadHandler.getOverallProgressForVideo(mediaModel.id, uploadProgress!!) + } else uploadProgress!! + } + + fun getPendingMediaForPost(postModel: PostModel?): Set { + return if (postModel == null) { + emptySet() + } else instance?.uploadStore!!.getUploadingMediaForPost(postModel) + } + + @JvmStatic + fun isPendingOrInProgressMediaUpload(media: MediaModel): Boolean { + return MediaUploadHandler.isPendingOrInProgressMediaUpload(media.id) + } + + /** + * Rechecks all media in the MediaStore marked UPLOADING/QUEUED against the UploadingService to see + * if it's actually uploading or queued and change it accordingly, to recover from an inconsistent state + */ + fun sanitizeMediaUploadStateForSite( + mediaStore: MediaStore, dispatcher: Dispatcher, + site: SiteModel + ) { + val uploadingMedia = mediaStore.getSiteMediaWithState(site, MediaModel.MediaUploadState.UPLOADING) + val queuedMedia = mediaStore.getSiteMediaWithState(site, MediaModel.MediaUploadState.QUEUED) + if (uploadingMedia.isEmpty() && queuedMedia.isEmpty()) { + return + } + val uploadingOrQueuedMedia: MutableList = ArrayList() + uploadingOrQueuedMedia.addAll(uploadingMedia) + uploadingOrQueuedMedia.addAll(queuedMedia) + for (media in uploadingOrQueuedMedia) { + if (!isPendingOrInProgressMediaUpload(media)) { + // it is NOT being uploaded or queued in the actual UploadService, mark it failed + media.setUploadState(MediaModel.MediaUploadState.FAILED) + dispatcher.dispatch(MediaActionBuilder.newUpdateMediaAction(media)) + } + } + } + + @Synchronized + private fun updatePostWithNewFeaturedImg( + post: PostModel?, + remoteMediaId: Long? + ): PostModel? { + if (post != null && remoteMediaId != null) { + val changesConfirmed = + post.contentHashcode() == post.changesConfirmedContentHashcode + post.setFeaturedImageId(remoteMediaId) + post.setIsLocallyChanged(true) + post.setDateLocallyChanged(DateTimeUtils.iso8601UTCFromTimestamp(System.currentTimeMillis() / 1000)) + if (changesConfirmed) { + /* + * We are replacing local featured image with a remote version. We need to make sure + * to retain the confirmation state. + */ + post.setChangesConfirmedContentHashcode(post.contentHashcode()) + } + } + return post + } + + @Synchronized + private fun updatePostWithMediaUrl( + post: PostModel?, media: MediaModel?, + processor: MediaUploadReadyListener? + ): PostModel? { + if (media != null && post != null && processor != null) { + val changesConfirmed = + post.contentHashcode() == post.changesConfirmedContentHashcode + + // obtain site url used to generate attachment page url + val site = instance?.siteStore?.getSiteByLocalId(media.localSiteId) + + // actually replace the media ID with the media uri + processor.replaceMediaFileWithUrlInPost( + post, media.id.toString(), + FluxCUtils.mediaFileFromMediaModel(media), site + ) + + // we changed the post, so let’s mark this down + if (!post.isLocalDraft) { + post.setIsLocallyChanged(true) + } + post.setDateLocallyChanged(DateTimeUtils.iso8601UTCFromTimestamp(System.currentTimeMillis() / 1000)) + if (changesConfirmed) { + /* + * We are replacing image local path with a url. We need to make sure to retain the confirmation + * state. + */ + post.setChangesConfirmedContentHashcode(post.contentHashcode()) + } + } + return post + } + } +} From 5352cbbdcbe9becf310d5467940dc7917230514a Mon Sep 17 00:00:00 2001 From: Ravi Date: Fri, 8 Dec 2023 16:00:26 +1100 Subject: [PATCH 2/3] Create UploadMediaWorker.kt Decompose UploadService to use WorkManager in place of Service --- .../android/ui/uploads/UploadMediaWorker.kt | 591 ++++++++++++++++++ 1 file changed, 591 insertions(+) create mode 100644 WordPress/src/main/java/org/wordpress/android/ui/uploads/UploadMediaWorker.kt diff --git a/WordPress/src/main/java/org/wordpress/android/ui/uploads/UploadMediaWorker.kt b/WordPress/src/main/java/org/wordpress/android/ui/uploads/UploadMediaWorker.kt new file mode 100644 index 000000000000..6c049410a772 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/uploads/UploadMediaWorker.kt @@ -0,0 +1,591 @@ +package org.wordpress.android.ui.uploads + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ListenableWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.Operation +import androidx.work.WorkManager +import androidx.work.WorkRequest +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import org.greenrobot.eventbus.EventBus +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 +import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.PostActionBuilder +import org.wordpress.android.fluxc.generated.UploadActionBuilder +import org.wordpress.android.fluxc.model.MediaModel +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.MediaStore +import org.wordpress.android.fluxc.store.MediaStore.OnMediaUploaded +import org.wordpress.android.fluxc.store.PostStore +import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.fluxc.store.UploadStore +import org.wordpress.android.ui.mysite.SelectedSiteRepository +import org.wordpress.android.ui.notifications.SystemNotificationsTracker +import org.wordpress.android.ui.posts.PostUtils +import org.wordpress.android.ui.posts.PostUtilsWrapper +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.UploadWorker + +class UploadMediaWorker( + val appContext: Context, + workerParameters: WorkerParameters, + private val siteStore: SiteStore, + private val postStore: PostStore, + private val uploadStore: UploadStore, + private val mediaStore: MediaStore, + private val selectedSiteRepository: SelectedSiteRepository, + private val dispatcher: Dispatcher, + private val systemNotificationsTracker: SystemNotificationsTracker, + private val postUtilsWrapper: PostUtilsWrapper +) : CoroutineWorker(appContext, workerParameters) { + private lateinit var mediaUploadHandler: MediaUploadHandler + private lateinit var postUploadHandler: PostUploadHandler + private lateinit var postUploadNotifier: PostUploadNotifier + private lateinit var uploadService: UploadService // temporarily replace UploadService in PostUploadNotifier + + override suspend fun doWork(): Result { + dispatcher.register(this) + + return try { + unpackMediaIntent() + + Result.success() + } catch (e: Exception) { + // SecurityException can happen on some devices without Google services (these devices probably strip + // the AndroidManifest.xml and remove unsupported permissions). + AppLog.e(AppLog.T.POSTS, "Post upload failed: ", e) + Result.failure() + } + } + + class Factory( + private val siteStore: SiteStore, + private val postStore: PostStore, + private val uploadStore: UploadStore, + private val mediaStore: MediaStore, + private val selectedSiteRepository: SelectedSiteRepository, + private val dispatcher: Dispatcher, + private val systemNotificationsTracker: SystemNotificationsTracker, + private val postUtilsWrapper: PostUtilsWrapper + ) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + return if (workerClassName == UploadPostWorker::class.java.name) { + UploadMediaWorker( + appContext, + workerParameters, + siteStore, + postStore, + uploadStore, + mediaStore, + selectedSiteRepository, + dispatcher, + systemNotificationsTracker, + postUtilsWrapper + ) + } else { + null + } + } + } + + private fun unpackMediaIntent() { + mediaUploadHandler = MediaUploadHandler() + // replace UploadService in PostUploadNotifier + uploadService = UploadService() + postUploadNotifier = PostUploadNotifier(applicationContext, uploadService, systemNotificationsTracker) + postUploadHandler = PostUploadHandler(postUploadNotifier) + + // add new media + val s:String? = inputData.getString(KEY_MEDIA_LIST) + val mediaModelList = object : TypeToken?>() {}.type + val mediaList: List? = Gson().fromJson(s, mediaModelList) // intent.getSerializableExtra(UploadService.KEY_MEDIA_LIST) as List? + + if (!mediaList.isNullOrEmpty()) { + if (!inputData.getBoolean(KEY_MEDIA_LIST, false)) { + // only cancel the media error notification if we're triggering a new media upload + // either from Media Browser or a RETRY from a notification. + // Otherwise, this flag should be true, and we need to keep the error notification as + // it might be a separate action (user is editing a Post and including media there) + PostUploadNotifier.cancelFinalNotificationForMedia( + appContext, + siteStore.getSiteByLocalId( + mediaList[0].localSiteId + )!! + ) + + // add these media items so we can use them in WRITE POST once they end up loading successfully + mediaBatchUploaded.addAll(mediaList) + } + + // if this media belongs to some post, register such Post + registerPostModelsForMedia(mediaList, inputData.getBoolean(KEY_SHOULD_RETRY, false)) + val toBeUploadedMediaList = ArrayList() + for (media in mediaList) { + val localMedia = mediaStore.getMediaWithLocalId(media.id) + val notUploadedYet = (localMedia != null + && (localMedia.uploadState == null + || MediaModel.MediaUploadState.fromString(localMedia.uploadState) + != MediaModel.MediaUploadState.UPLOADED)) + if (notUploadedYet) { + toBeUploadedMediaList.add(media) + } + } + for (media in toBeUploadedMediaList) { + mediaUploadHandler.upload(media) + } + if (toBeUploadedMediaList.isNotEmpty()) { + postUploadNotifier.addMediaInfoToForegroundNotification(toBeUploadedMediaList) + } + } + } + + private fun registerPostModelsForMedia(mediaList: List?, isRetry: Boolean) { + if (!mediaList.isNullOrEmpty()) { + val postsToRefresh = PostUtils.getPostsThatIncludeAnyOfTheseMedia(postStore, mediaList) + for (post in postsToRefresh) { + // If the post is already registered, the new media will be added to its list + uploadStore.registerPostModel(post, mediaList) + } + if (isRetry) { + // Bump analytics + AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATION_UPLOAD_MEDIA_ERROR_RETRY) + + // send event so Editors can handle clearing Failed statuses properly if Post is being edited right now + EventBus.getDefault().post(UploadMediaRetryEvent(mediaList)) + } + } + } + + class UploadErrorEvent { + val post: PostModel? + @JvmField + val mediaModelList: List? + @JvmField + val errorMessage: String + + constructor(post: PostModel?, errorMessage: String) { + this.post = post + mediaModelList = null + this.errorMessage = errorMessage + } + + constructor(mediaModelList: List?, errorMessage: String) { + post = null + this.mediaModelList = mediaModelList + this.errorMessage = errorMessage + } + } + + class UploadMediaSuccessEvent(@JvmField val mediaModelList: List?, @JvmField val successMessage: String) + class UploadMediaRetryEvent internal constructor(@JvmField val mediaModelList: List?) + + /** + * Has lower priority than the UploadHandlers, which ensures that the handlers have already received and + * processed this OnMediaUploaded event. This means we can safely rely on their internal state being up to date. + */ + @Suppress("unused") + @Subscribe(threadMode = ThreadMode.MAIN, priority = 7) + fun onMediaUploaded(event: OnMediaUploaded) { + if (event.media == null) { + return + } + if (event.isError) { + if (event.media!!.localPostId > 0) { + AppLog.w( + AppLog.T.MAIN, + "UploadService > Media upload failed for post " + event.media!!.localPostId + " : " + + event.error.type + ": " + event.error.message + ) + val errorMessage = + UploadUtils.getErrorMessageFromMediaError(appContext, event.media, event.error) + cancelPostUploadMatchingMedia(event.media!!, errorMessage, true) + } + if (!mediaBelongsToAPost(event.media)) { + // this media item doesn't belong to a Post + postUploadNotifier.incrementUploadedMediaCountFromProgressNotification(event.media!!.id) + // Only show the media upload error notification if the post is NOT registered in the UploadStore + // - otherwise if it IS registered in the UploadStore and we get a `cancelled` signal it means + // the user actively cancelled it. No need to show an error then. + val message = + UploadUtils.getErrorMessageFromMediaError(appContext, event.media, event.error) + + // if media has a local site id, use that. If not, default to currently selected site. + val siteLocalId = + if (event.media!!.localSiteId > 0) event.media!!.localSiteId else selectedSiteRepository.getSelectedSiteLocalId( + true + ) + val selectedSite = siteStore.getSiteByLocalId(siteLocalId) + val failedStandAloneMedia = getRetriableStandaloneMedia(selectedSite) + if (failedStandAloneMedia.isEmpty()) { + // if we couldn't get the failed media from the MediaStore, at least we know + // for sure we're handling the event for this specific media item, so throw an error + // notification for this particular media item travelling in event.media + failedStandAloneMedia.add(event.media) + } + postUploadNotifier.updateNotificationErrorForMedia( + failedStandAloneMedia, + selectedSite!!, message + ) + } + stopServiceIfUploadsComplete() + return + } + if (event.canceled) { + // remove this media item from the progress notification + postUploadNotifier.removeOneMediaItemInfoFromForegroundNotification() + if (event.media!!.localPostId > 0) { + AppLog.i( + AppLog.T.MAIN, + "UploadService > Upload cancelled for post with id " + event.media!!.localPostId + + " - a media upload for this post has been cancelled, id: " + event.media!!.id + ) + cancelPostUploadMatchingMedia( + event.media!!, + appContext.getString(R.string.error_media_canceled), + false + ) + } + stopServiceIfUploadsComplete() + return + } + if (event.completed) { + if (event.media!!.localPostId != 0) { + AppLog.i( + AppLog.T.MAIN, + "UploadService > Processing completed media with id " + event.media!!.id + + " and local post id " + event.media!!.localPostId + ) + } + postUploadNotifier.incrementUploadedMediaCountFromProgressNotification(event.media!!.id) + stopServiceIfUploadsComplete() + } else { + // in-progress upload + // Progress update + postUploadNotifier.updateNotificationProgressForMedia(event.media, event.progress) + } + } + + private fun mediaBelongsToAPost(media: MediaModel?): Boolean { + val postToCancel = postStore.getPostByLocalPostId( + media!!.localPostId + ) + return postToCancel != null && uploadStore.isRegisteredPostModel(postToCancel) + } + + /* + returns true if Post canceled + returns false if Post can't be found or is not registered in the UploadStore + */ + private fun cancelPostUploadMatchingMedia( + media: MediaModel, + errorMessage: String, + showError: Boolean + ): Boolean { + val postToCancel = postStore.getPostByLocalPostId(media.localPostId) ?: return false + if (!uploadStore.isRegisteredPostModel(postToCancel)) { + return false + } + if (PostUploadHandler.isPostUploadingOrQueued(postToCancel) && !PostUtils + .isPostCurrentlyBeingEdited(postToCancel) + ) { + // post is not being edited and is currently queued, update the count on the foreground notification + postUploadNotifier.incrementUploadedPostCountFromForegroundNotification(postToCancel) + } + if (showError || uploadStore.isFailedPost(postToCancel)) { + // Only show the media upload error notification if the post is NOT registered in the UploadStore + // - otherwise if it IS registered in the UploadStore and we get a `cancelled` signal it means + // the user actively cancelled it. No need to show an error then. + val message = + UploadUtils.getErrorMessage(appContext, postToCancel.isPage, errorMessage, true) + val site = siteStore.getSiteByLocalId(postToCancel.localSiteId) + if (site != null) { + postUploadNotifier.updateNotificationErrorForPost( + postToCancel, site, message, + uploadStore.getFailedMediaForPost(postToCancel).size + ) + } else { + AppLog.e(AppLog.T.POSTS, "Trying to update notifications with missing site") + } + } + postUploadHandler.unregisterPostForAnalyticsTracking(postToCancel.id) + EventBus.getDefault().post(PostEvents.PostUploadCanceled(postToCancel)) + return true + } + + @Synchronized + private fun stopServiceIfUploadsComplete() { + stopServiceIfUploadsComplete(null, null) + } + + @Synchronized + private fun stopServiceIfUploadsComplete(isError: Boolean?, post: PostModel?) { + if (postUploadHandler.hasInProgressUploads()) { + return + } + if (mediaUploadHandler.hasInProgressUploads()) { + return + } else { + verifyMediaOnlyUploadsAndNotify() + } + if (doFinalProcessingOfPosts(isError, post)) { + // when more Posts have been re-enqueued, don't stop the service just yet. + return + } + if (uploadStore.getPendingPosts().isNotEmpty()) { + return + } + AppLog.i(AppLog.T.MAIN, "UploadService > Completed") +// stopSelf() + } + + private fun verifyMediaOnlyUploadsAndNotify() { + // check if all are successful uploads, then notify the user about it + if (mediaBatchUploaded.isNotEmpty()) { + val standAloneMediaItems = ArrayList() + for (media in mediaBatchUploaded) { + // we need to obtain the latest copy from the Store, as it's got the remote mediaId field + val currentMedia = mediaStore.getMediaWithLocalId(media.id) + if (currentMedia != null && currentMedia.localPostId == 0 && (MediaModel.MediaUploadState.fromString( + currentMedia.uploadState + ) + == MediaModel.MediaUploadState.UPLOADED) + ) { + standAloneMediaItems.add(currentMedia) + } + } + if (standAloneMediaItems.isNotEmpty()) { + val site = siteStore.getSiteByLocalId(standAloneMediaItems[0].localSiteId) + postUploadNotifier.updateNotificationSuccessForMedia( + standAloneMediaItems, + site!! + ) + mediaBatchUploaded.clear() + } + } + } + + private fun getRetriableStandaloneMedia(selectedSite: SiteModel?): MutableList { + // get all retriable media ? To retry or not to retry, that is the question + val failedStandAloneMedia: MutableList = ArrayList() + if (selectedSite != null) { + val failedMedia = mediaStore.getSiteMediaWithState( + selectedSite, MediaModel.MediaUploadState.FAILED + ) + + // only take into account those media items that do not belong to any Post + for (media in failedMedia) { + if (media.localPostId == 0) { + failedStandAloneMedia.add(media) + } + } + } + return failedStandAloneMedia + } + + /* + * This method will make sure to keep the bodies of all Posts registered (*) in the UploadStore + * up-to-date with their corresponding media item upload statuses (i.e. marking them failed or + * successfully uploaded in the actual Post content to reflect what the UploadStore says). + * + * Finally, it will either cancel the Post upload from the queue and create an error notification + * for the user if there are any failed media items for such a Post, or upload the Post if it's + * in good shape. + * + * This method returns: + * - `false` if all registered posts have no in-progress items, and at least one or more retriable + * (failed) items are found in them (this, in other words, means all registered posts are found + * in a `finalized` state other than "UPLOADED"). + * - `true` if at least one registered Post is found that is in good conditions to be uploaded. + * + * + * (*)`Registered` posts are posts that had media in them and are waiting to be uploaded once + * their corresponding associated media is uploaded first. + */ + private fun doFinalProcessingOfPosts(isError: Boolean?, post: PostModel?): Boolean { + // If this was the last media upload a post was waiting for, update the post content + // This done for pending as well as cancelled and failed posts + for (postModel in uploadStore.getAllRegisteredPosts()) { + if (postUtilsWrapper.isPostCurrentlyBeingEdited(postModel)) { + // Don't upload a Post that is being currently open in the Editor. + // This fixes the issue on self-hosted sites when you have a queued post which couldn't be + // remote autosaved. When you try to leave the editor without saving it will get stuck in queued + // upload state. In case of the post still being edited we cancel any ongoing upload post action. + dispatcher.dispatch(UploadActionBuilder.newCancelPostAction(post)) + continue + } + if (!UploadService.hasPendingOrInProgressMediaUploadsForPost(postModel)) { + // Replace local with remote media in the post content + val updatedPost = updateOnePostModelWithCompletedAndFailedUploads(postModel) + if (updatedPost != null) { + // here let's check if there are any failed media + val failedMedia = uploadStore.getFailedMediaForPost(postModel) + if (failedMedia.isNotEmpty()) { + // this Post has failed media, don't upload it just yet, + // but tell the user about the error + UploadService.cancelQueuedPostUpload(postModel) + + // update error notification for Post, unless the media is in the user-deleted media set + if (!isAllFailedMediaUserDeleted(failedMedia)) { + val site = siteStore.getSiteByLocalId(postModel.localSiteId) + val message = UploadUtils + .getErrorMessage( + appContext, + postModel.isPage, + appContext.getString(R.string.error_generic_error), + true + ) + if (site != null) { + postUploadNotifier.updateNotificationErrorForPost( + postModel, + site, + message, + 0 + ) + } else { + AppLog.e( + AppLog.T.POSTS, + "Error notification cannot be updated without a post" + ) + } + } + postUploadHandler.unregisterPostForAnalyticsTracking(postModel.id) + EventBus.getDefault().post( + PostEvents.PostUploadCanceled(postModel) + ) + } else { + // Do not re-enqueue a post that has already failed + if (isError != null && isError && uploadStore.isFailedPost(updatedPost)) { + continue + } + // TODO Should do some extra validation here + // e.g. what if the post has local media URLs but no pending media uploads? + postUploadHandler.upload(updatedPost) + return true + } + } + } + } + return false + } + + private fun isAllFailedMediaUserDeleted(failedMediaSet: Set?): Boolean { + if (failedMediaSet != null && failedMediaSet.size == userDeletedMediaItemIds.size) { + var numberOfMatches = 0 + for (media in failedMediaSet) { + val mediaIdToCompare = media.id.toString() + if (userDeletedMediaItemIds.contains(mediaIdToCompare)) { + numberOfMatches++ + } + } + if (numberOfMatches == userDeletedMediaItemIds.size) { + return true + } + } + return false + } + + private fun updateOnePostModelWithCompletedAndFailedUploads(postModel: PostModel): PostModel? { + var updatedPost = UploadService.updatePostWithCurrentlyCompletedUploads(postModel) + // also do the same now with failed uploads + updatedPost = UploadService.updatePostWithCurrentlyFailedUploads(updatedPost) + // finally, save the PostModel + if (updatedPost != null) { + dispatcher.dispatch(PostActionBuilder.newUpdatePostAction(updatedPost)) + } + return updatedPost + } + + + companion object { + private const val KEY_SHOULD_RETRY = "shouldRetry" + private const val KEY_MEDIA_LIST = "mediaList" + private const val KEY_UPLOAD_MEDIA_FROM_EDITOR = "mediaFromEditor" + + // we keep this list so we don't tell the user an error happened when we find a FAILED media item + // for media that the user actively cancelled uploads for + private val userDeletedMediaItemIds = HashSet() + + // we hold this reference here for the success notification for Media uploads + private val mediaBatchUploaded: MutableList = ArrayList() + + fun enqueueUploadMediaWorkRequest(media: MediaModel): Pair { + val mediaList = ArrayList() + mediaList.add(media) + + val mediaModelList = object : TypeToken?>() {}.type + val s: String = Gson().toJson(mediaList, mediaModelList) + + val request = OneTimeWorkRequestBuilder() + .setConstraints(getUploadConstraints()) + .setInputData( + workDataOf(KEY_MEDIA_LIST to s) + ) + .build() + val operation = WorkManager.getInstance(WordPress.getContext()).enqueueUniqueWork( + "upload-media", + ExistingWorkPolicy.KEEP, request + ) + return Pair(request, operation) + } + + fun enqueueUploadMediaListWorkRequest(mediaList: ArrayList): Pair { + val mediaModelList = object : TypeToken?>() {}.type + val s: String = Gson().toJson(mediaList, mediaModelList) + + val request = OneTimeWorkRequestBuilder() + .setConstraints(getUploadConstraints()) + .setInputData( + workDataOf(KEY_MEDIA_LIST to s) + ) + .build() + val operation = WorkManager.getInstance(WordPress.getContext()).enqueueUniqueWork( + "upload-media-list", + ExistingWorkPolicy.KEEP, request + ) + return Pair(request, operation) + } + + fun enqueueUploadMediaListFromEditorWorkRequest(mediaList: ArrayList): Pair { + val mediaModelList = object : TypeToken?>() {}.type + val s: String = Gson().toJson(mediaList, mediaModelList) + + val request = OneTimeWorkRequestBuilder() + .setConstraints(getUploadConstraints()) + .setInputData( + workDataOf( + KEY_MEDIA_LIST to s, + KEY_UPLOAD_MEDIA_FROM_EDITOR to true + ) + ) + .build() + val operation = WorkManager.getInstance(WordPress.getContext()).enqueueUniqueWork( + "upload-media-list-from-editor", + ExistingWorkPolicy.KEEP, request + ) + return Pair(request, operation) + } + + private fun getUploadConstraints(): Constraints { + return Constraints.Builder() + .setRequiredNetworkType(NetworkType.NOT_ROAMING) + .build() + } + } +} From 8e38e4addf712de918ab1fb2087b87ffac15284c Mon Sep 17 00:00:00 2001 From: Ravi Date: Fri, 8 Dec 2023 16:01:06 +1100 Subject: [PATCH 3/3] Update WordPressWorkersFactory.kt For - UploadPostWorker - UploadMediaWorker --- .../workers/WordPressWorkersFactory.kt | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/WordPress/src/main/java/org/wordpress/android/workers/WordPressWorkersFactory.kt b/WordPress/src/main/java/org/wordpress/android/workers/WordPressWorkersFactory.kt index 8c3739490fd3..f6993dcffbf1 100644 --- a/WordPress/src/main/java/org/wordpress/android/workers/WordPressWorkersFactory.kt +++ b/WordPress/src/main/java/org/wordpress/android/workers/WordPressWorkersFactory.kt @@ -1,9 +1,16 @@ package org.wordpress.android.workers import androidx.work.DelegatingWorkerFactory +import org.wordpress.android.fluxc.Dispatcher import org.wordpress.android.fluxc.store.AccountStore +import org.wordpress.android.fluxc.store.MediaStore +import org.wordpress.android.fluxc.store.PostStore import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.fluxc.store.UploadStore import org.wordpress.android.support.ZendeskHelper +import org.wordpress.android.ui.notifications.SystemNotificationsTracker +import org.wordpress.android.ui.posts.PostUtilsWrapper +import org.wordpress.android.ui.uploads.UploadPostWorker import org.wordpress.android.ui.uploads.UploadStarter import org.wordpress.android.util.UploadWorker import org.wordpress.android.workers.notification.local.LocalNotificationHandlerFactory @@ -26,7 +33,13 @@ class WordPressWorkersFactory @Inject constructor( weeklyRoundupNotifier: WeeklyRoundupNotifier, promptReminderNotifier: PromptReminderNotifier, accountStore: AccountStore, - zendeskHelper: ZendeskHelper + zendeskHelper: ZendeskHelper, + postStore: PostStore, + uploadStore: UploadStore, + mediaStore: MediaStore, + dispatcher: Dispatcher, + systemNotificationsTracker: SystemNotificationsTracker, + postUtilsWrapper: PostUtilsWrapper ) : DelegatingWorkerFactory() { init { addFactory(UploadWorker.Factory(uploadStarter, siteStore)) @@ -34,5 +47,14 @@ class WordPressWorkersFactory @Inject constructor( addFactory(ReminderWorker.Factory(reminderScheduler, reminderNotifier, promptReminderNotifier)) addFactory(WeeklyRoundupWorker.Factory(weeklyRoundupNotifier)) addFactory(GCMRegistrationWorker.Factory(accountStore, zendeskHelper)) + addFactory(UploadPostWorker.Factory( + siteStore, + postStore, + uploadStore, + mediaStore, + dispatcher, + systemNotificationsTracker, + postUtilsWrapper + )) } }