diff --git a/WordPress/build.gradle b/WordPress/build.gradle index 1b74daef3347..73548a0781af 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -184,6 +184,7 @@ androidExtensions { dependencies { implementation project(path:':libs:stories-android:stories') + testImplementation project(path:':photoeditor') implementation project(path:':libs:image-editor::ImageEditor') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.java index dc9efac6b237..a4e7c787e6ab 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/EditPostActivity.java @@ -171,7 +171,6 @@ import org.wordpress.android.ui.stories.StoryRepositoryWrapper; import org.wordpress.android.ui.stories.prefs.StoriesPrefs; import org.wordpress.android.ui.stories.usecase.LoadStoryFromStoriesPrefsUseCase; -import org.wordpress.android.ui.stories.usecase.LoadStoryFromStoriesPrefsUseCase.ReCreateStoryResult; import org.wordpress.android.ui.uploads.PostEvents; import org.wordpress.android.ui.uploads.UploadService; import org.wordpress.android.ui.uploads.UploadUtils; @@ -677,7 +676,7 @@ protected void onCreate(Bundle savedInstanceState) { setupPrepublishingBottomSheetRunnable(); - mStoriesEventListener.start(this.getLifecycle(), mSite); + mStoriesEventListener.start(this.getLifecycle(), mSite, mEditPostRepository); setupPreviewUI(); } @@ -3252,62 +3251,32 @@ public void onTrackableEvent(TrackableEvent event, Map propertie } @Override public void onStoryComposerLoadRequested(ArrayList mediaFiles, String blockId) { - if (mLoadStoryFromStoriesPrefsUseCase.anyMediaIdsInGutenbergStoryBlockAreCorrupt(mediaFiles)) { - // unfortunately the medaiIds seem corrupt so, show a dialog and bail - AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); - builder.setTitle(getString(R.string.dialog_edit_story_unavailable_title)); - builder.setMessage(getString(R.string.dialog_edit_story_corrupt_message)); - builder.setPositiveButton(R.string.dialog_button_ok, (dialog, id) -> { - dialog.dismiss(); - }); - AlertDialog dialog = builder.create(); - dialog.show(); - return; - } + boolean noSlidesLoaded = mStoriesEventListener.onRequestMediaFilesEditorLoad( + this, + new LocalId(mEditPostRepository.getId()), + mNetworkErrorOnLastMediaFetchAttempt, + mediaFiles, + blockId + ); - ReCreateStoryResult result = mLoadStoryFromStoriesPrefsUseCase - .loadStoryFromMemoryOrRecreateFromPrefs(mSite, mediaFiles); - if (!result.getNoSlidesLoaded()) { - // Story instance loaded or re-created! Load it onto the StoryComposer for editing now - ActivityLauncher.editStoryForResult( - this, - mSite, - new LocalId(mEditPostRepository.getId()), - result.getStoryIndex(), - result.getAllStorySlidesAreEditable(), - true, - blockId - ); - } else { - // unfortunately we couldn't even load the remote media Ids indicated by the StoryBlock so we can't allow - // editing at this time :( - if (mNetworkErrorOnLastMediaFetchAttempt) { - // there was an error fetching media when we were loading the editor, - // we *may* still have a possibility, tell the user they may try refreshing the media again - AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); - builder.setTitle(getString(R.string.dialog_edit_story_unavailable_title)); - builder.setMessage(getString(R.string.dialog_edit_story_unavailable_message)); - builder.setPositiveButton(R.string.dialog_button_ok, (dialog, id) -> { - // try another fetchMedia request - fetchMediaList(); - dialog.dismiss(); - }); - AlertDialog dialog = builder.create(); - dialog.show(); - } else { - // unrecoverable error, nothing we can do, inform the user :(. - AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this); - builder.setTitle(getString(R.string.dialog_edit_story_unrecoverable_title)); - builder.setMessage(getString(R.string.dialog_edit_story_unrecoverable_message)); - builder.setPositiveButton(R.string.dialog_button_ok, (dialog, id) -> { - dialog.dismiss(); - }); - AlertDialog dialog = builder.create(); - dialog.show(); - } + if (mNetworkErrorOnLastMediaFetchAttempt && noSlidesLoaded) { + // try another fetchMedia request + fetchMediaList(); } } + @Override public void onRetryUploadForMediaCollection(ArrayList mediaFiles) { + mStoriesEventListener.onRetryUploadForMediaCollection(this, mediaFiles, mEditorMediaUploadListener); + } + + @Override public void onCancelUploadForMediaCollection(ArrayList mediaFiles) { + mStoriesEventListener.onCancelUploadForMediaCollection(mediaFiles); + } + + @Override public void onCancelSaveForMediaCollection(ArrayList mediaFiles) { + mStoriesEventListener.onCancelSaveForMediaCollection(mediaFiles); + } + // FluxC events @SuppressWarnings("unused") diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StoriesEventListener.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StoriesEventListener.kt index b8c5b7ffb9a0..2732766f11db 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StoriesEventListener.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StoriesEventListener.kt @@ -1,10 +1,14 @@ package org.wordpress.android.ui.posts.editor +import android.app.Activity +import android.content.DialogInterface import android.net.Uri +import androidx.appcompat.app.AlertDialog.Builder import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle.State.CREATED import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.OnLifecycleEvent +import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.wordpress.stories.compose.frame.StorySaveEvents.FrameSaveCompleted import com.wordpress.stories.compose.frame.StorySaveEvents.FrameSaveFailed import com.wordpress.stories.compose.frame.StorySaveEvents.FrameSaveProgress @@ -12,27 +16,47 @@ import com.wordpress.stories.compose.frame.StorySaveEvents.FrameSaveStart import com.wordpress.stories.compose.frame.StorySaveEvents.StorySaveResult import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.wordpress.android.R +import org.wordpress.android.R.string +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.analytics.AnalyticsTracker.Stat.EDITOR_UPLOAD_MEDIA_RETRIED +import org.wordpress.android.editor.EditorMediaUploadListener import org.wordpress.android.editor.gutenberg.StorySaveMediaListener import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId import org.wordpress.android.fluxc.model.MediaModel +import org.wordpress.android.fluxc.model.MediaModel.MediaUploadState.UPLOADED import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.MediaStore +import org.wordpress.android.ui.ActivityLauncher +import org.wordpress.android.ui.posts.EditPostRepository +import org.wordpress.android.ui.posts.editor.media.EditorMedia import org.wordpress.android.ui.stories.SaveStoryGutenbergBlockUseCase.Companion.TEMPORARY_ID_PREFIX import org.wordpress.android.ui.stories.StoryRepositoryWrapper import org.wordpress.android.ui.stories.media.StoryMediaSaveUploadBridge.StoryFrameMediaModelCreatedEvent +import org.wordpress.android.ui.stories.usecase.LoadStoryFromStoriesPrefsUseCase +import org.wordpress.android.ui.uploads.UploadService +import org.wordpress.android.util.AppLog +import org.wordpress.android.util.AppLog.T.MEDIA import org.wordpress.android.util.EventBusWrapper import org.wordpress.android.util.FluxCUtils +import org.wordpress.android.util.StringUtils import org.wordpress.android.util.helpers.MediaFile +import java.util.ArrayList +import java.util.HashMap import javax.inject.Inject class StoriesEventListener @Inject constructor( private val dispatcher: Dispatcher, private val mediaStore: MediaStore, private val eventBusWrapper: EventBusWrapper, + private val editorMedia: EditorMedia, + private val loadStoryFromStoriesPrefsUseCase: LoadStoryFromStoriesPrefsUseCase, private val storyRepositoryWrapper: StoryRepositoryWrapper ) : LifecycleObserver { private lateinit var lifecycle: Lifecycle private lateinit var site: SiteModel + private lateinit var editPostRepository: EditPostRepository private var storySaveMediaListener: StorySaveMediaListener? = null @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) @@ -43,7 +67,7 @@ class StoriesEventListener @Inject constructor( /** * Handles the [Lifecycle.Event.ON_DESTROY] event to cleanup the registration for dispatcher and removing the - * observer for lifecycle. + * observer for lifecycle . */ @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) private fun onDestroy() { @@ -52,8 +76,9 @@ class StoriesEventListener @Inject constructor( eventBusWrapper.unregister(this) } - fun start(lifecycle: Lifecycle, site: SiteModel) { + fun start(lifecycle: Lifecycle, site: SiteModel, editPostRepository: EditPostRepository) { this.site = site + this.editPostRepository = editPostRepository this.lifecycle = lifecycle this.lifecycle.addObserver(this) } @@ -121,11 +146,11 @@ class StoriesEventListener @Inject constructor( } @Subscribe(threadMode = ThreadMode.MAIN) - fun onStoryFrameMediaModelCreated(event: StoryFrameMediaModelCreatedEvent) { + fun onStoryFrameMediaIdChanged(event: StoryFrameMediaModelCreatedEvent) { if (!lifecycle.currentState.isAtLeast(CREATED)) { return } - storySaveMediaListener?.onMediaModelCreatedForFile(event.oldId, event.newId, event.oldUrl) + storySaveMediaListener?.onMediaModelCreatedForFile(event.oldId, event.newId.toString(), event.oldUrl) } @Subscribe(threadMode = ThreadMode.MAIN) @@ -135,9 +160,10 @@ class StoriesEventListener @Inject constructor( } val localMediaId = event.frameId.toString() // just update progress, we may have still some other frames in this story that need be saved. - // we will send the Failed signal once all the Story frames have been processed + // we will send the Failed signal once all the Story frames have been processed (see onStorySaveProcessFinished) val progress: Float = storyRepositoryWrapper.getCurrentStorySaveProgress(event.storyIndex, 0.0f) storySaveMediaListener?.onMediaSaveReattached(localMediaId, progress) + // storySaveMediaListener?.onMediaSaveFailed(localMediaId) } @Subscribe(threadMode = ThreadMode.MAIN) @@ -146,14 +172,135 @@ class StoriesEventListener @Inject constructor( return } val story = storyRepositoryWrapper.getStoryAtIndex(event.storyIndex) - if (event.isSuccess() && event.frameSaveResult.size == story.frames.size) { + if (!event.isRetry && event.frameSaveResult.size == story.frames.size) { // take the first frame IDs and mediaUri val localMediaId = story.frames[0].id.toString() - val mediaUrl: String = Uri.fromFile(story.frames[0].composedFrameFile).toString() - storySaveMediaListener?.onMediaSaveSucceeded(localMediaId, mediaUrl) + storySaveMediaListener?.onStorySaveResult(localMediaId, event.isSuccess()) + } + } + + // Editor load / cancel events + fun onRequestMediaFilesEditorLoad( + activity: Activity, + postId: LocalId, + networkErrorOnLastMediaFetchAttempt: Boolean, + mediaFiles: ArrayList, + blockId: String + ): Boolean { + val reCreateStoryResult = loadStoryFromStoriesPrefsUseCase + .loadStoryFromMemoryOrRecreateFromPrefs(site, mediaFiles) + if (!reCreateStoryResult.noSlidesLoaded) { + // Story instance loaded or re-created! Load it onto the StoryComposer for editing now + ActivityLauncher.editStoryForResult( + activity, + site, + postId, + reCreateStoryResult.storyIndex, + reCreateStoryResult.allStorySlidesAreEditable, + true, + blockId + ) } else { - val localMediaId = story.frames[0].id.toString() - storySaveMediaListener?.onMediaSaveFailed(localMediaId) + // unfortunately we couldn't even load the remote media Ids indicated by the StoryBlock so we can't allow + // editing at this time :( + if (networkErrorOnLastMediaFetchAttempt) { + // there was an error fetching media when we were loading the editor, + // we *may* still have a possibility, tell the user they may try refreshing the media again + val builder: Builder = MaterialAlertDialogBuilder( + activity + ) + builder.setTitle(activity.getString(R.string.dialog_edit_story_unavailable_title)) + builder.setMessage(activity.getString(R.string.dialog_edit_story_unavailable_message)) + builder.setPositiveButton(R.string.dialog_button_ok) { dialog, id -> + dialog.dismiss() + } + val dialog = builder.create() + dialog.show() + } else { + // unrecoverable error, nothing we can do, inform the user :(. + val builder: Builder = MaterialAlertDialogBuilder( + activity + ) + builder.setTitle(activity.getString(R.string.dialog_edit_story_unrecoverable_title)) + builder.setMessage(activity.getString(R.string.dialog_edit_story_unrecoverable_message)) + builder.setPositiveButton(R.string.dialog_button_ok) { dialog, id -> dialog.dismiss() } + val dialog = builder.create() + dialog.show() + } + } + return reCreateStoryResult.noSlidesLoaded + } + + fun onCancelUploadForMediaCollection(mediaFiles: ArrayList) { + // just cancel upload for each media + for (mediaFile in mediaFiles) { + val localMediaId = StringUtils.stringToInt( + (mediaFile as HashMap)["id"].toString(), 0 + ) + if (localMediaId != 0) { + editorMedia.cancelMediaUploadAsync(localMediaId, false) + } + } + } + + fun onRetryUploadForMediaCollection( + activity: Activity, + mediaFiles: ArrayList, + editorMediaUploadListener: EditorMediaUploadListener? + ) { + val mediaIdsToRetry = ArrayList() + for (mediaFile in mediaFiles) { + val localMediaId = StringUtils.stringToInt( + (mediaFile as HashMap)["id"].toString(), 0 + ) + if (localMediaId != 0) { + val media: MediaModel = mediaStore.getMediaWithLocalId(localMediaId) + // if we find at least one item in the mediaFiles collection passed + // for which we don't have a local MediaModel, just tell the user and bail + if (media == null) { + AppLog.e( + MEDIA, + "Can't find media with local id: $localMediaId" + ) + val builder: Builder = MaterialAlertDialogBuilder( + activity + ) + builder.setTitle(activity.getString(string.cannot_retry_deleted_media_item_fatal)) + builder.setPositiveButton(string.yes) { dialog, id -> dialog.dismiss() } + builder.setNegativeButton(activity.getString(string.no), + DialogInterface.OnClickListener { dialog: DialogInterface, id: Int -> dialog.dismiss() } + ) + val dialog = builder.create() + dialog.show() + return + } + if (media.url != null && media.uploadState == UPLOADED.toString()) { + // Note: we should actually do this when the editor fragment starts instead of waiting for user + // input. + // Notify the editor fragment upload was successful and it should replace the local url by the + // remote url. + editorMediaUploadListener?.onMediaUploadSucceeded( + media.id.toString(), + FluxCUtils.mediaFileFromMediaModel(media) + ) + } else { + UploadService.cancelFinalNotification( + activity, + editPostRepository.getPost() + ) + UploadService.cancelFinalNotificationForMedia(activity, site) + mediaIdsToRetry.add(localMediaId) + } + } + } + + if (!mediaIdsToRetry.isEmpty()) { + editorMedia.retryFailedMediaAsync(mediaIdsToRetry) } + AnalyticsTracker.track(EDITOR_UPLOAD_MEDIA_RETRIED) + } + + fun onCancelSaveForMediaCollection(mediaFiles: ArrayList) { + // TODO implement cancelling save process for media collection } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stories/SaveStoryGutenbergBlockUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/stories/SaveStoryGutenbergBlockUseCase.kt index 81f1fd87a6dd..8dae05f35665 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stories/SaveStoryGutenbergBlockUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stories/SaveStoryGutenbergBlockUseCase.kt @@ -1,9 +1,14 @@ package org.wordpress.android.ui.stories import com.google.gson.Gson +import com.wordpress.stories.compose.frame.FrameIndex +import com.wordpress.stories.compose.story.StoryFrameItem +import com.wordpress.stories.compose.story.StoryIndex import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.ui.posts.EditPostRepository import org.wordpress.android.ui.stories.prefs.StoriesPrefs +import org.wordpress.android.ui.stories.prefs.StoriesPrefs.TempId import org.wordpress.android.util.StringUtils import org.wordpress.android.util.helpers.MediaFile import javax.inject.Inject @@ -52,7 +57,7 @@ class SaveStoryGutenbergBlockUseCase @Inject constructor( fun buildMediaFileDataWithTemporaryId(mediaFile: MediaFile, temporaryId: String): StoryMediaFileData { return StoryMediaFileData( alt = "", - id = TEMPORARY_ID_PREFIX + temporaryId, // mediaFile.id, + id = temporaryId, // mediaFile.id, link = StringUtils.notNullStr(mediaFile.fileURL), type = if (mediaFile.isVideo) "video" else "image", mime = StringUtils.notNullStr(mediaFile.mimeType), @@ -68,7 +73,7 @@ class SaveStoryGutenbergBlockUseCase @Inject constructor( ): StoryMediaFileData { return StoryMediaFileData( alt = "", - id = TEMPORARY_ID_PREFIX + temporaryId, // mediaFile.id, + id = temporaryId, // mediaFile.id, link = url, type = if (isVideo) "video" else "image", mime = "", @@ -77,35 +82,8 @@ class SaveStoryGutenbergBlockUseCase @Inject constructor( ) } - fun cleanTemporaryMediaFilesStructFoundInAnyStoryBlockInPost(editPostRepository: EditPostRepository) { - editPostRepository.update { postModel: PostModel -> - val gson = Gson() - findAllStoryBlocksInPostAndPerformOnEachMediaFilesJson( - postModel, - object : DoWithMediaFilesListener { - override fun doWithMediaFilesJson(content: String, mediaFilesJsonString: String): String { - var processedContent = content - val storyBlockData: StoryBlockData? = - gson.fromJson(mediaFilesJsonString, StoryBlockData::class.java) - storyBlockData?.let { - if (hasTemporaryIdsInStoryData(it)) { - // here remove the whole mediaFiles attribute - processedContent = content.replace(mediaFilesJsonString, "") - } - } - return processedContent - } - } - ) - true - } - } - - private fun hasTemporaryIdsInStoryData(storyBlockData: StoryBlockData): Boolean { - val temporaryIds = storyBlockData.mediaFiles.filter { - it.id.startsWith(TEMPORARY_ID_PREFIX) - } - return temporaryIds.size > 0 + fun getTempIdForStoryFrame(tempIdBase: Long, storyIndex: StoryIndex, frameIndex: FrameIndex): String { + return TEMPORARY_ID_PREFIX + "$tempIdBase-$storyIndex-$frameIndex" } fun findAllStoryBlocksInPostAndPerformOnEachMediaFilesJson( @@ -156,7 +134,7 @@ class SaveStoryGutenbergBlockUseCase @Inject constructor( // look for the slide saved with the local id key (mediaFile.id), and re-convert to // mediaId. storiesPrefs.replaceLocalMediaIdKeyedSlideWithRemoteMediaIdKeyedSlide( - mediaFile.id.toInt(), + mediaFile.id, mediaFile.mediaId.toLong(), postModel.localSiteId.toLong() ) @@ -170,6 +148,28 @@ class SaveStoryGutenbergBlockUseCase @Inject constructor( ) } + fun saveNewLocalFilesToStoriesPrefsTempSlides( + site: SiteModel, + storyIndex: StoryIndex, + frames: ArrayList + ) { + for ((frameIndex, frame) in frames.withIndex()) { + if (frame.id == null) { + val assignedTempId = getTempIdForStoryFrame( + storiesPrefs.getNewIncrementalTempId(), + storyIndex, + frameIndex + ) + frame.id = assignedTempId + } + storiesPrefs.saveSlideWithTempId( + site.id.toLong(), + TempId(requireNotNull(frame.id)), // should not be null at this point + frame + ) + } + } + private fun createGBStoryBlockStringFromJson(storyBlock: StoryBlockData): String { val gson = Gson() return HEADING_START + gson.toJson(storyBlock) + HEADING_END + DIV_PART + CLOSING_TAG diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stories/StoryComposerActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/stories/StoryComposerActivity.kt index 22608803c14c..9ccdb705ae61 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stories/StoryComposerActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stories/StoryComposerActivity.kt @@ -22,6 +22,7 @@ import com.wordpress.stories.compose.PrepublishingEventProvider import com.wordpress.stories.compose.SnackbarProvider import com.wordpress.stories.compose.StoryDiscardListener import com.wordpress.stories.compose.frame.StorySaveEvents.StorySaveResult +import com.wordpress.stories.compose.story.StoryFrameItem import com.wordpress.stories.compose.story.StoryFrameItem.BackgroundSource.FileBackgroundSource import com.wordpress.stories.compose.story.StoryFrameItem.BackgroundSource.UriBackgroundSource import com.wordpress.stories.compose.story.StoryFrameItemType.VIDEO @@ -62,6 +63,7 @@ import org.wordpress.android.ui.posts.PublishPost import org.wordpress.android.ui.posts.editor.media.AddExistingMediaSource.WP_MEDIA_LIBRARY import org.wordpress.android.ui.posts.editor.media.EditorMediaListener import org.wordpress.android.ui.posts.prepublishing.PrepublishingBottomSheetListener +import org.wordpress.android.ui.stories.SaveStoryGutenbergBlockUseCase.Companion.TEMPORARY_ID_PREFIX import org.wordpress.android.ui.stories.SaveStoryGutenbergBlockUseCase.StoryMediaFileData import org.wordpress.android.ui.stories.media.StoryEditorMedia import org.wordpress.android.ui.stories.media.StoryEditorMedia.AddMediaToStoryPostUiState @@ -510,39 +512,45 @@ class StoryComposerActivity : ComposeLoopFrameActivity(), val storyMediaFileDataList = ArrayList() // holds media files val story = storyRepositoryWrapper.getStoryAtIndex(storyIndex) for ((frameIndex, frame) in story.frames.withIndex()) { + val newTempId = storiesPrefs.getNewIncrementalTempId() + val assignedTempId = saveStoryGutenbergBlockUseCase.getTempIdForStoryFrame( + newTempId, storyIndex, frameIndex + ) when (frame.id) { // if the frame.id is null, this is a new frame that has been added to an edited Story // so, we don't have much information yet. We do have the background source (not the flattened // image yet) so, let's use that for now, and assign the temporaryID we'll use to send // save progress events to Gutenberg. null -> { - val storyMediaFileData = - saveStoryGutenbergBlockUseCase.buildMediaFileDataWithTemporaryIdNoMediaFile( - temporaryId = "$storyIndex-$frameIndex", - url = if (frame.source is FileBackgroundSource) { - (frame.source as FileBackgroundSource).file.toString() - } else { - (frame.source as UriBackgroundSource).contentUri.toString() - }, - isVideo = (frame.frameItemType is VIDEO) - ) + val storyMediaFileData = buildStoryMediaFileDataForTemporarySlide( + frame, + assignedTempId + ) frame.id = storyMediaFileData.id storyMediaFileDataList.add(storyMediaFileData) } - // if the frame.id is populated, this should be an actual MediaModel mediaId so, + // if the frame.id is populated and is not a temporary id, this should be an actual MediaModel mediaId so, // let's use that to obtain the mediaFile and then replace it with the temporary frame.id else -> { frame.id?.let { - val mediaModel = mediaStore.getSiteMediaWithId(site, it.toLong()) - val mediaFile = fluxCUtilsWrapper.mediaFileFromMediaModel(mediaModel) - mediaFile?.let { - val storyMediaFileData = - saveStoryGutenbergBlockUseCase.buildMediaFileDataWithTemporaryId( - mediaFile = it, - temporaryId = "$storyIndex-$frameIndex" - ) - frame.id = storyMediaFileData.id + if (it.startsWith(TEMPORARY_ID_PREFIX)) { + val storyMediaFileData = buildStoryMediaFileDataForTemporarySlide( + frame, + it + ) storyMediaFileDataList.add(storyMediaFileData) + } else { + val mediaModel = mediaStore.getSiteMediaWithId(site, it.toLong()) + val mediaFile = fluxCUtilsWrapper.mediaFileFromMediaModel(mediaModel) + mediaFile?.let { mediafile -> + val storyMediaFileData = + saveStoryGutenbergBlockUseCase.buildMediaFileDataWithTemporaryId( + mediaFile = mediafile, + temporaryId = assignedTempId + ) + frame.id = storyMediaFileData.id + storyMediaFileDataList.add(storyMediaFileData) + } } } } @@ -551,6 +559,18 @@ class StoryComposerActivity : ComposeLoopFrameActivity(), return storyMediaFileDataList } + private fun buildStoryMediaFileDataForTemporarySlide(frame: StoryFrameItem, tempId: String): StoryMediaFileData { + return saveStoryGutenbergBlockUseCase.buildMediaFileDataWithTemporaryIdNoMediaFile( + temporaryId = tempId, + url = if (frame.source is FileBackgroundSource) { + (frame.source as FileBackgroundSource).file.toString() + } else { + (frame.source as UriBackgroundSource).contentUri.toString() + }, + isVideo = (frame.frameItemType is VIDEO) + ) + } + override fun onSubmitButtonClicked(publishPost: PublishPost) { viewModel.onSubmitButtonClicked() } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stories/media/StoryMediaSaveUploadBridge.kt b/WordPress/src/main/java/org/wordpress/android/ui/stories/media/StoryMediaSaveUploadBridge.kt index 38d77f22b07b..51917328a1a5 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stories/media/StoryMediaSaveUploadBridge.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stories/media/StoryMediaSaveUploadBridge.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.LifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.OnLifecycleEvent import com.wordpress.stories.compose.frame.StorySaveEvents.StorySaveResult +import com.wordpress.stories.compose.story.StoryFrameItem import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -32,6 +33,7 @@ import org.wordpress.android.ui.stories.StoriesTrackerHelper import org.wordpress.android.ui.stories.StoryComposerActivity import org.wordpress.android.ui.stories.StoryRepositoryWrapper import org.wordpress.android.ui.stories.prefs.StoriesPrefs +import org.wordpress.android.ui.stories.prefs.StoriesPrefs.TempId import org.wordpress.android.ui.uploads.UploadServiceFacade import org.wordpress.android.util.EventBusWrapper import org.wordpress.android.util.NetworkUtilsWrapper @@ -143,11 +145,18 @@ class StoryMediaSaveUploadBridge @Inject constructor( mediaModel?.let { val oldTemporaryId = frame.id ?: "" frame.id = it.id.toString() - storiesPrefs.saveSlideWithLocalId( + + // if prefs has this Slide with the temporary key, replace it + // if not, let's now save the new slide with the local key + storiesPrefs.replaceTempMediaIdKeyedSlideWithLocalMediaIdKeyedSlide( + TempId(oldTemporaryId), + LocalId(it.id), + it.localSiteId.toLong() + ) ?: storiesPrefs.saveSlideWithLocalId( it.localSiteId.toLong(), // use the local id to save the original, will be replaced later // with mediaModel.mediaId after uploading to the remote site - LocalId(it.id.toInt()), + LocalId(it.id), frame ) @@ -158,8 +167,9 @@ class StoryMediaSaveUploadBridge @Inject constructor( EventBus.getDefault().post( StoryFrameMediaModelCreatedEvent( oldTemporaryId, - it.id.toString(), - oldUri.toString() + it.id, + oldUri.toString(), + frame ) ) } @@ -211,23 +221,23 @@ class StoryMediaSaveUploadBridge @Inject constructor( // track event storiesTrackerHelper.trackStorySaveResultEvent(event) - // only trigger the bridge preparation and the UploadService if the Story is now complete - // otherwise we can be receiving successful retry events for individual frames we shouldn't care about just - // yet. - if (isStorySavingComplete(event)) { - // only remove it if it was successful - we want to keep it and show a snackbar once when the user - // comes back to the app if it wasn't, see MySiteFrament for details. - eventBusWrapper.removeStickyEvent(event) - event.metadata?.let { - val site = it.getSerializable(WordPress.SITE) as SiteModel + event.metadata?.let { + val site = it.getSerializable(WordPress.SITE) as SiteModel + val story = storyRepositoryWrapper.getStoryAtIndex(event.storyIndex) + saveStoryGutenbergBlockUseCase.saveNewLocalFilesToStoriesPrefsTempSlides( + site, + event.storyIndex, + story.frames + ) + + // only trigger the bridge preparation and the UploadService if the Story is now complete + // otherwise we can be receiving successful retry events for individual frames we shouldn't care about just + // yet. + if (isStorySavingComplete(event) && !event.isRetry) { + // only remove it if it was successful - we want to keep it and show a snackbar once when the user + // comes back to the app if it wasn't, see MySiteFragment for details. + eventBusWrapper.removeStickyEvent(event) editPostRepository.loadPostByLocalPostId(it.getInt(StoryComposerActivity.KEY_POST_LOCAL_ID)) - if (event.isEditMode) { - // we're done using the temporary ids, let's clean mediaFiles attribute from the blocks that have - // those - saveStoryGutenbergBlockUseCase.cleanTemporaryMediaFilesStructFoundInAnyStoryBlockInPost( - editPostRepository - ) - } // media upload tracking already in addLocalMediaToPostUseCase.addNewMediaToEditorAsync addNewStoryFrameMediaItemsToPostAndUploadAsync(site, event) } @@ -239,5 +249,10 @@ class StoryMediaSaveUploadBridge @Inject constructor( event.frameSaveResult.size == storyRepositoryWrapper.getStoryAtIndex(event.storyIndex).frames.size) } - data class StoryFrameMediaModelCreatedEvent(val oldId: String, val newId: String, val oldUrl: String) + data class StoryFrameMediaModelCreatedEvent( + val oldId: String, + val newId: Int, + val oldUrl: String, + val frame: StoryFrameItem + ) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stories/prefs/StoriesPrefs.kt b/WordPress/src/main/java/org/wordpress/android/ui/stories/prefs/StoriesPrefs.kt index d09d7b3234b4..a96b2cdb8f48 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stories/prefs/StoriesPrefs.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stories/prefs/StoriesPrefs.kt @@ -1,5 +1,6 @@ package org.wordpress.android.ui.stories.prefs +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import androidx.preference.PreferenceManager @@ -17,9 +18,11 @@ class StoriesPrefs @Inject constructor( private val context: Context ) { companion object { - private val KEY_PREFIX_STORIES_SLIDE_ID = "story_slide_id-" - private val KEY_PREFIX_LOCAL_MEDIA_ID = "l-" - private val KEY_PREFIX_REMOTE_MEDIA_ID = "r-" + private const val KEY_STORIES_SLIDE_INCREMENTAL_ID = "incremental_id" + private const val KEY_PREFIX_STORIES_SLIDE_ID = "story_slide_id-" + private const val KEY_PREFIX_TEMP_MEDIA_ID = "t-" + private const val KEY_PREFIX_LOCAL_MEDIA_ID = "l-" + private const val KEY_PREFIX_REMOTE_MEDIA_ID = "r-" } private fun buildSlideKey(siteId: Long, mediaId: RemoteId): String { @@ -32,13 +35,57 @@ class StoriesPrefs @Inject constructor( KEY_PREFIX_LOCAL_MEDIA_ID + mediaId.value.toString() } + private fun buildSlideKey(siteId: Long, tempId: TempId): String { + return KEY_PREFIX_STORIES_SLIDE_ID + siteId.toString() + "-" + + KEY_PREFIX_TEMP_MEDIA_ID + tempId.id + } + + @SuppressLint("ApplySharedPref") + @Synchronized + fun getNewIncrementalTempId(): Long { + var currentIncrementalId = getIncrementalTempId() + currentIncrementalId++ + val editor = PreferenceManager.getDefaultSharedPreferences(context).edit() + editor.putLong(KEY_STORIES_SLIDE_INCREMENTAL_ID, currentIncrementalId) + editor.commit() + return currentIncrementalId + } + + private fun getIncrementalTempId(): Long { + return PreferenceManager.getDefaultSharedPreferences(context).getLong( + KEY_STORIES_SLIDE_INCREMENTAL_ID, + 0 + ) + } + fun checkSlideIdExists(siteId: Long, mediaId: RemoteId): Boolean { val slideIdKey = buildSlideKey(siteId, mediaId) return PreferenceManager.getDefaultSharedPreferences(context).contains(slideIdKey) } - fun checkSlideOriginalBackgroundMediaExists(siteId: Long, mediaId: RemoteId): Boolean { - val storyFrameItem: StoryFrameItem? = getSlideWithRemoteId(siteId, mediaId) + private fun checkSlideIdExists(siteId: Long, tempId: TempId): Boolean { + val slideIdKey = buildSlideKey(siteId, tempId) + return PreferenceManager.getDefaultSharedPreferences(context).contains(slideIdKey) + } + + private fun checkSlideIdExists(siteId: Long, localId: LocalId): Boolean { + val slideIdKey = buildSlideKey(siteId, localId) + return PreferenceManager.getDefaultSharedPreferences(context).contains(slideIdKey) + } + + private fun checkSlideOriginalBackgroundMediaExists(siteId: Long, mediaId: RemoteId): Boolean { + return checkSlideOriginalBackgroundMediaExists(getSlideWithRemoteId(siteId, mediaId)) + } + + private fun checkSlideOriginalBackgroundMediaExists(siteId: Long, mediaId: TempId): Boolean { + return checkSlideOriginalBackgroundMediaExists(getSlideWithTempId(siteId, mediaId)) + } + + private fun checkSlideOriginalBackgroundMediaExists(siteId: Long, mediaId: LocalId): Boolean { + return checkSlideOriginalBackgroundMediaExists(getSlideWithLocalId(siteId, mediaId)) + } + + private fun checkSlideOriginalBackgroundMediaExists(storyFrameItem: StoryFrameItem?): Boolean { storyFrameItem?.let { frame -> // now check the background media exists or is accessible on this device frame.source.let { source -> @@ -84,6 +131,16 @@ class StoriesPrefs @Inject constructor( checkSlideOriginalBackgroundMediaExists(siteId, mediaId) } + fun isValidSlide(siteId: Long, tempId: TempId): Boolean { + return checkSlideIdExists(siteId, tempId) && + checkSlideOriginalBackgroundMediaExists(siteId, tempId) + } + + fun isValidSlide(siteId: Long, localId: LocalId): Boolean { + return checkSlideIdExists(siteId, localId) && + checkSlideOriginalBackgroundMediaExists(siteId, localId) + } + private fun getSlideJson(slideIdKey: String): String? { return PreferenceManager.getDefaultSharedPreferences(context).getString(slideIdKey, null) } @@ -102,6 +159,18 @@ class StoriesPrefs @Inject constructor( } ?: return null } + fun getSlideWithTempId(siteId: Long, tempId: TempId): StoryFrameItem? { + val jsonSlide = getSlideJson(buildSlideKey(siteId, tempId)) + jsonSlide?.let { + return StorySerializerUtils.deserializeStoryFrameItem(jsonSlide) + } ?: return null + } + + fun saveSlideWithTempId(siteId: Long, tempId: TempId, storyFrameItem: StoryFrameItem) { + val slideIdKey = buildSlideKey(siteId, tempId) + saveSlide(slideIdKey, StorySerializerUtils.serializeStoryFrameItem(storyFrameItem)) + } + fun saveSlideWithLocalId(siteId: Long, mediaId: LocalId, storyFrameItem: StoryFrameItem) { val slideIdKey = buildSlideKey(siteId, mediaId) saveSlide(slideIdKey, StorySerializerUtils.serializeStoryFrameItem(storyFrameItem)) @@ -112,6 +181,13 @@ class StoriesPrefs @Inject constructor( saveSlide(slideIdKey, StorySerializerUtils.serializeStoryFrameItem(storyFrameItem)) } + fun deleteSlideWithTempId(siteId: Long, tempId: TempId) { + val slideIdKey = buildSlideKey(siteId, tempId) + val editor = PreferenceManager.getDefaultSharedPreferences(context).edit() + editor.remove(slideIdKey) + editor.apply() + } + fun deleteSlideWithLocalId(siteId: Long, mediaId: LocalId) { val slideIdKey = buildSlideKey(siteId, mediaId) val editor = PreferenceManager.getDefaultSharedPreferences(context).edit() @@ -127,6 +203,9 @@ class StoriesPrefs @Inject constructor( } } + // Phase 2: this method is likely used after a first phase in which a local media which only has a temporary id has + // then be replaced by a local id. At this point, we now have a remote Id and we can replace the local + // media id with the remote media id. fun replaceLocalMediaIdKeyedSlideWithRemoteMediaIdKeyedSlide( localIdKey: Int, remoteIdKey: Long, @@ -150,4 +229,35 @@ class StoriesPrefs @Inject constructor( ) } } + + // Phase 1: this method is likely used at the beginning when a local media which only has a temporary id needs now + // to be assigned with a localMediaId. At a later point when the media is uploaded to the server, it will be + // assigned a remote Id which will replace this localId. + fun replaceTempMediaIdKeyedSlideWithLocalMediaIdKeyedSlide( + tempId: TempId, + localId: LocalId, + localSiteId: Long + ): StoryFrameItem? { + // look for the slide saved with the local id key (mediaFile.id), and re-convert to mediaId. + getSlideWithTempId( + localSiteId, + tempId + )?.let { + it.id = localId.value.toString() + saveSlideWithLocalId( + localSiteId, + localId, // use the new localId as key + it + ) + // now delete the old entry + deleteSlideWithTempId( + localSiteId, + tempId + ) + return it + } + return null + } + + data class TempId(val id: String) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/stories/usecase/LoadStoryFromStoriesPrefsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/stories/usecase/LoadStoryFromStoriesPrefsUseCase.kt index 08ea59a5d2f0..4b7d0e694015 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/stories/usecase/LoadStoryFromStoriesPrefsUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/stories/usecase/LoadStoryFromStoriesPrefsUseCase.kt @@ -5,12 +5,16 @@ import com.wordpress.stories.compose.story.StoryFrameItem import com.wordpress.stories.compose.story.StoryIndex import com.wordpress.stories.compose.story.StoryRepository import dagger.Reusable +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId import org.wordpress.android.fluxc.model.MediaModel import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.MediaStore +import org.wordpress.android.ui.stories.SaveStoryGutenbergBlockUseCase.Companion.TEMPORARY_ID_PREFIX import org.wordpress.android.ui.stories.StoryRepositoryWrapper import org.wordpress.android.ui.stories.prefs.StoriesPrefs +import org.wordpress.android.ui.stories.prefs.StoriesPrefs.TempId +import org.wordpress.android.util.StringUtils import java.util.ArrayList import java.util.HashMap import javax.inject.Inject @@ -24,34 +28,33 @@ class LoadStoryFromStoriesPrefsUseCase @Inject constructor( fun getMediaIdsFromStoryBlockBridgeMediaFiles(mediaFiles: ArrayList): ArrayList { val mediaIds = ArrayList() for (mediaFile in mediaFiles) { - val mediaIdLong = (mediaFile as HashMap)["id"] - .toString() - .toDouble() // this conversion is needed to strip off decimals that can come from RN - .toLong() - val mediaIdString = mediaIdLong.toString() - mediaIds.add(mediaIdString) - } - return mediaIds - } - - fun anyMediaIdsInGutenbergStoryBlockAreCorrupt(mediaFiles: ArrayList): Boolean { - for (mediaFile in mediaFiles) { - try { - (mediaFile as HashMap)["id"] + val rawIdField = (mediaFile as HashMap)["id"] + if (rawIdField is String && rawIdField.startsWith(TEMPORARY_ID_PREFIX)) { + mediaIds.add(rawIdField) + } else { + val mediaIdLong = rawIdField .toString() .toDouble() // this conversion is needed to strip off decimals that can come from RN .toLong() - } catch (exception: NumberFormatException) { - return true + val mediaIdString = mediaIdLong.toString() + mediaIds.add(mediaIdString) } } - return false + return mediaIds } fun areAllStorySlidesEditable(site: SiteModel, mediaIds: ArrayList): Boolean { for (mediaId in mediaIds) { - if (!storiesPrefs.isValidSlide(site.id.toLong(), RemoteId(mediaId.toLong()))) { - return false + // if this is not a remote nor a local / temporary slide, return false + if (mediaId.startsWith(TEMPORARY_ID_PREFIX)) { + if (!storiesPrefs.isValidSlide(site.id.toLong(), TempId(mediaId))) { + return false + } + } else { + if (!storiesPrefs.isValidSlide(site.id.toLong(), RemoteId(mediaId.toLong())) && + !storiesPrefs.isValidSlide(site.id.toLong(), LocalId(StringUtils.stringToInt(mediaId)))) { + return false + } } } return true @@ -66,37 +69,48 @@ class LoadStoryFromStoriesPrefsUseCase @Inject constructor( storyRepositoryWrapper.loadStory(storyIndex) storyIndex = storyRepositoryWrapper.getCurrentStoryIndex() for (mediaId in mediaIds) { - var storyFrameItem = storiesPrefs.getSlideWithRemoteId( - site.getId().toLong(), - RemoteId(mediaId.toLong()) - ) - if (storyFrameItem != null) { - storyRepositoryWrapper.addStoryFrameItemToCurrentStory(storyFrameItem) + // let's check if this is a temporary id + if (mediaId.startsWith(TEMPORARY_ID_PREFIX)) { + storiesPrefs.getSlideWithTempId( + site.getId().toLong(), + TempId(mediaId) + )?.let { + storyRepositoryWrapper.addStoryFrameItemToCurrentStory(it) + } } else { - allStorySlidesAreEditable = false + storiesPrefs.getSlideWithRemoteId( + site.getId().toLong(), + RemoteId(mediaId.toLong()) + )?.let { + storyRepositoryWrapper.addStoryFrameItemToCurrentStory(it) + } ?: run { + allStorySlidesAreEditable = false - // for this missing frame we'll create a new frame using the actual uploaded flattened media - val tmpMediaIdsLong = ArrayList() - tmpMediaIdsLong.add(mediaId.toLong()) - val mediaModelList: List = mediaStore.getSiteMediaWithIds( - site, - tmpMediaIdsLong - ) - if (mediaModelList.isEmpty()) { - noSlidesLoaded = true - } else { - for (mediaModel in mediaModelList) { - storyFrameItem = StoryFrameItem.getNewStoryFrameItemFromUri( - Uri.parse(mediaModel.url), - mediaModel.isVideo - ) - storyFrameItem.id = mediaModel.mediaId.toString() - storyRepositoryWrapper.addStoryFrameItemToCurrentStory(storyFrameItem) + // for this missing frame we'll create a new frame using the actual uploaded flattened media + val tmpMediaIdsLong = ArrayList() + tmpMediaIdsLong.add(mediaId.toLong()) + val mediaModelList: List = mediaStore.getSiteMediaWithIds( + site, + tmpMediaIdsLong + ) + if (mediaModelList.isEmpty()) { + noSlidesLoaded = true + } else { + for (mediaModel in mediaModelList) { + val storyFrameItem = StoryFrameItem.getNewStoryFrameItemFromUri( + Uri.parse(mediaModel.url), + mediaModel.isVideo + ) + storyFrameItem.id = mediaModel.mediaId.toString() + storyRepositoryWrapper.addStoryFrameItemToCurrentStory(storyFrameItem) + } } } } } + noSlidesLoaded = storyRepositoryWrapper.getStoryAtIndex(storyIndex).frames.size == 0 + return ReCreateStoryResult(storyIndex, allStorySlidesAreEditable, noSlidesLoaded) } diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index d3372abfb3c7..7708031650ca 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -209,6 +209,7 @@ Some media can\'t be deleted at this time. Try again later. Media has been removed. Delete it from this post? + Media has been removed. Try editing your Story. You don\'t have any media No media matching your search You don\'t have any images @@ -2902,7 +2903,6 @@ Limited Story Editing This story was edited on a different device and the ability to edit certain objects may be limited. Can\'t edit Story - There was a problem saving this story and can\'t be edited at this moment. Unable to load media for this story. Check your internet connection and try again in a moment. Can\'t edit Story We couldn\'t find the media for this story on the site. @@ -2910,7 +2910,6 @@ Flip camera Flash Stickers - More Text Sound Flip @@ -2921,8 +2920,6 @@ Saved to photos SHARE Share to - Done - Next Close Saved Retry @@ -2932,7 +2929,6 @@ errored Change text alignment Change text color - Delete slide Delete story slide? This slide will be removed from your story. This slide has not been saved yet. If you delete this slide, you will lose any edits you have made. diff --git a/WordPress/src/test/java/org/wordpress/android/ui/stories/SaveStoryGutenbergBlockUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/stories/SaveStoryGutenbergBlockUseCaseTest.kt new file mode 100644 index 000000000000..968f9e47fb32 --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/stories/SaveStoryGutenbergBlockUseCaseTest.kt @@ -0,0 +1,286 @@ +package org.wordpress.android.ui.stories + +import android.content.Context +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.wordpress.stories.compose.story.StoryFrameItem +import kotlinx.coroutines.InternalCoroutinesApi +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.BaseUnitTest +import org.wordpress.android.TEST_DISPATCHER +import org.wordpress.android.fluxc.model.PostModel +import org.wordpress.android.fluxc.store.PostStore +import org.wordpress.android.ui.posts.EditPostRepository +import org.wordpress.android.ui.stories.SaveStoryGutenbergBlockUseCase.Companion.TEMPORARY_ID_PREFIX +import org.wordpress.android.ui.stories.SaveStoryGutenbergBlockUseCase.StoryMediaFileData +import org.wordpress.android.ui.stories.prefs.StoriesPrefs +import org.wordpress.android.util.helpers.MediaFile + +@RunWith(MockitoJUnitRunner::class) +class SaveStoryGutenbergBlockUseCaseTest : BaseUnitTest() { + private lateinit var saveStoryGutenbergBlockUseCase: SaveStoryGutenbergBlockUseCase + private lateinit var editPostRepository: EditPostRepository + @Mock lateinit var storiesPrefs: StoriesPrefs + @Mock lateinit var context: Context + @Mock lateinit var postStore: PostStore + + @InternalCoroutinesApi + @Before + fun setUp() { + saveStoryGutenbergBlockUseCase = SaveStoryGutenbergBlockUseCase(storiesPrefs) + editPostRepository = EditPostRepository( + mock(), + postStore, + mock(), + TEST_DISPATCHER, + TEST_DISPATCHER + ) + } + + @Test + fun `post with empty Story block is given an empty mediaFiles array`() { + // Given + val mediaFiles: ArrayList = setupFluxCMediaFiles(emptyList = true) + editPostRepository.set { PostModel() } + + // When + saveStoryGutenbergBlockUseCase.buildJetpackStoryBlockInPost( + editPostRepository, + mediaFiles + ) + + // Then + Assertions.assertThat(editPostRepository.content).isEqualTo(BLOCK_WITH_EMPTY_MEDIA_FILES) + } + + @Test + fun `post with non-empty Story block is set given a non-empty mediaFiles array`() { + // Given + val mediaFiles: ArrayList = setupFluxCMediaFiles(emptyList = false) + editPostRepository.set { PostModel() } + + // When + saveStoryGutenbergBlockUseCase.buildJetpackStoryBlockInPost( + editPostRepository, + mediaFiles + ) + + // Then + Assertions.assertThat(editPostRepository.content).isEqualTo(BLOCK_WITH_NON_EMPTY_MEDIA_FILES) + } + + @Test + fun `builds non-empty story block string from non-empty mediaFiles array`() { + // Given + val mediaFileDataList: ArrayList = setupMediaFileDataList(emptyList = false) + + // When + val result = saveStoryGutenbergBlockUseCase.buildJetpackStoryBlockStringFromStoryMediaFileData( + mediaFileDataList + ) + + // Then + Assertions.assertThat(result).isEqualTo(BLOCK_WITH_NON_EMPTY_MEDIA_FILES) + } + + @Test + fun `builds empty story block string from empty mediaFiles array`() { + // Given + val mediaFileDataList: ArrayList = setupMediaFileDataList(emptyList = true) + + // When + val result = saveStoryGutenbergBlockUseCase.buildJetpackStoryBlockStringFromStoryMediaFileData( + mediaFileDataList + ) + + // Then + Assertions.assertThat(result).isEqualTo(BLOCK_WITH_EMPTY_MEDIA_FILES) + } + + @Test + fun `verify all properties of mediaFileData that are created from buildMediaFileDataWithTemporaryId are correct`() { + // Given + val mediaFileId = 1 + val mediaFile = getMediaFile(mediaFileId) + + // When + val mediaFileData = saveStoryGutenbergBlockUseCase.buildMediaFileDataWithTemporaryId( + mediaFile, + TEMPORARY_ID_PREFIX + mediaFileId + ) + + // Then + Assertions.assertThat(mediaFileData.alt).isEqualTo("") + Assertions.assertThat(mediaFileData.id).isEqualTo(TEMPORARY_ID_PREFIX + mediaFileId) + Assertions.assertThat(mediaFileData.link).isEqualTo( + "https://testsite.files.wordpress.com/2020/10/wp-0000000.jpg" + ) + Assertions.assertThat(mediaFileData.type).isEqualTo("image") + Assertions.assertThat(mediaFileData.mime).isEqualTo(mediaFile.mimeType) + Assertions.assertThat(mediaFileData.caption).isEqualTo("") + Assertions.assertThat(mediaFileData.url).isEqualTo(mediaFile.fileURL) + } + + @Test + fun `local media id is found and gets replaced with remote media id`() { + // Given + val mediaFile = getMediaFile(1) + val postModel = PostModel() + postModel.setContent(BLOCK_WITH_NON_EMPTY_MEDIA_FILES) + + // When + saveStoryGutenbergBlockUseCase.replaceLocalMediaIdsWithRemoteMediaIdsInPost( + postModel, + mediaFile + ) + + // Then + Assertions.assertThat(postModel.content).isEqualTo(BLOCK_WITH_NON_EMPTY_MEDIA_FILES_WITH_ONE_REMOTE_ID) + } + + @Test + fun `slides are saved locally to storiedPrefs`() { + // Given + val frames = ArrayList() + frames.add(getOneStoryFrameItem("1")) + frames.add(getOneStoryFrameItem("2")) + frames.add(getOneStoryFrameItem("3")) + + // When + saveStoryGutenbergBlockUseCase.saveNewLocalFilesToStoriesPrefsTempSlides( + mock(), + 0, + frames + ) + + // Then + verify(storiesPrefs, times(3)).saveSlideWithTempId(any(), any(), any()) + } + + private fun setupFluxCMediaFiles( + emptyList: Boolean + ): ArrayList { + return when (emptyList) { + true -> ArrayList() + false -> { + val mediaFiles = ArrayList() + for (i in 1..10) { + val mediaFile = MediaFile() + mediaFile.id = i + mediaFile.mediaId = (i + 1000).toString() + mediaFile.mimeType = "image/jpeg" + mediaFile.fileURL = "https://testsite.files.wordpress.com/2020/10/wp-0000000.jpg" + mediaFiles.add(mediaFile) + } + mediaFiles + } + } + } + + private fun getMediaFile(id: Int): MediaFile { + val mediaFile = MediaFile() + mediaFile.id = id + mediaFile.mediaId = (id + 1000).toString() + mediaFile.mimeType = "image/jpeg" + mediaFile.fileURL = "https://testsite.files.wordpress.com/2020/10/wp-0000000.jpg" + return mediaFile + } + + private fun getOneStoryFrameItem(id: String): StoryFrameItem { + return StoryFrameItem( + source = mock(), + id = id + ) + } + + private fun setupMediaFileDataList( + emptyList: Boolean + ): ArrayList { + when (emptyList) { + true -> return ArrayList() + false -> { + val mediaFiles = ArrayList() + for (i in 1..10) { + val mediaFile = StoryMediaFileData( + id = i.toString(), + mime = "image/jpeg", + link = "https://testsite.files.wordpress.com/2020/10/wp-0000000.jpg", + url = "https://testsite.files.wordpress.com/2020/10/wp-0000000.jpg", + alt = "", + type = "image", + caption = "" + ) + mediaFiles.add(mediaFile) + } + return mediaFiles + } + } + } + + companion object { + private const val BLOCK_WITH_EMPTY_MEDIA_FILES = "\n" + + "
\n" + + "" + private const val BLOCK_WITH_NON_EMPTY_MEDIA_FILES = "\n" + + "
\n" + + "" + private const val BLOCK_WITH_NON_EMPTY_MEDIA_FILES_WITH_ONE_REMOTE_ID = "\n" + + "
\n" + + "" + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/stories/usecase/LoadStoryFromStoriesPrefsUseCaseTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/stories/usecase/LoadStoryFromStoriesPrefsUseCaseTest.kt new file mode 100644 index 000000000000..285954b3341b --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/stories/usecase/LoadStoryFromStoriesPrefsUseCaseTest.kt @@ -0,0 +1,188 @@ +package org.wordpress.android.ui.stories.usecase + +import android.content.Context +import com.nhaarman.mockitokotlin2.whenever +import org.assertj.core.api.Assertions +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import org.wordpress.android.fluxc.model.LocalOrRemoteId.LocalId +import org.wordpress.android.fluxc.model.LocalOrRemoteId.RemoteId +import org.wordpress.android.fluxc.model.SiteModel +import org.wordpress.android.fluxc.store.MediaStore +import org.wordpress.android.ui.stories.SaveStoryGutenbergBlockUseCase.Companion.TEMPORARY_ID_PREFIX +import org.wordpress.android.ui.stories.StoryRepositoryWrapper +import org.wordpress.android.ui.stories.prefs.StoriesPrefs +import org.wordpress.android.ui.stories.prefs.StoriesPrefs.TempId + +@RunWith(MockitoJUnitRunner::class) +class LoadStoryFromStoriesPrefsUseCaseTest { + private lateinit var loadStoryFromStoriesPrefsUseCase: LoadStoryFromStoriesPrefsUseCase + @Mock lateinit var storyRepositoryWrapper: StoryRepositoryWrapper + @Mock lateinit var mediaStore: MediaStore + @Mock lateinit var storiesPrefs: StoriesPrefs + @Mock lateinit var context: Context + @Mock lateinit var siteModel: SiteModel + + @Before + fun setUp() { + loadStoryFromStoriesPrefsUseCase = LoadStoryFromStoriesPrefsUseCase( + storyRepositoryWrapper, + storiesPrefs, + mediaStore + ) + } + + @Test + fun `obtain empty media ids list from empty mediaFiles array`() { + // Given + val mediaFiles: ArrayList> = setupMediaFiles(emptyList = true) + + // When + val mediaIds = loadStoryFromStoriesPrefsUseCase.getMediaIdsFromStoryBlockBridgeMediaFiles( + mediaFiles as ArrayList + ) + + // Then + Assertions.assertThat(mediaIds).isEmpty() + } + + @Test + fun `obtain media ids list from non empty mediaFiles array`() { + // Given + val mediaFiles: ArrayList> = setupMediaFiles(emptyList = false) + + // When + val mediaIds = loadStoryFromStoriesPrefsUseCase.getMediaIdsFromStoryBlockBridgeMediaFiles( + mediaFiles as ArrayList + ) + + // Then + Assertions.assertThat(mediaIds).containsExactly("1", "2", "3", "4", "5", "6", "7", "8", "9", "10") + } + + @Test + fun `verify all story slides are editable with temporary ids`() { + // Given + val tempMediaIds = setupTestSlides(markAsValid = true, useTempPrefix = true, useRemoteId = false) + + // When + val result = loadStoryFromStoriesPrefsUseCase.areAllStorySlidesEditable(siteModel, tempMediaIds) + + // Then + Assertions.assertThat(result).isTrue() + } + + @Test + fun `verify all story slides are editable with local ids`() { + // Given + val mediaIdsLocal = setupTestSlides(markAsValid = true, useTempPrefix = false, useRemoteId = false) + + // When + val result = loadStoryFromStoriesPrefsUseCase.areAllStorySlidesEditable(siteModel, mediaIdsLocal) + + // Then + Assertions.assertThat(result).isTrue() + } + + @Test + fun `verify all story slides are editable with remote ids`() { + // Given + val mediaIdsLocal = setupTestSlides(markAsValid = true, useTempPrefix = false, useRemoteId = true) + + // When + val result = loadStoryFromStoriesPrefsUseCase.areAllStorySlidesEditable(siteModel, mediaIdsLocal) + + // Then + Assertions.assertThat(result).isTrue() + } + + @Test + fun `verify not all story slides are editable with temporary ids`() { + // Given + val mediaIdsLocal = setupTestSlides(markAsValid = false, useTempPrefix = true, useRemoteId = false) + + // When + val result = loadStoryFromStoriesPrefsUseCase.areAllStorySlidesEditable(siteModel, mediaIdsLocal) + + // Then + Assertions.assertThat(result).isFalse() + } + + @Test + fun `verify not all story slides are editable with remote ids`() { + // Given + val mediaIdsLocal = setupTestSlides(markAsValid = false, useTempPrefix = false, useRemoteId = true) + + // When + val result = loadStoryFromStoriesPrefsUseCase.areAllStorySlidesEditable(siteModel, mediaIdsLocal) + + // Then + Assertions.assertThat(result).isFalse() + } + + @Test + fun `verify not all story slides are editable with local ids`() { + // Given + val mediaIdsLocal = setupTestSlides(markAsValid = false, useTempPrefix = false, useRemoteId = false) + + // When + val result = loadStoryFromStoriesPrefsUseCase.areAllStorySlidesEditable(siteModel, mediaIdsLocal) + + // Then + Assertions.assertThat(result).isFalse() + } + + private fun setupMediaFiles( + emptyList: Boolean + ): ArrayList> { + return when (emptyList) { + true -> ArrayList() + false -> { + val mediaFiles = ArrayList>() + for (i in 1..10) { + val mediaFile = HashMap() + mediaFile["mime"] = "image/jpeg" + mediaFile["link"] = "https://testsite.files.wordpress.com/2020/10/wp-0000000.jpg" + mediaFile["type"] = "image" + mediaFile["id"] = i.toString() + mediaFiles.add(mediaFile) + } + mediaFiles + } + } + } + + private fun setupTestSlides( + markAsValid: Boolean, + useTempPrefix: Boolean, + useRemoteId: Boolean + ): ArrayList { + val mediaIds = ArrayList() + + for (i in 1..10) { + val mediaId = (if (useTempPrefix) TEMPORARY_ID_PREFIX else "") + i.toString() + mediaIds.add(mediaId) + if (useTempPrefix) { + whenever(storiesPrefs.isValidSlide(siteModel.id.toLong(), TempId(mediaId))).thenReturn(markAsValid) + } else if (useRemoteId) { + whenever( + storiesPrefs.isValidSlide( + siteModel.id.toLong(), + RemoteId(mediaId.toLong()) + ) + ).thenReturn(markAsValid) + } else { + whenever(storiesPrefs.isValidSlide( + siteModel.id.toLong(), + LocalId(mediaId.toInt()) + ) + ).thenReturn(markAsValid) + } + } + + return mediaIds + } +} diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragmentAbstract.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragmentAbstract.java index 4c9060ea6562..c5c3eb1bd35b 100644 --- a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragmentAbstract.java +++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/EditorFragmentAbstract.java @@ -207,8 +207,11 @@ public interface EditorFragmentListener { void onGutenbergEditorSetStarterPageTemplatesTooltipShown(boolean tooltipShown); boolean onGutenbergEditorRequestStarterPageTemplatesTooltipShown(); String getErrorMessageFromMedia(int mediaId); - void onStoryComposerLoadRequested(ArrayList mediaFiles, String blockId); void showJetpackSettings(); + void onStoryComposerLoadRequested(ArrayList mediaFiles, String blockId); + void onRetryUploadForMediaCollection(ArrayList mediaFiles); + void onCancelUploadForMediaCollection(ArrayList mediaFiles); + void onCancelSaveForMediaCollection(ArrayList mediaFiles); } /** diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergContainerFragment.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergContainerFragment.java index d26401c603ed..7a5f9bd750c2 100644 --- a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergContainerFragment.java +++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergContainerFragment.java @@ -28,7 +28,7 @@ import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnStarterPageTemplatesTooltipShownEventListener; import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnMediaEditorListener; import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnMediaLibraryButtonListener; -import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnMediaFilesEditorLoadRequestListener; +import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnMediaFilesCollectionBasedBlockEditorListener; import java.util.ArrayList; @@ -69,7 +69,8 @@ public void attachToContainer(ViewGroup viewGroup, OnMediaLibraryButtonListener onGutenbergDidSendButtonPressedActionListener, AddMentionUtil addMentionUtil, OnStarterPageTemplatesTooltipShownEventListener onSPTTooltipShownEventListener, - OnMediaFilesEditorLoadRequestListener onMediaFilesEditorLoadRequestListener, + OnMediaFilesCollectionBasedBlockEditorListener + onMediaFilesCollectionBasedBlockEditorListener, boolean isDarkMode) { mWPAndroidGlueCode.attachToContainer( viewGroup, @@ -87,7 +88,7 @@ public void attachToContainer(ViewGroup viewGroup, OnMediaLibraryButtonListener onGutenbergDidSendButtonPressedActionListener, addMentionUtil, onSPTTooltipShownEventListener, - onMediaFilesEditorLoadRequestListener, + onMediaFilesCollectionBasedBlockEditorListener, isDarkMode); } @@ -251,6 +252,6 @@ public void onStorySaveResult(final String storyFirstMediaId, final boolean succ } public void onMediaModelCreatedForFile(String oldId, String newId, String oldUrl) { - mWPAndroidGlueCode.mediaModelCreatedForFile(oldId, newId, oldUrl); + mWPAndroidGlueCode.mediaIdChanged(oldId, newId, oldUrl); } } diff --git a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergEditorFragment.java b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergEditorFragment.java index c6bdddfa9581..4da28522a5c4 100644 --- a/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergEditorFragment.java +++ b/libs/editor/WordPressEditor/src/main/java/org/wordpress/android/editor/gutenberg/GutenbergEditorFragment.java @@ -47,6 +47,7 @@ import org.wordpress.android.util.DateTimeUtils; import org.wordpress.android.util.PermissionUtils; import org.wordpress.android.util.ProfilingUtils; +import org.wordpress.android.util.StringUtils; import org.wordpress.android.util.ToastUtils; import org.wordpress.android.util.helpers.MediaFile; import org.wordpress.android.util.helpers.MediaGallery; @@ -65,7 +66,7 @@ import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnReattachMediaUploadQueryListener; import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnStarterPageTemplatesTooltipShownEventListener; import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnMediaLibraryButtonListener; -import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnMediaFilesEditorLoadRequestListener; +import org.wordpress.mobile.WPAndroidGlue.WPAndroidGlueCode.OnMediaFilesCollectionBasedBlockEditorListener; import java.util.ArrayList; import java.util.Date; @@ -348,10 +349,22 @@ public boolean onRequestStarterPageTemplatesTooltipShown() { return mEditorFragmentListener.onGutenbergEditorRequestStarterPageTemplatesTooltipShown(); } }, - new OnMediaFilesEditorLoadRequestListener() { + new OnMediaFilesCollectionBasedBlockEditorListener() { @Override public void onRequestMediaFilesEditorLoad(ArrayList mediaFiles, String blockId) { mEditorFragmentListener.onStoryComposerLoadRequested(mediaFiles, blockId); } + + @Override public void onCancelUploadForMediaCollection(ArrayList mediaFiles) { + showCancelMediaCollectionUploadDialog(mediaFiles); + } + + @Override public void onRetryUploadForMediaCollection(ArrayList mediaFiles) { + showRetryMediaCollectionUploadDialog(mediaFiles); + } + + @Override public void onCancelSaveForMediaCollection(ArrayList mediaFiles) { + showCancelMediaCollectionSaveDialog(mediaFiles); + } }, GutenbergUtils.isDarkMode(getActivity())); @@ -511,7 +524,12 @@ public void resetUploadingMediaToFailed(Set failedMediaIds) { private void updateFailedMediaState() { for (String mediaId : mFailedMediaIds) { - getGutenbergContainerFragment().mediaFileUploadFailed(Integer.valueOf(mediaId)); + // upload progress should work on numeric mediaIds only + if (!TextUtils.isEmpty(mediaId) && TextUtils.isDigitsOnly(mediaId)) { + getGutenbergContainerFragment().mediaFileUploadFailed(Integer.valueOf(mediaId)); + } else { + getGutenbergContainerFragment().mediaFileSaveFailed(mediaId); + } } } @@ -521,6 +539,9 @@ private void updateMediaProgress() { if (!TextUtils.isEmpty(mediaId) && TextUtils.isDigitsOnly(mediaId)) { getGutenbergContainerFragment().mediaFileUploadProgress(Integer.valueOf(mediaId), mUploadingMediaProgressMax.get(mediaId)); + } else { + getGutenbergContainerFragment().mediaFileSaveProgress(mediaId, + mUploadingMediaProgressMax.get(mediaId)); } } } @@ -608,6 +629,76 @@ public void onClick(DialogInterface dialog, int id) { dialog.show(); } + private void showCancelMediaCollectionUploadDialog(ArrayList mediaFiles) { + // Display 'cancel upload' dialog + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(getActivity()); + builder.setTitle(getString(R.string.stop_upload_dialog_title)); + builder.setPositiveButton(R.string.stop_upload_dialog_button_yes, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + mEditorFragmentListener.onCancelUploadForMediaCollection(mediaFiles); + // now signal Gutenberg upload failed, and remove the mediaIds from our tracking map + for (Object mediaFile : mediaFiles) { + // this conversion is needed to strip off decimals that can come from RN when using int as + // string + int localMediaId + = StringUtils.stringToInt( + ((HashMap) mediaFile).get("id").toString(), 0); + getGutenbergContainerFragment().mediaFileUploadFailed(localMediaId); + mUploadingMediaProgressMax.remove(localMediaId); + } + dialog.dismiss(); + } + }); + + builder.setNegativeButton(R.string.stop_upload_dialog_button_no, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.dismiss(); + } + }); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + + private void showRetryMediaCollectionUploadDialog(ArrayList mediaFiles) { + // Display 'retry upload' dialog + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(getActivity()); + builder.setTitle(getString(R.string.retry_failed_upload_title)); + builder.setPositiveButton(R.string.retry_failed_upload_yes, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + mEditorFragmentListener.onRetryUploadForMediaCollection(mediaFiles); + dialog.dismiss(); + } + }); + + builder.setNegativeButton(R.string.dialog_button_cancel, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.dismiss(); + } + }); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + + private void showCancelMediaCollectionSaveDialog(ArrayList mediaFiles) { + // Display 'cancel upload' dialog + AlertDialog.Builder builder = new MaterialAlertDialogBuilder(getActivity()); + builder.setTitle(getString(R.string.stop_save_dialog_title)); + builder.setMessage(getString(R.string.stop_save_dialog_message)); + builder.setPositiveButton(R.string.stop_save_dialog_ok_button, + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.dismiss(); + } + }); + + AlertDialog dialog = builder.create(); + dialog.show(); + } + private void showImplicitKeyboard() { InputMethodManager keyboard = (InputMethodManager) getActivity().getSystemService(Context.INPUT_METHOD_SERVICE); keyboard.toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); @@ -1054,6 +1145,10 @@ public void onEditorThemeUpdated(Bundle editorTheme) { } @Override public void onStorySaveResult(String storyFirstMediaId, boolean success) { + if (!success) { + mFailedMediaIds.add(storyFirstMediaId); + mUploadingMediaProgressMax.remove(storyFirstMediaId); + } getGutenbergContainerFragment().onStorySaveResult(storyFirstMediaId, success); } diff --git a/libs/editor/WordPressEditor/src/main/res/values/strings.xml b/libs/editor/WordPressEditor/src/main/res/values/strings.xml index 3473aa1ea6c1..5f0296797549 100644 --- a/libs/editor/WordPressEditor/src/main/res/values/strings.xml +++ b/libs/editor/WordPressEditor/src/main/res/values/strings.xml @@ -20,6 +20,10 @@ Stop uploading? Can\'t stop the upload because it\'s already finished + Files saving + Please wait until all files have been saved + OK + bold italic blockquote diff --git a/libs/gutenberg-mobile b/libs/gutenberg-mobile index 1e637673eeb9..21e92296af21 160000 --- a/libs/gutenberg-mobile +++ b/libs/gutenberg-mobile @@ -1 +1 @@ -Subproject commit 1e637673eeb98d93981ff7a0b2c4c0921e197108 +Subproject commit 21e92296af210039214c0afb46e9300dba91cf86 diff --git a/libs/stories-android b/libs/stories-android index 621c36488b99..c343c6be8c46 160000 --- a/libs/stories-android +++ b/libs/stories-android @@ -1 +1 @@ -Subproject commit 621c36488b999208617b9e72b3291f4a83c02d0a +Subproject commit c343c6be8c46c822a945f179ff29ffdaefe0d20c