diff --git a/WordPress/build.gradle b/WordPress/build.gradle index b83d17bbc294..e0c6874e28a2 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -157,6 +157,7 @@ android { buildConfigField "boolean", "BLAZE_MANAGE_CAMPAIGNS", "false" buildConfigField "boolean", "DASHBOARD_PERSONALIZATION", "false" buildConfigField "boolean", "ENABLE_SITE_MONITORING", "false" + buildConfigField "boolean", "SYNC_PUBLISHING", "false" manifestPlaceholders = [magicLinkScheme:"wordpress"] } diff --git a/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt new file mode 100644 index 000000000000..14d034b274b5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/modules/PostModule.kt @@ -0,0 +1,17 @@ +package org.wordpress.android.modules + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.wordpress.android.ui.posts.IPostFreshnessChecker +import org.wordpress.android.ui.posts.PostFreshnessCheckerImpl +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class PostModule { + @Singleton + @Provides + fun providePostFreshnessChecker(): IPostFreshnessChecker = PostFreshnessCheckerImpl() +} 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 20d861c576fd..f3802a04d743 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 @@ -17,6 +17,7 @@ import android.view.View; import android.view.ViewGroup; import android.webkit.MimeTypeMap; +import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.Toast; @@ -221,6 +222,7 @@ import org.wordpress.android.util.analytics.AnalyticsUtils.BlockEditorEnabledSource; import org.wordpress.android.util.config.ContactSupportFeatureConfig; import org.wordpress.android.util.config.GlobalStyleSupportFeatureConfig; +import org.wordpress.android.util.config.SyncPublishingFeatureConfig; import org.wordpress.android.util.extensions.AppBarLayoutExtensionsKt; import org.wordpress.android.util.helpers.MediaFile; import org.wordpress.android.util.helpers.MediaGallery; @@ -382,6 +384,8 @@ enum RestartEditorOptions { private boolean mHtmlModeMenuStateOn = false; + private FrameLayout mUpdatingPostArea; + @Inject Dispatcher mDispatcher; @Inject AccountStore mAccountStore; @Inject SiteStore mSiteStore; @@ -423,6 +427,7 @@ enum RestartEditorOptions { @Inject BloggingPromptsStore mBloggingPromptsStore; @Inject JetpackFeatureRemovalPhaseHelper mJetpackFeatureRemovalPhaseHelper; @Inject ContactSupportFeatureConfig mContactSupportFeatureConfig; + @Inject SyncPublishingFeatureConfig mSyncPublishingFeatureConfig; private StorePostViewModel mViewModel; private StorageUtilsViewModel mStorageUtilsViewModel; @@ -437,6 +442,11 @@ enum RestartEditorOptions { private ActivityResultLauncher mEditShareMessageActivityResultLauncher; + private final Handler mHideUpdatingPostAreaHandler = new Handler(Looper.getMainLooper()); + private Runnable mHideUpdatingPostAreaRunnable; + private long mUpdatingPostStartTime = 0L; + private static final long MIN_UPDATING_POST_DISPLAY_TIME = 2000L; // Minimum display time in milliseconds + public static boolean checkToRestart(@NonNull Intent data) { return data.hasExtra(EXTRA_RESTART_EDITOR) && RestartEditorOptions.valueOf(data.getStringExtra(EXTRA_RESTART_EDITOR)) @@ -558,12 +568,15 @@ public void handleOnBackPressed() { getOnBackPressedDispatcher().addCallback(this, callback); mDispatcher.register(this); + + // initialise ViewModels mViewModel = new ViewModelProvider(this, mViewModelFactory).get(StorePostViewModel.class); mStorageUtilsViewModel = new ViewModelProvider(this, mViewModelFactory).get(StorageUtilsViewModel.class); mEditorBloggingPromptsViewModel = new ViewModelProvider(this, mViewModelFactory) .get(EditorBloggingPromptsViewModel.class); mEditorJetpackSocialViewModel = new ViewModelProvider(this, mViewModelFactory) .get(EditorJetpackSocialViewModel.class); + setContentView(R.layout.new_edit_post_activity); createEditShareMessageActivityResultLauncher(); @@ -615,7 +628,6 @@ public void handleOnBackPressed() { mToolbar = findViewById(R.id.toolbar_main); setSupportActionBar(mToolbar); - final ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(false); @@ -797,6 +809,46 @@ public void handleOnBackPressed() { mEditorJetpackSocialViewModel.start(mSite, mEditPostRepository); customizeToolbar(); + + mUpdatingPostArea = findViewById(R.id.updating); + + // check if post content needs updating + if (mSyncPublishingFeatureConfig.isEnabled()) { + mViewModel.checkIfUpdatedPostVersionExists(mEditPostRepository, mSite); + } + } + private void showUpdatingPostArea() { + mUpdatingPostArea.setVisibility(View.VISIBLE); + mUpdatingPostStartTime = System.currentTimeMillis(); + // Cancel any pending hide operations to avoid conflicts + if (mHideUpdatingPostAreaRunnable != null) { + mHideUpdatingPostAreaHandler.removeCallbacks(mHideUpdatingPostAreaRunnable); + } + } + + private void hideUpdatingPostArea() { + long elapsedTime = System.currentTimeMillis() - mUpdatingPostStartTime; + long delay = MIN_UPDATING_POST_DISPLAY_TIME - elapsedTime; + + if (delay > 0) { + // Delay hiding the view if the elapsed time is less than the minimum display time + hideUpdatingPostAreaWithDelay(delay); + } else { + // Hide the view immediately if the minimum display time has been met or exceeded + mUpdatingPostArea.setVisibility(View.GONE); + } + } + + private void hideUpdatingPostAreaWithDelay(long delay) { + // Define the runnable only once or ensure it's the same instance if it's already defined + if (mHideUpdatingPostAreaRunnable == null) { + mHideUpdatingPostAreaRunnable = () -> { + if (mUpdatingPostArea != null) { + mUpdatingPostArea.setVisibility(View.GONE); + } + }; + } + mHideUpdatingPostAreaHandler.postDelayed(mHideUpdatingPostAreaRunnable, delay); } private void customizeToolbar() { @@ -1013,6 +1065,27 @@ private void startObserving() { ); } }); + + mViewModel.getOnPostUpdateUiVisible().observe(this, isVisible -> { + if (isVisible) { + showUpdatingPostArea(); + } else { + hideUpdatingPostArea(); + } + }); + + mViewModel.getOnPostUpdateResult().observe(this, isSuccess -> { + if (isSuccess) { + mEditPostRepository.loadPostByLocalPostId(mEditPostRepository.getId()); + refreshEditorContent(); + } else { + ToastUtils.showToast( + EditPostActivity.this, + getString(R.string.editor_updating_post_failed), + ToastUtils.Duration.SHORT + ); + } + }); } private void initializePostObject() { @@ -1094,6 +1167,10 @@ protected void onPause() { if (mShowPrepublishingBottomSheetHandler != null && mShowPrepublishingBottomSheetRunnable != null) { mShowPrepublishingBottomSheetHandler.removeCallbacks(mShowPrepublishingBottomSheetRunnable); } + + if (mHideUpdatingPostAreaHandler != null && mHideUpdatingPostAreaRunnable != null) { + mHideUpdatingPostAreaHandler.removeCallbacks(mHideUpdatingPostAreaRunnable); + } } @Override diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/IPostFreshnessChecker.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/IPostFreshnessChecker.kt new file mode 100644 index 000000000000..0b97fd520f47 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/IPostFreshnessChecker.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.ui.posts + +import org.wordpress.android.fluxc.model.PostImmutableModel + +/** + * This interface is implemented by a component that determines if a post + * is "fresh" or we need to refetch it from the backend. + */ +interface IPostFreshnessChecker { + fun shouldRefreshPost(post: PostImmutableModel): Boolean +} + +interface TimeProvider { + fun currentTimeMillis(): Long +} + diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostFreshnessCheckerImpl.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostFreshnessCheckerImpl.kt new file mode 100644 index 000000000000..26f1643b1770 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostFreshnessCheckerImpl.kt @@ -0,0 +1,25 @@ +package org.wordpress.android.ui.posts + +import org.wordpress.android.fluxc.model.PostImmutableModel + +class PostFreshnessCheckerImpl( + private val timeProvider: TimeProvider = SystemTimeProvider() +) : IPostFreshnessChecker { + override fun shouldRefreshPost(post: PostImmutableModel): Boolean { + return postNeedsRefresh(post) + } + + private fun postNeedsRefresh(post: PostImmutableModel) : Boolean { + return timeProvider.currentTimeMillis() - post.dbTimestamp > CACHE_VALIDITY_MILLIS + } + + companion object { + // Todo turn this into a remote config value + const val CACHE_VALIDITY_MILLIS = 20000 + } +} + +class SystemTimeProvider : TimeProvider { + override fun currentTimeMillis(): Long = System.currentTimeMillis() +} + diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtilsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtilsWrapper.kt index 8c622c138a56..9e62b2c8a917 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtilsWrapper.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostUtilsWrapper.kt @@ -16,7 +16,10 @@ import javax.inject.Inject * */ @Reusable -class PostUtilsWrapper @Inject constructor(private val dateProvider: DateProvider) { +class PostUtilsWrapper +@Inject constructor( + private val dateProvider: DateProvider +) { fun isPublishable(post: PostImmutableModel) = PostUtils.isPublishable(post) fun isPostInConflictWithRemote(post: PostImmutableModel) = diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StorePostViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StorePostViewModel.kt index 5aa35762de68..684285199203 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StorePostViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/editor/StorePostViewModel.kt @@ -12,15 +12,19 @@ import org.wordpress.android.editor.gutenberg.DialogVisibility.Hidden import org.wordpress.android.editor.gutenberg.DialogVisibility.Showing import org.wordpress.android.editor.gutenberg.DialogVisibilityProvider import org.wordpress.android.fluxc.Dispatcher +import org.wordpress.android.fluxc.generated.PostActionBuilder +import org.wordpress.android.fluxc.model.CauseOfOnPostChanged import org.wordpress.android.fluxc.model.PostImmutableModel import org.wordpress.android.fluxc.model.PostModel import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.PostStore.OnPostChanged import org.wordpress.android.fluxc.store.PostStore.OnPostUploaded +import org.wordpress.android.fluxc.store.PostStore.RemotePostPayload import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.modules.UI_THREAD import org.wordpress.android.ui.posts.EditPostRepository import org.wordpress.android.ui.posts.EditPostRepository.UpdatePostResult +import org.wordpress.android.ui.posts.IPostFreshnessChecker import org.wordpress.android.ui.posts.PostUtilsWrapper import org.wordpress.android.ui.posts.SavePostToDbUseCase import org.wordpress.android.ui.posts.editor.StorePostViewModel.ActivityFinishState.SAVED_LOCALLY @@ -46,7 +50,8 @@ class StorePostViewModel private val uploadService: UploadServiceFacade, private val savePostToDbUseCase: SavePostToDbUseCase, private val networkUtils: NetworkUtilsWrapper, - private val dispatcher: Dispatcher + private val dispatcher: Dispatcher, + private val postFreshnessChecker: IPostFreshnessChecker ) : ScopedViewModel(uiCoroutineDispatcher), DialogVisibilityProvider { private var debounceCounter = 0 private var saveJob: Job? = null @@ -64,6 +69,12 @@ class StorePostViewModel } override val savingInProgressDialogVisibility: LiveData = _savingProgressDialogVisibility + private val _onPostUpdateUiVisible = MutableLiveData() + val onPostUpdateUiVisible: LiveData = _onPostUpdateUiVisible + + private val _onPostUpdateResult = MutableLiveData() + val onPostUpdateResult: LiveData = _onPostUpdateResult + init { dispatcher.register(this) } @@ -190,6 +201,21 @@ class StorePostViewModel _onFinish.postValue(Event(state)) } + fun checkIfUpdatedPostVersionExists( + editPostRepository: EditPostRepository, + site: SiteModel + ) { + editPostRepository.getPost()?.let { postModel -> + if (!postModel.isLocalDraft + && !postModel.isLocallyChanged + && postFreshnessChecker.shouldRefreshPost(postModel)) { + _onPostUpdateUiVisible.postValue(true) + val payload = RemotePostPayload(editPostRepository.getEditablePost(), site) + dispatcher.dispatch(PostActionBuilder.newFetchPostAction(payload)) + } + } + } + @Suppress("unused", "UNUSED_PARAMETER") @Subscribe fun onPostUploaded(event: OnPostUploaded) { @@ -200,6 +226,17 @@ class StorePostViewModel @Subscribe fun onPostChanged(event: OnPostChanged) { hideSavingProgressDialog() + + // Refresh post content if needed + (event.causeOfChange as? CauseOfOnPostChanged.UpdatePost)?.let { updatePost -> + // if post update is only local do nothing + if (!updatePost.isLocalUpdate) { + // Post the result based on `event.isError` + _onPostUpdateResult.postValue(!event.isError) + // Hide updating post area + _onPostUpdateUiVisible.postValue(false) + } + } } sealed class UpdateResult { diff --git a/WordPress/src/main/java/org/wordpress/android/util/config/SyncPublishingFeatureConfig.kt b/WordPress/src/main/java/org/wordpress/android/util/config/SyncPublishingFeatureConfig.kt new file mode 100644 index 000000000000..e1a4580e3b20 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/config/SyncPublishingFeatureConfig.kt @@ -0,0 +1,16 @@ +package org.wordpress.android.util.config + +import org.wordpress.android.BuildConfig +import org.wordpress.android.annotation.Feature +import javax.inject.Inject + +private const val SYNC_PUBLISHING_FEATURE_REMOTE_FIELD = "sync_publishing" + +@Feature(SYNC_PUBLISHING_FEATURE_REMOTE_FIELD, false) +class SyncPublishingFeatureConfig @Inject constructor( + appConfig: AppConfig +) : FeatureConfig( + appConfig, + BuildConfig.SYNC_PUBLISHING, + SYNC_PUBLISHING_FEATURE_REMOTE_FIELD +) diff --git a/WordPress/src/main/res/layout/new_edit_post_activity.xml b/WordPress/src/main/res/layout/new_edit_post_activity.xml index b619e8b65803..d65437835efa 100644 --- a/WordPress/src/main/res/layout/new_edit_post_activity.xml +++ b/WordPress/src/main/res/layout/new_edit_post_activity.xml @@ -65,6 +65,44 @@ tools:context=".ui.photopicker.PhotoPickerFragment" tools:visibility="visible" /> + + + + + + + + + + + + diff --git a/WordPress/src/main/res/values/colors.xml b/WordPress/src/main/res/values/colors.xml index c75fad414336..8e803301f886 100644 --- a/WordPress/src/main/res/values/colors.xml +++ b/WordPress/src/main/res/values/colors.xml @@ -158,4 +158,8 @@ #F2F2F7 #2C2C2E + + + #EDD6C5 + diff --git a/WordPress/src/main/res/values/strings.xml b/WordPress/src/main/res/values/strings.xml index 60c2fbaa9cd1..ef4354f0bdd0 100644 --- a/WordPress/src/main/res/values/strings.xml +++ b/WordPress/src/main/res/values/strings.xml @@ -1712,6 +1712,8 @@ Your draft is uploading Post converted back to draft Failed to insert media.\nPlease tap to retry. + Updating post content + Failed to update post content Post settings diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/PostFreshnessCheckerImplTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostFreshnessCheckerImplTest.kt new file mode 100644 index 000000000000..639c3312cdcd --- /dev/null +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/PostFreshnessCheckerImplTest.kt @@ -0,0 +1,39 @@ +package org.wordpress.android.ui.posts + +import org.junit.Test +import org.mockito.kotlin.mock +import org.wordpress.android.fluxc.model.PostImmutableModel +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PostFreshnessCheckerImplTest { + private val fixedCurrentTimeMillis = 100_000L // Example current time in milliseconds + private val timeProvider = object : TimeProvider { + override fun currentTimeMillis() = fixedCurrentTimeMillis + } + + private val postFreshnessChecker = PostFreshnessCheckerImpl(timeProvider) + + @Test + fun `should refresh post when post is older than cache validity`() { + // Post timestamp is set to simulate being older than the cache validity + val postTimestamp = fixedCurrentTimeMillis - PostFreshnessCheckerImpl.CACHE_VALIDITY_MILLIS - 1 + val post = mock { + on { dbTimestamp }.thenReturn(postTimestamp) + } + + // Adjust the system time or post creation time as needed to reflect the scenario being tested + assertTrue(postFreshnessChecker.shouldRefreshPost(post)) + } + + @Test + fun `should not refresh post when post is within cache validity`() { + // Post timestamp is set to simulate being within the cache validity period + val postTimestamp = fixedCurrentTimeMillis - PostFreshnessCheckerImpl.CACHE_VALIDITY_MILLIS + 1 + val post = mock { + on { dbTimestamp }.thenReturn(postTimestamp) + } + + assertFalse(postFreshnessChecker.shouldRefreshPost(post)) + } +} diff --git a/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/StorePostViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/StorePostViewModelTest.kt index c4ca4d62a19b..2d5a52692ea0 100644 --- a/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/StorePostViewModelTest.kt +++ b/WordPress/src/test/java/org/wordpress/android/ui/posts/editor/StorePostViewModelTest.kt @@ -23,6 +23,7 @@ import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.SiteStore import org.wordpress.android.ui.posts.EditPostRepository import org.wordpress.android.ui.posts.EditPostRepository.UpdatePostResult +import org.wordpress.android.ui.posts.IPostFreshnessChecker import org.wordpress.android.ui.posts.PostUtilsWrapper import org.wordpress.android.ui.posts.SavePostToDbUseCase import org.wordpress.android.ui.posts.editor.StorePostViewModel.ActivityFinishState.SAVED_LOCALLY @@ -59,6 +60,9 @@ class StorePostViewModelTest : BaseUnitTest() { @Mock lateinit var context: Context + @Mock + lateinit var postFreshnessChecker: IPostFreshnessChecker + private lateinit var viewModel: StorePostViewModel private val title = "title" private val updatedTitle = "updatedTitle" @@ -79,7 +83,8 @@ class StorePostViewModelTest : BaseUnitTest() { uploadService, savePostToDbUseCase, networkUtils, - dispatcher + dispatcher, + postFreshnessChecker ) postModel.setId(postId) postModel.setTitle(title) diff --git a/build.gradle b/build.gradle index c63662b105df..7a986807979f 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ ext { automatticTracksVersion = '3.4.0' gutenbergMobileVersion = 'v1.115.0-alpha3' wordPressAztecVersion = 'v2.0' - wordPressFluxCVersion = '2.70.0' + wordPressFluxCVersion = 'trunk-ae15f6b0b21c0ee9e0f97741ea2e16545358eac3' wordPressLoginVersion = '1.14.1' wordPressPersistentEditTextVersion = '1.0.2' wordPressUtilsVersion = '3.13.0'