Repository: wordpress-mobile/WordPress-Android (language: kotlin)

Issue:
[Reader] Implement "Tags" feed fetching
To populate the new _Tags_ feed we will need to fetch a few posts from each tags and create a map of tags -> post lists that can be used by the feed.

Some important notes/ideas:
* The fetching function/use-case could receive the list of tags to be fetched
* We will fetch a limited constant number of posts for each of the requested tags
* At first, let's try to use only what the codebase already has in terms of fetching posts from tags (without creating extra Repositories or DB Tables)


Discussion hints:
Thanks for reporting! 👍 <!-- generated_by_dangermattic -->


Pull request: [Tags IA] Fetch posts for multiple tags
Fixes #20588 

Move shared fetching logic to Repository and create ViewModel to use Repository to fetch posts for tags directly.

-----

## To Test:

- Open Jetpack
- Enable the `reader_tags_feed` feature flag in Debug Settings
- Go to Reader
- Select the `Tags` feed
- **Verify** a UI shows up that loads the tag posts for the followed tags

-----

## Regression Notes

1. Potential unintended areas of impact

    - Fetching posts in other places of the Reader

2. What I did to test those areas of impact (or what existing automated tests I relied on)

    - Manual tests
    - Unit tests of Repository
    - Unit tests of existing code

3. What automated tests I added (or what prevented me from doing so)

    - Unit tests in all new classes introduced

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

-----

## Testing Checklist (strike-out the not-applying and unnecessary ones):
N/A, the UI in this PR is completely throwaway and only there to easily test these changes.

Test command: ./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.reader.services.post.ReaderPostLogicFactoryTest" --tests "org.wordpress.android.ui.reader.viewmodels.ReaderTagsFeedViewModelTest" --tests "org.wordpress.android.ui.reader.sources.ReaderPostLocalSourceTest" --tests "org.wordpress.android.ui.bloggingprompts.BloggingPromptsPostTagProviderTest"

Fix patch:
diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/ReaderPostTableWrapper.kt b/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/ReaderPostTableWrapper.kt
index eeabbd6a0e0f..8b532688a530 100644
--- a/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/ReaderPostTableWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/datasets/wrappers/ReaderPostTableWrapper.kt
@@ -5,6 +5,8 @@ import org.wordpress.android.datasets.ReaderPostTable
 import org.wordpress.android.models.ReaderPost
 import org.wordpress.android.models.ReaderPostList
 import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResult
+import org.wordpress.android.ui.reader.models.ReaderBlogIdPostId
 import javax.inject.Inject
 
 @Reusable
@@ -30,6 +32,23 @@ class ReaderPostTableWrapper @Inject constructor() {
 
     fun getNumPostsWithTag(readerTag: ReaderTag): Int = ReaderPostTable.getNumPostsWithTag(readerTag)
 
-    fun addOrUpdatePosts(readerTag: ReaderTag, posts: ReaderPostList) =
+    fun addOrUpdatePosts(readerTag: ReaderTag?, posts: ReaderPostList) =
         ReaderPostTable.addOrUpdatePosts(readerTag, posts)
+
+    fun deletePostsWithTag(tag: ReaderTag) = ReaderPostTable.deletePostsWithTag(tag)
+
+    fun comparePosts(posts: ReaderPostList): UpdateResult = ReaderPostTable.comparePosts(posts)
+
+    fun updateBookmarkedPostPseudoId(posts: ReaderPostList) = ReaderPostTable.updateBookmarkedPostPseudoId(posts)
+
+    fun setGapMarkerForTag(blogId: Long, postId: Long, tag: ReaderTag) =
+        ReaderPostTable.setGapMarkerForTag(blogId, postId, tag)
+
+    fun removeGapMarkerForTag(tag: ReaderTag) = ReaderPostTable.removeGapMarkerForTag(tag)
+
+    fun deletePostsBeforeGapMarkerForTag(tag: ReaderTag) = ReaderPostTable.deletePostsBeforeGapMarkerForTag(tag)
+
+    fun hasOverlap(posts: ReaderPostList?, tag: ReaderTag): Boolean = ReaderPostTable.hasOverlap(posts, tag)
+
+    fun getGapMarkerIdsForTag(tag: ReaderTag): ReaderBlogIdPostId? = ReaderPostTable.getGapMarkerIdsForTag(tag)
 }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt
index 9bac2972def3..2fcff83c7a75 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt
@@ -2,7 +2,7 @@ package org.wordpress.android.ui.bloggingprompts
 
 import org.wordpress.android.models.ReaderTag
 import org.wordpress.android.models.ReaderTagType
-import org.wordpress.android.ui.reader.services.post.ReaderPostLogic
+import org.wordpress.android.ui.reader.repository.ReaderPostRepository
 import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper
 import javax.inject.Inject
 
@@ -23,7 +23,7 @@ class BloggingPromptsPostTagProvider @Inject constructor(
             promptIdTag,
             promptIdTag,
             promptIdTag,
-            ReaderPostLogic.formatFullEndpointForTag(promptIdTag),
+            ReaderPostRepository.formatFullEndpointForTag(promptIdTag),
             ReaderTagType.FOLLOWED,
         )
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
index ac2df764c1f4..d42aa5fa4797 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
@@ -448,6 +448,10 @@ class AppPrefsWrapper @Inject constructor() {
     fun getShouldHideDynamicCard(id: String, ): Boolean =
         AppPrefs.getShouldHideDynamicCard(id)
 
+    fun shouldUpdateBookmarkPostsPseudoIds(tag: ReaderTag?): Boolean = AppPrefs.shouldUpdateBookmarkPostsPseudoIds(tag)
+
+    fun setBookmarkPostsPseudoIdsUpdated() = AppPrefs.setBookmarkPostsPseudoIdsUpdated()
+
     fun getAllPrefs(): Map<String, Any?> = AppPrefs.getAllPrefs()
 
     fun setString(prefKey: PrefKey, value: String) {
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt
index 23e1b6835c44..eb8a55d29038 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt
@@ -226,14 +226,15 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableView
         }
 
         childFragmentManager.beginTransaction().apply {
-            val fragment = if (uiState.selectedReaderTag.isDiscover) {
-                ReaderDiscoverFragment()
-            } else {
-                ReaderPostListFragment.newInstanceForTag(
-                    uiState.selectedReaderTag,
+            val selectedTag = uiState.selectedReaderTag
+            val fragment = when {
+                selectedTag.isDiscover -> ReaderDiscoverFragment()
+                selectedTag.isTags -> ReaderTagsFeedFragment.newInstance(selectedTag)
+                else -> ReaderPostListFragment.newInstanceForTag(
+                    selectedTag,
                     ReaderTypes.ReaderPostListType.TAG_FOLLOWED,
                     true,
-                    uiState.selectedReaderTag.isFilterable
+                    selectedTag.isFilterable
                 )
             }
             replace(R.id.container, fragment, uiState.selectedReaderTag.tagSlug)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt
new file mode 100644
index 000000000000..c4ce39ad344c
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagsFeedFragment.kt
@@ -0,0 +1,232 @@
+package org.wordpress.android.ui.reader
+
+import android.os.Bundle
+import android.view.View
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.MaterialTheme
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.ViewModelProvider
+import dagger.hilt.android.AndroidEntryPoint
+import org.wordpress.android.R
+import org.wordpress.android.databinding.ReaderTagFeedFragmentLayoutBinding
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.ui.ViewPagerFragment
+import org.wordpress.android.ui.compose.theme.AppThemeWithoutBackground
+import org.wordpress.android.ui.main.WPMainActivity
+import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter
+import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel
+import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel.Companion.getViewModelKeyForTag
+import org.wordpress.android.ui.reader.subfilter.SubfilterListItem
+import org.wordpress.android.ui.reader.viewmodels.ReaderTagsFeedViewModel
+import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel
+import org.wordpress.android.util.NetworkUtils
+import org.wordpress.android.util.extensions.getSerializableCompat
+import javax.inject.Inject
+
+/**
+ * Initial implementation of ReaderTagsFeedFragment with the idea of it containing both a ComposeView, which will host
+ * all Compose content related to the new Tags Feed as well as an internal ReaderPostListFragment, which will be used
+ * to display "filtered" content based on the currently selected tag on the top app bar filter.
+ *
+ * It might be tricky to get this working properly since a lot of places expect the ReaderPostListFragment to be the
+ * main content of the ReaderFragment (e.g.: initializing the SubFilterViewModel), so a few changes might be needed.
+ */
+@AndroidEntryPoint
+class ReaderTagsFeedFragment : ViewPagerFragment(R.layout.reader_tag_feed_fragment_layout),
+    WPMainActivity.OnScrollToTopListener {
+    private val tagsFeedTag by lazy {
+        // TODO maybe we can just create a static function somewhere that returns the Tags Feed ReaderTag, since it's
+        //  used in multiple places, client-side only, and always the same.
+        requireArguments().getSerializableCompat<ReaderTag>(ARG_TAGS_FEED_TAG)!!
+    }
+
+    @Inject
+    lateinit var viewModelFactory: ViewModelProvider.Factory
+    private lateinit var subFilterViewModel: SubFilterViewModel
+
+    private val viewModel: ReaderTagsFeedViewModel by viewModels()
+    private val readerViewModel: ReaderViewModel by viewModels(
+        ownerProducer = { requireParentFragment() }
+    )
+
+    // binding
+    private lateinit var binding: ReaderTagFeedFragmentLayoutBinding
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        binding = ReaderTagFeedFragmentLayoutBinding.bind(view)
+
+        binding.composeView.setContent {
+            AppThemeWithoutBackground {
+                val uiState by viewModel.uiStateFlow.collectAsState()
+                ReaderTagsFeedScreen(
+                    uiState = uiState,
+                    onRetryClicked = viewModel::fetchTag,
+                )
+            }
+        }
+
+        initViewModels(savedInstanceState)
+    }
+
+    private fun initViewModels(savedInstanceState: Bundle?) {
+        subFilterViewModel = ViewModelProvider(this, viewModelFactory).get(
+            getViewModelKeyForTag(tagsFeedTag),
+            SubFilterViewModel::class.java
+        )
+        subFilterViewModel.start(tagsFeedTag, tagsFeedTag, savedInstanceState)
+
+        subFilterViewModel.updateTagsAndSites.observe(viewLifecycleOwner) { event ->
+            event.applyIfNotHandled {
+                if (NetworkUtils.isNetworkAvailable(activity)) {
+                    ReaderUpdateServiceStarter.startService(activity, this)
+                }
+            }
+        }
+
+        subFilterViewModel.subFilters.observe(viewLifecycleOwner) { subFilters ->
+            readerViewModel.showTopBarFilterGroup(
+                tagsFeedTag,
+                subFilters
+            )
+
+            val tags = subFilters.filterIsInstance<SubfilterListItem.Tag>().map { it.tag }
+            viewModel.fetchAll(tags)
+        }
+        subFilterViewModel.updateTagsAndSites()
+    }
+
+    override fun getScrollableViewForUniqueIdProvision(): View {
+        return binding.composeView
+    }
+
+    override fun onScrollToTop() {
+        // TODO scroll current content to top
+    }
+
+    companion object {
+        private const val ARG_TAGS_FEED_TAG = "tags_feed_tag"
+
+        fun newInstance(
+            feedTag: ReaderTag
+        ): ReaderTagsFeedFragment = ReaderTagsFeedFragment().apply {
+            arguments = Bundle().apply {
+                putSerializable(ARG_TAGS_FEED_TAG, feedTag)
+            }
+        }
+    }
+}
+
+/**
+ * Throwaway UI code just for testing the initial Tags Feed fetching code.
+ * TODO remove this and replace with the final Compose content.
+ */
+@Composable
+private fun ReaderTagsFeedScreen(
+    uiState: ReaderTagsFeedViewModel.UiState,
+    onRetryClicked: (ReaderTag) -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxHeight()
+            .verticalScroll(rememberScrollState())
+            .padding(16.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        uiState.tagStates.forEach { (tag, fetchState) ->
+            Column(
+                verticalArrangement = Arrangement.spacedBy(4.dp),
+            ) {
+                Text(
+                    text = tag.tagTitle,
+                    style = MaterialTheme.typography.h4,
+                )
+
+                when (fetchState) {
+                    is ReaderTagsFeedViewModel.FetchState.Loading -> {
+                        Text(
+                            text = "Loading...",
+                            style = MaterialTheme.typography.body1,
+                        )
+                    }
+
+                    is ReaderTagsFeedViewModel.FetchState.Error -> {
+                        Row(
+                            horizontalArrangement = Arrangement.spacedBy(2.dp),
+                        ) {
+                            Text(
+                                text = "Error loading posts.",
+                                style = MaterialTheme.typography.body1,
+                            )
+
+                            Text(
+                                text = "Retry",
+                                style = MaterialTheme.typography.body1,
+                                textDecoration = TextDecoration.Underline,
+                                color = MaterialTheme.colors.primary,
+                                modifier = Modifier
+                                    .padding(start = 8.dp)
+                                    .clickable { onRetryClicked(tag) },
+                            )
+                        }
+                    }
+
+                    is ReaderTagsFeedViewModel.FetchState.Success -> {
+                        Row(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .horizontalScroll(rememberScrollState()),
+                        ) {
+                            fetchState.posts.forEach { post ->
+                                Column(
+                                    modifier = Modifier
+                                        .width(300.dp)
+                                        .background(
+                                            MaterialTheme.colors.surface,
+                                            RoundedCornerShape(4.dp)
+                                        )
+                                        .padding(4.dp),
+                                    verticalArrangement = Arrangement.spacedBy(4.dp),
+                                ) {
+                                    Text(
+                                        text = post.title,
+                                        style = MaterialTheme.typography.h5,
+                                        maxLines = 2,
+                                        overflow = TextOverflow.Ellipsis,
+                                    )
+
+                                    Text(
+                                        text = post.excerpt,
+                                        style = MaterialTheme.typography.body1,
+                                        maxLines = 4,
+                                        overflow = TextOverflow.Ellipsis,
+                                    )
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/exceptions/ReaderPostFetchException.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/exceptions/ReaderPostFetchException.kt
new file mode 100644
index 000000000000..de4cc47ae7df
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/exceptions/ReaderPostFetchException.kt
@@ -0,0 +1,5 @@
+package org.wordpress.android.ui.reader.exceptions
+
+class ReaderPostFetchException(
+    message: String = "Failed to fetch post(s).",
+) : RuntimeException(message)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt
new file mode 100644
index 000000000000..9c24b917023f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/repository/ReaderPostRepository.kt
@@ -0,0 +1,250 @@
+package org.wordpress.android.ui.reader.repository
+
+import com.android.volley.VolleyError
+import com.wordpress.rest.RestRequest
+import dagger.Reusable
+import kotlinx.coroutines.suspendCancellableCoroutine
+import org.json.JSONObject
+import org.wordpress.android.WordPress.Companion.getRestClientUtilsV1_2
+import org.wordpress.android.datasets.ReaderPostTable
+import org.wordpress.android.datasets.ReaderTagTable
+import org.wordpress.android.models.ReaderPostList
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.models.ReaderTagType
+import org.wordpress.android.ui.reader.ReaderConstants
+import org.wordpress.android.ui.reader.actions.ReaderActions
+import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResultListener
+import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException
+import org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter
+import org.wordpress.android.ui.reader.sources.ReaderPostLocalSource
+import org.wordpress.android.ui.reader.utils.ReaderUtils
+import org.wordpress.android.util.AppLog
+import org.wordpress.android.util.LocaleManagerWrapper
+import org.wordpress.android.util.UrlUtils
+import java.util.Locale
+import javax.inject.Inject
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+@Reusable
+class ReaderPostRepository @Inject constructor(
+    private val localeManagerWrapper: LocaleManagerWrapper,
+    private val localSource: ReaderPostLocalSource,
+) {
+    /**
+     * Fetches and returns the most recent posts for the passed tag, respecting the maxPosts limit.
+     * It always fetches the most recent posts, saves them to the local DB and returns the latest from that cache.
+     */
+    suspend fun fetchNewerPostsForTag(tag: ReaderTag, maxPosts: Int = 7): ReaderPostList {
+        return suspendCancellableCoroutine { cont ->
+            val resultListener = UpdateResultListener { result ->
+                if (result == ReaderActions.UpdateResult.FAILED) {
+                    cont.resumeWithException(
+                        ReaderPostFetchException("Failed to fetch newer posts for tag: ${tag.tagSlug}")
+                    )
+                } else {
+                    val posts = ReaderPostTable.getPostsWithTag(tag, maxPosts, false)
+                    cont.resume(posts)
+                }
+            }
+            requestPostsWithTag(tag, ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER, resultListener)
+        }
+    }
+
+    fun requestPostsWithTag(
+        tag: ReaderTag,
+        updateAction: ReaderPostServiceStarter.UpdateAction,
+        resultListener: UpdateResultListener
+    ) {
+        val path = getRelativeEndpointForTag(tag)
+        if (path.isNullOrBlank()) {
+            resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED)
+            return
+        }
+        val sb = StringBuilder(path)
+
+        // append #posts to retrieve
+        sb.append("?number=").append(ReaderConstants.READER_MAX_POSTS_TO_REQUEST)
+
+        // return newest posts first (this is the default, but make it explicit since it's important)
+        sb.append("&order=DESC")
+
+        val beforeDate: String? = when (updateAction) {
+            ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER -> {
+                // request posts older than the oldest existing post with this tag
+                ReaderPostTable.getOldestDateWithTag(tag)
+            }
+
+            ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP -> {
+                // request posts older than the post with the gap marker for this tag
+                ReaderPostTable.getGapMarkerDateForTag(tag)
+            }
+
+            ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER,
+            ReaderPostServiceStarter.UpdateAction.REQUEST_REFRESH -> null
+        }
+
+        if (!beforeDate.isNullOrBlank()) {
+            sb.append("&before=").append(UrlUtils.urlEncode(beforeDate))
+        }
+        sb.append("&meta=site,likes")
+        sb.append("&lang=").append(localeManagerWrapper.getLanguage())
+
+        val listener = RestRequest.Listener { jsonObject: JSONObject? ->
+            // remember when this tag was updated if newer posts were requested
+            if (updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER ||
+                updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_REFRESH
+            ) {
+                ReaderTagTable.setTagLastUpdated(tag)
+            }
+            handleUpdatePostsResponse(tag, jsonObject, updateAction, resultListener)
+        }
+
+        val errorListener = RestRequest.ErrorListener { volleyError: VolleyError? ->
+            AppLog.e(AppLog.T.READER, volleyError)
+            resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED)
+        }
+
+        getRestClientUtilsV1_2().get(sb.toString(), null, null, listener, errorListener)
+    }
+
+    fun requestPostsForBlog(
+        blogId: Long,
+        updateAction: ReaderPostServiceStarter.UpdateAction,
+        resultListener: UpdateResultListener
+    ) {
+        var path = "read/sites/$blogId/posts/?meta=site,likes"
+
+        // append the date of the oldest cached post in this blog when requesting older posts
+        if (updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER) {
+            val dateOldest = ReaderPostTable.getOldestPubDateInBlog(blogId)
+            if (!dateOldest.isNullOrBlank()) {
+                path += "&before=" + UrlUtils.urlEncode(dateOldest)
+            }
+        }
+        val listener = RestRequest.Listener { jsonObject ->
+            handleUpdatePostsResponse(
+                null,
+                jsonObject,
+                updateAction,
+                resultListener
+            )
+        }
+        val errorListener = RestRequest.ErrorListener { volleyError ->
+            AppLog.e(AppLog.T.READER, volleyError)
+            resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED)
+        }
+        AppLog.d(AppLog.T.READER, "updating posts in blog $blogId")
+        getRestClientUtilsV1_2().getWithLocale(path, null, null, listener, errorListener)
+    }
+
+    fun requestPostsForFeed(
+        feedId: Long,
+        updateAction: ReaderPostServiceStarter.UpdateAction,
+        resultListener: UpdateResultListener
+    ) {
+        var path = "read/feed/$feedId/posts/?meta=site,likes"
+        if (updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER) {
+            val dateOldest = ReaderPostTable.getOldestPubDateInFeed(feedId)
+            if (!dateOldest.isNullOrBlank()) {
+                path += "&before=" + UrlUtils.urlEncode(dateOldest)
+            }
+        }
+        val listener = RestRequest.Listener { jsonObject ->
+            handleUpdatePostsResponse(
+                null,
+                jsonObject,
+                updateAction,
+                resultListener
+            )
+        }
+        val errorListener = RestRequest.ErrorListener { volleyError ->
+            AppLog.e(AppLog.T.READER, volleyError)
+            resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED)
+        }
+        AppLog.d(AppLog.T.READER, "updating posts in feed $feedId")
+        getRestClientUtilsV1_2().getWithLocale(path, null, null, listener, errorListener)
+    }
+
+    /**
+     * called after requesting posts with a specific tag or in a specific blog/feed
+     */
+    private fun handleUpdatePostsResponse(
+        tag: ReaderTag?,
+        jsonObject: JSONObject?,
+        updateAction: ReaderPostServiceStarter.UpdateAction,
+        resultListener: UpdateResultListener
+    ) {
+        if (jsonObject == null) {
+            resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED)
+            return
+        }
+
+        // this should ideally be done using coroutines, but this class is currently being used from Java, which makes
+        // it difficult to use coroutines. This should be refactored to use coroutines when possible.
+        object : Thread() {
+            override fun run() {
+                val serverPosts = ReaderPostList.fromJson(jsonObject)
+                val updateResult = localSource.saveUpdatedPosts(serverPosts, updateAction, tag)
+                resultListener.onUpdateResult(updateResult)
+            }
+        }.start()
+    }
+
+    /**
+     * returns the endpoint to use when requesting posts with the passed tag
+     */
+    private fun getRelativeEndpointForTag(tag: ReaderTag): String? {
+        val endpoint = tag.endpoint?.takeIf { it.isNotBlank() } // if passed tag has an assigned endpoint, use it
+            ?: ReaderTagTable.getEndpointForTag(tag)?.takeIf { it.isNotBlank() } // check the db for the endpoint
+
+        return endpoint
+            ?.let { getRelativeEndpoint(it) }
+            ?: if (tag.tagType == ReaderTagType.DEFAULT) {
+                // never hand craft the endpoint for default tags, since these MUST be updated using their endpoints
+                null
+            } else {
+                formatRelativeEndpointForTag(tag.tagSlug)
+            }
+    }
+
+    private fun formatRelativeEndpointForTag(tagSlug: String): String {
+        return String.format(Locale.US, "read/tags/%s/posts", ReaderUtils.sanitizeWithDashes(tagSlug))
+    }
+
+    /**
+     * returns the passed endpoint without the unnecessary path - this is
+     * needed because as of 20-Feb-2015 the /read/menu/ call returns the
+     * full path but we don't want to use the full path since it may change
+     * between API versions (as it did when we moved from v1 to v1.1)
+     *
+     * ex: https://public-api.wordpress.com/rest/v1/read/tags/fitness/posts
+     * becomes just read/tags/fitness/posts
+     */
+    @Suppress("MagicNumber")
+    private fun getRelativeEndpoint(endpoint: String): String {
+        return endpoint.takeIf { it.startsWith("http") }
+            ?.let {
+                var pos = it.indexOf("/read/")
+                if (pos > -1) {
+                    return@let it.substring(pos + 1)
+                }
+                pos = it.indexOf("/v1/")
+                if (pos > -1) {
+                    return@let it.substring(pos + 4)
+                }
+                return@let it
+            }
+            ?: endpoint
+    }
+
+    companion object {
+        private fun formatRelativeEndpointForTag(tagSlug: String): String {
+            return String.format(Locale.US, "read/tags/%s/posts", ReaderUtils.sanitizeWithDashes(tagSlug))
+        }
+
+        fun formatFullEndpointForTag(tagSlug: String): String {
+            return (getRestClientUtilsV1_2().restClient.endpointURL + formatRelativeEndpointForTag(tagSlug))
+        }
+    }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostJobService.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostJobService.java
index 70a2ed80efb8..b0cfb5d519db 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostJobService.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostJobService.java
@@ -10,12 +10,9 @@
 import org.wordpress.android.ui.reader.ReaderEvents;
 import org.wordpress.android.ui.reader.services.ServiceCompletionListener;
 import org.wordpress.android.util.AppLog;
-import org.wordpress.android.util.LocaleManagerWrapper;
 
 import javax.inject.Inject;
 
-import dagger.hilt.android.AndroidEntryPoint;
-
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_ACTION;
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_BLOG_ID;
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_FEED_ID;
@@ -26,6 +23,8 @@
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_TAG_PARAM_TITLE;
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.UpdateAction;
 
+import dagger.hilt.android.AndroidEntryPoint;
+
 /**
  * service which updates posts with specific tags or in specific blogs/feeds - relies on
  * EventBus to alert of update status
@@ -33,10 +32,9 @@
 
 @AndroidEntryPoint
 public class ReaderPostJobService extends JobService implements ServiceCompletionListener {
+    @Inject ReaderPostLogicFactory mPostLogicFactory;
     private ReaderPostLogic mReaderPostLogic;
 
-    @Inject LocaleManagerWrapper mLocaleManagerWrapper;
-
     @Override public boolean onStartJob(JobParameters params) {
         AppLog.i(AppLog.T.READER, "reader post job service > started");
         UpdateAction action;
@@ -74,7 +72,7 @@ public class ReaderPostJobService extends JobService implements ServiceCompletio
     @Override
     public void onCreate() {
         super.onCreate();
-        mReaderPostLogic = new ReaderPostLogic(this, mLocaleManagerWrapper);
+        mReaderPostLogic = mPostLogicFactory.create(this);
         AppLog.i(AppLog.T.READER, "reader post job service > created");
     }
 
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogic.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogic.java
index 212d32230fab..d3b9dd20a8dd 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogic.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogic.java
@@ -1,43 +1,26 @@
 package org.wordpress.android.ui.reader.services.post;
 
-import android.text.TextUtils;
-
 import androidx.annotation.NonNull;
 
-import com.android.volley.VolleyError;
-import com.wordpress.rest.RestRequest;
-
 import org.greenrobot.eventbus.EventBus;
-import org.json.JSONObject;
-import org.wordpress.android.WordPress;
-import org.wordpress.android.datasets.ReaderPostTable;
-import org.wordpress.android.datasets.ReaderTagTable;
-import org.wordpress.android.models.ReaderPost;
-import org.wordpress.android.models.ReaderPostList;
 import org.wordpress.android.models.ReaderTag;
-import org.wordpress.android.models.ReaderTagType;
-import org.wordpress.android.ui.prefs.AppPrefs;
-import org.wordpress.android.ui.reader.ReaderConstants;
 import org.wordpress.android.ui.reader.ReaderEvents;
 import org.wordpress.android.ui.reader.actions.ReaderActions;
-import org.wordpress.android.ui.reader.models.ReaderBlogIdPostId;
+import org.wordpress.android.ui.reader.repository.ReaderPostRepository;
 import org.wordpress.android.ui.reader.services.ServiceCompletionListener;
 import org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.UpdateAction;
-import org.wordpress.android.ui.reader.utils.ReaderUtils;
-import org.wordpress.android.util.AppLog;
-import org.wordpress.android.util.LocaleManagerWrapper;
-import org.wordpress.android.util.StringUtils;
-import org.wordpress.android.util.UrlUtils;
 
 public class ReaderPostLogic {
-    private ServiceCompletionListener mCompletionListener;
-    private final LocaleManagerWrapper mLocaleManagerWrapper;
+    @NonNull
+    private final ServiceCompletionListener mCompletionListener;
+    @NonNull
+    private final ReaderPostRepository mReaderPostRepository;
     private Object mListenerCompanion;
 
     public ReaderPostLogic(@NonNull final ServiceCompletionListener listener,
-                           @NonNull final LocaleManagerWrapper localeManagerWrapper) {
+                           @NonNull final ReaderPostRepository readerPostRepository) {
         mCompletionListener = listener;
-        mLocaleManagerWrapper = localeManagerWrapper;
+        mReaderPostRepository = readerPostRepository;
     }
 
     public void performTask(Object companion, UpdateAction action,
@@ -55,9 +38,8 @@ public void performTask(Object companion, UpdateAction action,
         }
     }
 
-
     private void updatePostsWithTag(final ReaderTag tag, final UpdateAction action) {
-        requestPostsWithTag(
+        mReaderPostRepository.requestPostsWithTag(
                 tag,
                 action,
                 new ReaderActions.UpdateResultListener() {
@@ -77,7 +59,7 @@ public void onUpdateResult(ReaderActions.UpdateResult result) {
                 mCompletionListener.onCompleted(mListenerCompanion);
             }
         };
-        requestPostsForBlog(blogId, action, listener);
+        mReaderPostRepository.requestPostsForBlog(blogId, action, listener);
     }
 
     private void updatePostsInFeed(long feedId, final UpdateAction action) {
@@ -88,261 +70,6 @@ public void onUpdateResult(ReaderActions.UpdateResult result) {
                 mCompletionListener.onCompleted(mListenerCompanion);
             }
         };
-        requestPostsForFeed(feedId, action, listener);
-    }
-
-    private void requestPostsWithTag(final ReaderTag tag,
-                                            final UpdateAction updateAction,
-                                            final ReaderActions.UpdateResultListener resultListener) {
-        String path = getRelativeEndpointForTag(tag);
-        if (TextUtils.isEmpty(path)) {
-            resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
-            return;
-        }
-
-        StringBuilder sb = new StringBuilder(path);
-
-        // append #posts to retrieve
-        sb.append("?number=").append(ReaderConstants.READER_MAX_POSTS_TO_REQUEST);
-
-        // return newest posts first (this is the default, but make it explicit since it's important)
-        sb.append("&order=DESC");
-
-        String beforeDate;
-        switch (updateAction) {
-            case REQUEST_OLDER:
-                // request posts older than the oldest existing post with this tag
-                beforeDate = ReaderPostTable.getOldestDateWithTag(tag);
-                break;
-            case REQUEST_OLDER_THAN_GAP:
-                // request posts older than the post with the gap marker for this tag
-                beforeDate = ReaderPostTable.getGapMarkerDateForTag(tag);
-                break;
-            case REQUEST_NEWER:
-            case REQUEST_REFRESH:
-            default:
-                beforeDate = null;
-                break;
-        }
-        if (!TextUtils.isEmpty(beforeDate)) {
-            sb.append("&before=").append(UrlUtils.urlEncode(beforeDate));
-        }
-
-        sb.append("&meta=site,likes");
-
-        sb.append("&lang=").append(mLocaleManagerWrapper.getLanguage());
-
-        com.wordpress.rest.RestRequest.Listener listener = jsonObject -> {
-            // remember when this tag was updated if newer posts were requested
-            if (updateAction == UpdateAction.REQUEST_NEWER || updateAction == UpdateAction.REQUEST_REFRESH) {
-                ReaderTagTable.setTagLastUpdated(tag);
-            }
-            handleUpdatePostsResponse(tag, jsonObject, updateAction, resultListener);
-        };
-        RestRequest.ErrorListener errorListener = volleyError -> {
-            AppLog.e(AppLog.T.READER, volleyError);
-            resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
-        };
-
-        WordPress.getRestClientUtilsV1_2().get(sb.toString(), null, null, listener, errorListener);
-    }
-
-    private static void requestPostsForBlog(final long blogId,
-                                            final UpdateAction updateAction,
-                                            final ReaderActions.UpdateResultListener resultListener) {
-        String path = "read/sites/" + blogId + "/posts/?meta=site,likes";
-
-        // append the date of the oldest cached post in this blog when requesting older posts
-        if (updateAction == UpdateAction.REQUEST_OLDER) {
-            String dateOldest = ReaderPostTable.getOldestPubDateInBlog(blogId);
-            if (!TextUtils.isEmpty(dateOldest)) {
-                path += "&before=" + UrlUtils.urlEncode(dateOldest);
-            }
-        }
-
-        com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
-            @Override
-            public void onResponse(JSONObject jsonObject) {
-                handleUpdatePostsResponse(null, jsonObject, updateAction, resultListener);
-            }
-        };
-        RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
-            @Override
-            public void onErrorResponse(VolleyError volleyError) {
-                AppLog.e(AppLog.T.READER, volleyError);
-                resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
-            }
-        };
-        AppLog.d(AppLog.T.READER, "updating posts in blog " + blogId);
-        WordPress.getRestClientUtilsV1_2().getWithLocale(path, null, null, listener, errorListener);
-    }
-
-    private static void requestPostsForFeed(final long feedId,
-                                            final UpdateAction updateAction,
-                                            final ReaderActions.UpdateResultListener resultListener) {
-        String path = "read/feed/" + feedId + "/posts/?meta=site,likes";
-        if (updateAction == UpdateAction.REQUEST_OLDER) {
-            String dateOldest = ReaderPostTable.getOldestPubDateInFeed(feedId);
-            if (!TextUtils.isEmpty(dateOldest)) {
-                path += "&before=" + UrlUtils.urlEncode(dateOldest);
-            }
-        }
-
-        com.wordpress.rest.RestRequest.Listener listener = new RestRequest.Listener() {
-            @Override
-            public void onResponse(JSONObject jsonObject) {
-                handleUpdatePostsResponse(null, jsonObject, updateAction, resultListener);
-            }
-        };
-        RestRequest.ErrorListener errorListener = new RestRequest.ErrorListener() {
-            @Override
-            public void onErrorResponse(VolleyError volleyError) {
-                AppLog.e(AppLog.T.READER, volleyError);
-                resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
-            }
-        };
-
-        AppLog.d(AppLog.T.READER, "updating posts in feed " + feedId);
-        WordPress.getRestClientUtilsV1_2().getWithLocale(path, null, null, listener, errorListener);
-    }
-
-    /*
-     * called after requesting posts with a specific tag or in a specific blog/feed
-     */
-    private static void handleUpdatePostsResponse(final ReaderTag tag,
-                                                  final JSONObject jsonObject,
-                                                  final UpdateAction updateAction,
-                                                  final ReaderActions.UpdateResultListener resultListener) {
-        if (jsonObject == null) {
-            resultListener.onUpdateResult(ReaderActions.UpdateResult.FAILED);
-            return;
-        }
-
-        new Thread() {
-            @Override
-            public void run() {
-                ReaderPostList serverPosts = ReaderPostList.fromJson(jsonObject);
-                ReaderActions.UpdateResult updateResult = ReaderPostTable.comparePosts(serverPosts);
-                if (updateResult.isNewOrChanged()) {
-                    // gap detection - only applies to posts with a specific tag
-                    ReaderPost postWithGap = null;
-                    if (tag != null) {
-                        switch (updateAction) {
-                            case REQUEST_NEWER:
-                                // if there's no overlap between server and local (ie: all server
-                                // posts are new), assume there's a gap between server and local
-                                // provided that local posts exist
-                                int numServerPosts = serverPosts.size();
-                                if (numServerPosts >= 2
-                                    && ReaderPostTable.getNumPostsWithTag(tag) > 0
-                                    && !ReaderPostTable.hasOverlap(serverPosts, tag)) {
-                                    // treat the second to last server post as having a gap
-                                    postWithGap = serverPosts.get(numServerPosts - 2);
-                                    // remove the last server post to deal with the edge case of
-                                    // there actually not being a gap between local & server
-                                    serverPosts.remove(numServerPosts - 1);
-                                    ReaderBlogIdPostId gapMarker = ReaderPostTable.getGapMarkerIdsForTag(tag);
-                                    if (gapMarker != null) {
-                                        // We mustn't have two gapMarkers at the same time. Therefor we need to
-                                        // delete all posts before the current gapMarker and clear the gapMarker flag.
-                                        ReaderPostTable.deletePostsBeforeGapMarkerForTag(tag);
-                                        ReaderPostTable.removeGapMarkerForTag(tag);
-                                    }
-                                }
-                                break;
-                            case REQUEST_OLDER_THAN_GAP:
-                                // if service was started as a request to fill a gap, delete existing posts
-                                // before the one with the gap marker, then remove the existing gap marker
-                                ReaderPostTable.deletePostsBeforeGapMarkerForTag(tag);
-                                ReaderPostTable.removeGapMarkerForTag(tag);
-                                break;
-                            case REQUEST_REFRESH:
-                                ReaderPostTable.deletePostsWithTag(tag);
-                                break;
-                            case REQUEST_OLDER:
-                                // no-op
-                                break;
-                        }
-                    }
-                    ReaderPostTable.addOrUpdatePosts(tag, serverPosts);
-                    if (AppPrefs.shouldUpdateBookmarkPostsPseudoIds(tag)) {
-                        ReaderPostTable.updateBookmarkedPostPseudoId(serverPosts);
-                        AppPrefs.setBookmarkPostsPseudoIdsUpdated();
-                    }
-
-                    // gap marker must be set after saving server posts
-                    if (postWithGap != null) {
-                        ReaderPostTable.setGapMarkerForTag(postWithGap.blogId, postWithGap.postId, tag);
-                        AppLog.d(AppLog.T.READER, "added gap marker to tag " + tag.getTagNameForLog());
-                    }
-                } else if (updateResult == ReaderActions.UpdateResult.UNCHANGED
-                           && updateAction == UpdateAction.REQUEST_OLDER_THAN_GAP) {
-                    // edge case - request to fill gap returned nothing new, so remove the gap marker
-                    ReaderPostTable.removeGapMarkerForTag(tag);
-                    AppLog.w(AppLog.T.READER, "attempt to fill gap returned nothing new");
-                }
-                AppLog.d(AppLog.T.READER, "requested posts response = " + updateResult.toString());
-                resultListener.onUpdateResult(updateResult);
-            }
-        }.start();
-    }
-
-    /*
-     * returns the endpoint to use when requesting posts with the passed tag
-     */
-    private static String getRelativeEndpointForTag(ReaderTag tag) {
-        if (tag == null) {
-            return null;
-        }
-
-        // if passed tag has an assigned endpoint, return it and be done
-        if (!TextUtils.isEmpty(tag.getEndpoint())) {
-            return getRelativeEndpoint(tag.getEndpoint());
-        }
-
-        // check the db for the endpoint
-        String endpoint = ReaderTagTable.getEndpointForTag(tag);
-        if (!TextUtils.isEmpty(endpoint)) {
-            return getRelativeEndpoint(endpoint);
-        }
-
-        // never hand craft the endpoint for default tags, since these MUST be updated
-        // using their stored endpoints
-        if (tag.tagType == ReaderTagType.DEFAULT) {
-            return null;
-        }
-        return formatRelativeEndpointForTag(tag.getTagSlug());
-    }
-
-    private static String formatRelativeEndpointForTag(@NonNull final String tagSlug) {
-        return String.format("read/tags/%s/posts", ReaderUtils.sanitizeWithDashes(tagSlug));
-    }
-
-    public static String formatFullEndpointForTag(@NonNull final String tagSlug) {
-        return WordPress.getRestClientUtilsV1_2().getRestClient().getEndpointURL()
-               + formatRelativeEndpointForTag(tagSlug);
-    }
-
-    /*
-     * returns the passed endpoint without the unnecessary path - this is
-     * needed because as of 20-Feb-2015 the /read/menu/ call returns the
-     * full path but we don't want to use the full path since it may change
-     * between API versions (as it did when we moved from v1 to v1.1)
-     *
-     * ex: https://public-api.wordpress.com/rest/v1/read/tags/fitness/posts
-     * becomes just read/tags/fitness/posts
-     */
-    private static String getRelativeEndpoint(final String endpoint) {
-        if (endpoint != null && endpoint.startsWith("http")) {
-            int pos = endpoint.indexOf("/read/");
-            if (pos > -1) {
-                return endpoint.substring(pos + 1, endpoint.length());
-            }
-            pos = endpoint.indexOf("/v1/");
-            if (pos > -1) {
-                return endpoint.substring(pos + 4, endpoint.length());
-            }
-        }
-        return StringUtils.notNullStr(endpoint);
+        mReaderPostRepository.requestPostsForFeed(feedId, action, listener);
     }
 }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactory.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactory.kt
new file mode 100644
index 000000000000..952ef6b31db8
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactory.kt
@@ -0,0 +1,14 @@
+package org.wordpress.android.ui.reader.services.post
+
+import org.wordpress.android.ui.reader.repository.ReaderPostRepository
+import org.wordpress.android.ui.reader.services.ServiceCompletionListener
+import javax.inject.Inject
+
+class ReaderPostLogicFactory @Inject constructor(
+    private val readerPostRepository: ReaderPostRepository,
+) {
+    fun create(listener: ServiceCompletionListener): ReaderPostLogic = ReaderPostLogic(
+        listener,
+        readerPostRepository,
+    )
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostService.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostService.java
index 5753d9ce4cb7..8312575d0124 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostService.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/post/ReaderPostService.java
@@ -9,18 +9,17 @@
 import org.wordpress.android.ui.reader.ReaderEvents;
 import org.wordpress.android.ui.reader.services.ServiceCompletionListener;
 import org.wordpress.android.util.AppLog;
-import org.wordpress.android.util.LocaleManagerWrapper;
 
 import javax.inject.Inject;
 
-import dagger.hilt.android.AndroidEntryPoint;
-
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_ACTION;
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_BLOG_ID;
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_FEED_ID;
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.ARG_TAG;
 import static org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter.UpdateAction;
 
+import dagger.hilt.android.AndroidEntryPoint;
+
 /**
  * service which updates posts with specific tags or in specific blogs/feeds - relies on
  * EventBus to alert of update status
@@ -28,10 +27,9 @@
 
 @AndroidEntryPoint
 public class ReaderPostService extends Service implements ServiceCompletionListener {
+    @Inject ReaderPostLogicFactory mPostLogicFactory;
     private ReaderPostLogic mReaderPostLogic;
 
-    @Inject LocaleManagerWrapper mLocaleManagerWrapper;
-
     @Override
     public IBinder onBind(Intent intent) {
         return null;
@@ -40,7 +38,7 @@ public IBinder onBind(Intent intent) {
     @Override
     public void onCreate() {
         super.onCreate();
-        mReaderPostLogic = new ReaderPostLogic(this, mLocaleManagerWrapper);
+        mReaderPostLogic = mPostLogicFactory.create(this);
         AppLog.i(AppLog.T.READER, "reader post service > created");
     }
 
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSource.kt
new file mode 100644
index 000000000000..b649d5fb55ec
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSource.kt
@@ -0,0 +1,127 @@
+package org.wordpress.android.ui.reader.sources
+
+import dagger.Reusable
+import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper
+import org.wordpress.android.models.ReaderPost
+import org.wordpress.android.models.ReaderPostList
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.ui.reader.actions.ReaderActions
+import org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter
+import org.wordpress.android.util.AppLog
+import javax.inject.Inject
+
+/**
+ * Manage the saving of posts to the local database table.
+ */
+@Reusable
+class ReaderPostLocalSource @Inject constructor(
+    private val readerPostTableWrapper: ReaderPostTableWrapper,
+    private val appPrefsWrapper: AppPrefsWrapper,
+) {
+    /**
+     * Save the list of posts to the local database, and handle any gaps between local and server posts.
+     *
+     * Ideally this should be a suspend function but since it's being ultimately used by Java in some scenarios we
+     * are keeping it blocking for now and it's up to the caller to run it in a coroutine or different thread.
+     */
+    fun saveUpdatedPosts(
+        serverPosts: ReaderPostList,
+        updateAction: ReaderPostServiceStarter.UpdateAction,
+        requestedTag: ReaderTag?,
+    ): ReaderActions.UpdateResult {
+        val updateResult = readerPostTableWrapper.comparePosts(serverPosts)
+        if (updateResult.isNewOrChanged) {
+            // gap detection - only applies to posts with a specific tag
+            var postWithGap: ReaderPost? = null
+            if (requestedTag != null) {
+                when (updateAction) {
+                    ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER -> {
+                        postWithGap = handleRequestNewerResult(serverPosts, requestedTag)
+                    }
+
+                    ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP -> {
+                        handleRequestOlderThanGapResult(requestedTag)
+                    }
+
+                    ReaderPostServiceStarter.UpdateAction.REQUEST_REFRESH -> readerPostTableWrapper.deletePostsWithTag(
+                        requestedTag
+                    )
+
+                    ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER -> {
+                        /* noop */
+                    }
+                }
+            }
+
+            // save posts to local db
+            readerPostTableWrapper.addOrUpdatePosts(requestedTag, serverPosts)
+
+            if (appPrefsWrapper.shouldUpdateBookmarkPostsPseudoIds(requestedTag)) {
+                readerPostTableWrapper.updateBookmarkedPostPseudoId(serverPosts)
+                appPrefsWrapper.setBookmarkPostsPseudoIdsUpdated()
+            }
+
+            // gap marker must be set after saving server posts
+            if (postWithGap != null && requestedTag != null) {
+                readerPostTableWrapper.setGapMarkerForTag(postWithGap.blogId, postWithGap.postId, requestedTag)
+                AppLog.d(AppLog.T.READER, "added gap marker to tag " + requestedTag.tagNameForLog)
+            }
+        } else if (updateResult == ReaderActions.UpdateResult.UNCHANGED
+            && updateAction == ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP
+            && requestedTag != null
+        ) {
+            // edge case - request to fill gap returned nothing new, so remove the gap marker
+            readerPostTableWrapper.removeGapMarkerForTag(requestedTag)
+            AppLog.w(AppLog.T.READER, "attempt to fill gap returned nothing new")
+        }
+        AppLog.d(
+            AppLog.T.READER,
+            "requested posts response = $updateResult"
+        )
+        return updateResult
+    }
+
+    private fun handleRequestOlderThanGapResult(requestedTag: ReaderTag) {
+        // if service was started as a request to fill a gap, delete existing posts
+        // before the one with the gap marker, then remove the existing gap marker
+        readerPostTableWrapper.deletePostsBeforeGapMarkerForTag(requestedTag)
+        readerPostTableWrapper.removeGapMarkerForTag(requestedTag)
+    }
+
+    /**
+     * Handle the result of a request for newer posts, which may include a gap between local and server posts.
+     *
+     * @return the post that has a gap, or null if there's no gap
+     */
+    private fun handleRequestNewerResult(
+        serverPosts: ReaderPostList,
+        requestedTag: ReaderTag,
+    ): ReaderPost? {
+        // if there's no overlap between server and local (ie: all server
+        // posts are new), assume there's a gap between server and local
+        // provided that local posts exist
+        var postWithGap: ReaderPost? = null
+        val numServerPosts = serverPosts.size
+        if (numServerPosts >= 2 && readerPostTableWrapper.getNumPostsWithTag(requestedTag) > 0 &&
+            !readerPostTableWrapper.hasOverlap(
+                serverPosts,
+                requestedTag
+            )
+        ) {
+            // treat the second to last server post as having a gap
+            postWithGap = serverPosts[numServerPosts - 2]
+            // remove the last server post to deal with the edge case of
+            // there actually not being a gap between local & server
+            serverPosts.removeAt(numServerPosts - 1)
+            val gapMarker = readerPostTableWrapper.getGapMarkerIdsForTag(requestedTag)
+            if (gapMarker != null) {
+                // We mustn't have two gapMarkers at the same time. Therefor we need to
+                // delete all posts before the current gapMarker and clear the gapMarker flag.
+                readerPostTableWrapper.deletePostsBeforeGapMarkerForTag(requestedTag)
+                readerPostTableWrapper.removeGapMarkerForTag(requestedTag)
+            }
+        }
+        return postWithGap
+    }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModel.kt
new file mode 100644
index 000000000000..6016df2cc87a
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModel.kt
@@ -0,0 +1,70 @@
+package org.wordpress.android.ui.reader.viewmodels
+
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import org.wordpress.android.models.ReaderPostList
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.modules.BG_THREAD
+import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException
+import org.wordpress.android.ui.reader.repository.ReaderPostRepository
+import org.wordpress.android.viewmodel.ScopedViewModel
+import javax.inject.Inject
+import javax.inject.Named
+
+@HiltViewModel
+class ReaderTagsFeedViewModel @Inject constructor(
+    @Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
+    private val readerPostRepository: ReaderPostRepository,
+) : ScopedViewModel(bgDispatcher) {
+    private val _uiStateFlow = MutableStateFlow(UiState(emptyMap()))
+    val uiStateFlow: StateFlow<UiState> = _uiStateFlow
+
+    /**
+     * Fetch multiple tag posts in parallel. Each tag load causes a new state to be emitted, so multiple emissions of
+     * [uiStateFlow] are expected when calling this method for each tag, since each can go through the following
+     * [FetchState]s: [FetchState.Loading], [FetchState.Success], [FetchState.Error].
+     */
+    fun fetchAll(tags: List<ReaderTag>) {
+        tags.forEach {
+            fetchTag(it)
+        }
+    }
+
+    /**
+     * Fetch posts for a single tag. This method will emit a new state to [uiStateFlow] for different [FetchState]s:
+     * [FetchState.Loading], [FetchState.Success], [FetchState.Error], but only for the tag being fetched.
+     *
+     * Can be used for retrying a failed fetch, for instance.
+     */
+    fun fetchTag(tag: ReaderTag) {
+        launch {
+            _uiStateFlow.update {
+                it.copy(tagStates = it.tagStates + (tag to FetchState.Loading))
+            }
+
+            try {
+                val posts = readerPostRepository.fetchNewerPostsForTag(tag)
+                _uiStateFlow.update {
+                    it.copy(tagStates = it.tagStates + (tag to FetchState.Success(posts)))
+                }
+            } catch (e: ReaderPostFetchException) {
+                _uiStateFlow.update {
+                    it.copy(tagStates = it.tagStates + (tag to FetchState.Error(e)))
+                }
+            }
+        }
+    }
+
+    data class UiState(
+        val tagStates: Map<ReaderTag, FetchState>,
+    )
+
+    sealed class FetchState {
+        data object Loading : FetchState()
+        data class Success(val posts: ReaderPostList) : FetchState()
+        data class Error(val exception: Exception) : FetchState()
+    }
+}
diff --git a/WordPress/src/main/res/layout/reader_tag_feed_fragment_layout.xml b/WordPress/src/main/res/layout/reader_tag_feed_fragment_layout.xml
new file mode 100644
index 000000000000..c2b216966121
--- /dev/null
+++ b/WordPress/src/main/res/layout/reader_tag_feed_fragment_layout.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <androidx.compose.ui.platform.ComposeView
+        android:id="@+id/compose_view"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+    <FrameLayout
+        android:id="@+id/post_list_container"
+        android:visibility="gone"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+</FrameLayout>


Test patch:
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt
index 73250ba0c7f5..5b1fc3546c74 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt
@@ -10,7 +10,7 @@ import org.mockito.kotlin.whenever
 import org.wordpress.android.BaseUnitTest
 import org.wordpress.android.models.ReaderTag
 import org.wordpress.android.models.ReaderTagType
-import org.wordpress.android.ui.reader.services.post.ReaderPostLogic
+import org.wordpress.android.ui.reader.repository.ReaderPostRepository
 import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper
 import kotlin.test.assertEquals
 
@@ -47,7 +47,7 @@ class BloggingPromptsPostTagProviderTest : BaseUnitTest() {
             BLOGGING_PROMPT_ID_TAG,
             BLOGGING_PROMPT_ID_TAG,
             BLOGGING_PROMPT_ID_TAG,
-            ReaderPostLogic.formatFullEndpointForTag(BLOGGING_PROMPT_ID_TAG),
+            ReaderPostRepository.formatFullEndpointForTag(BLOGGING_PROMPT_ID_TAG),
             ReaderTagType.FOLLOWED,
         )
         val actual = tagProvider.promptSearchReaderTag("valid-url")
@@ -61,7 +61,7 @@ class BloggingPromptsPostTagProviderTest : BaseUnitTest() {
             BLOGGING_PROMPT_TAG,
             BLOGGING_PROMPT_TAG,
             BLOGGING_PROMPT_TAG,
-            ReaderPostLogic.formatFullEndpointForTag(BLOGGING_PROMPT_TAG),
+            ReaderPostRepository.formatFullEndpointForTag(BLOGGING_PROMPT_TAG),
             ReaderTagType.FOLLOWED,
         )
         val actual = tagProvider.promptSearchReaderTag("invalid-url")
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactoryTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactoryTest.kt
new file mode 100644
index 000000000000..dea73350f024
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/services/post/ReaderPostLogicFactoryTest.kt
@@ -0,0 +1,32 @@
+package org.wordpress.android.ui.reader.services.post
+
+import org.assertj.core.api.Assertions.assertThat
+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.ui.reader.repository.ReaderPostRepository
+import org.wordpress.android.ui.reader.services.ServiceCompletionListener
+
+@RunWith(MockitoJUnitRunner::class)
+class ReaderPostLogicFactoryTest {
+    @Mock
+    lateinit var readerPostRepository: ReaderPostRepository
+
+    private lateinit var factory: ReaderPostLogicFactory
+
+    @Before
+    fun setUp() {
+        factory = ReaderPostLogicFactory(readerPostRepository)
+    }
+
+    @Test
+    fun `create should return a PostLogic instance`() {
+        val listener = ServiceCompletionListener {
+            // no-op
+        }
+        val logic = factory.create(listener)
+        assertThat(logic).isInstanceOf(ReaderPostLogic::class.java)
+    }
+}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSourceTest.kt
new file mode 100644
index 000000000000..34e555cf219e
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/sources/ReaderPostLocalSourceTest.kt
@@ -0,0 +1,327 @@
+package org.wordpress.android.ui.reader.sources
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.kotlin.any
+import org.mockito.kotlin.clearInvocations
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.only
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.datasets.wrappers.ReaderPostTableWrapper
+import org.wordpress.android.models.ReaderPostList
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.models.ReaderTagType
+import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.ui.reader.actions.ReaderActions.UpdateResult
+import org.wordpress.android.ui.reader.services.post.ReaderPostServiceStarter
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReaderPostLocalSourceTest : BaseUnitTest() {
+    @Mock
+    lateinit var readerPostTableWrapper: ReaderPostTableWrapper
+
+    @Mock
+    lateinit var appPrefsWrapper: AppPrefsWrapper
+
+    private lateinit var localSource: ReaderPostLocalSource
+
+    @Before
+    fun setUp() {
+        localSource = ReaderPostLocalSource(readerPostTableWrapper, appPrefsWrapper)
+    }
+
+    @Test
+    fun `given no changes and no tag provided, when saveUpdatedPosts, then do nothing`() {
+        // Given
+        val serverPosts = ReaderPostList()
+        val requestedTag = null
+        whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(UpdateResult.UNCHANGED)
+
+        // it doesn't matter which update action was used, so let's test all of them
+        ReaderPostServiceStarter.UpdateAction.values().forEach { updateAction ->
+            clearInvocations(readerPostTableWrapper)
+
+            // When
+            val result = localSource.saveUpdatedPosts(serverPosts, updateAction, requestedTag)
+
+            // Then
+            verify(readerPostTableWrapper, only()).comparePosts(serverPosts) // only comparePosts should be
+
+            assertThat(result).isEqualTo(UpdateResult.UNCHANGED)
+        }
+    }
+
+    @Test
+    fun `given no changes and tag provided, when saveUpdatedPosts, then do nothing`() {
+        // Given
+        val serverPosts = ReaderPostList()
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(UpdateResult.UNCHANGED)
+
+        // if the action is any but REQUEST_OLDER_THAN_GAP we should not do anything
+        ReaderPostServiceStarter.UpdateAction.values()
+            .filterNot { it == ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP }
+            .forEach { updateAction ->
+                clearInvocations(readerPostTableWrapper)
+
+                // When
+                val result = localSource.saveUpdatedPosts(serverPosts, updateAction, requestedTag)
+
+                // Then
+                verify(readerPostTableWrapper, only()).comparePosts(serverPosts) // only comparePosts should be
+
+                assertThat(result).isEqualTo(UpdateResult.UNCHANGED)
+            }
+    }
+
+    @Test
+    fun `given no changes, tag provided and OLDER_THAN_GAP, when saveUpdatedPosts, then remove gap marker`() {
+        // Given
+        val serverPosts = ReaderPostList()
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(UpdateResult.UNCHANGED)
+
+        // When
+        val result = localSource.saveUpdatedPosts(
+            serverPosts,
+            ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP,
+            requestedTag,
+        )
+
+        // Then
+        verify(readerPostTableWrapper).removeGapMarkerForTag(requestedTag)
+
+        assertThat(result).isEqualTo(UpdateResult.UNCHANGED)
+    }
+
+    @Test
+    fun `given new posts and no tag provided, when saveUpdatedPosts, then save posts`() {
+        // Given
+        val serverPosts = ReaderPostList()
+        val requestedTag = null
+        whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(UpdateResult.HAS_NEW)
+
+        // it doesn't matter which update action was used, so let's test all of them
+        ReaderPostServiceStarter.UpdateAction.values().forEach { updateAction ->
+            clearInvocations(readerPostTableWrapper)
+
+            // When
+            val result = localSource.saveUpdatedPosts(serverPosts, updateAction, requestedTag)
+
+            // Then
+            verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts)
+
+            assertThat(result).isEqualTo(UpdateResult.HAS_NEW)
+        }
+    }
+
+    @Test
+    fun `given posts changed, tag provided and OLDER_THAN_GAP, when saveUpdatedPosts, then remove gap marker`() {
+        // Given
+        val serverPosts = ReaderPostList()
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult ->
+            whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult)
+            clearInvocations(readerPostTableWrapper)
+
+            // When
+            val result = localSource.saveUpdatedPosts(
+                serverPosts,
+                ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER_THAN_GAP,
+                requestedTag,
+            )
+
+            // Then
+            verify(readerPostTableWrapper).deletePostsBeforeGapMarkerForTag(requestedTag)
+            verify(readerPostTableWrapper).removeGapMarkerForTag(requestedTag)
+            verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts)
+
+            assertThat(result).isEqualTo(updateResult)
+        }
+    }
+
+    @Test
+    fun `given posts changed, tag provided and REFRESH, when saveUpdatedPosts, then delete posts and save`() {
+        // Given
+        val serverPosts = ReaderPostList()
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult ->
+            whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult)
+            clearInvocations(readerPostTableWrapper)
+
+            // When
+            val result = localSource.saveUpdatedPosts(
+                serverPosts,
+                ReaderPostServiceStarter.UpdateAction.REQUEST_REFRESH,
+                requestedTag,
+            )
+
+            // Then
+            verify(readerPostTableWrapper).deletePostsWithTag(requestedTag)
+            verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts)
+
+            assertThat(result).isEqualTo(updateResult)
+        }
+    }
+
+    @Test
+    fun `given posts changed, tag provided and OLDER, when saveUpdatedPosts, then save`() {
+        // Given
+        val serverPosts = ReaderPostList()
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult ->
+            whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult)
+            clearInvocations(readerPostTableWrapper)
+
+
+            // When
+            val result = localSource.saveUpdatedPosts(
+                serverPosts,
+                ReaderPostServiceStarter.UpdateAction.REQUEST_OLDER,
+                requestedTag,
+            )
+
+            // Then
+            verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts)
+
+            assertThat(result).isEqualTo(updateResult)
+        }
+    }
+
+    @Test
+    fun `given posts changed, tag provided and NEWER with no gap, when saveUpdatedPosts, then save`() {
+        // Given
+        val serverPosts = ReaderPostList().apply {
+            repeat(4) { add(mock()) }
+        }
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        whenever(readerPostTableWrapper.getNumPostsWithTag(requestedTag)).thenReturn(4)
+        whenever(readerPostTableWrapper.hasOverlap(serverPosts, requestedTag)).thenReturn(true)
+
+        listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult ->
+            whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult)
+            clearInvocations(readerPostTableWrapper)
+
+            // When
+            val result = localSource.saveUpdatedPosts(
+                serverPosts,
+                ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER,
+                requestedTag,
+            )
+
+            // Then
+            verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts)
+
+            assertThat(result).isEqualTo(updateResult)
+        }
+    }
+
+    @Test
+    fun `given posts changed, tag provided and NEWER with gap, when saveUpdatedPosts, then save and set gap`() {
+        // Given
+        val serverPosts = ReaderPostList().apply {
+            repeat(4) { add(mock()) }
+        }
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        whenever(readerPostTableWrapper.getNumPostsWithTag(requestedTag)).thenReturn(4)
+        whenever(readerPostTableWrapper.hasOverlap(serverPosts, requestedTag)).thenReturn(false)
+
+        listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult ->
+            whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult)
+            clearInvocations(readerPostTableWrapper)
+
+            // When
+            val result = localSource.saveUpdatedPosts(
+                serverPosts,
+                ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER,
+                requestedTag,
+            )
+
+            // Then
+            verify(readerPostTableWrapper, never()).deletePostsBeforeGapMarkerForTag(requestedTag)
+            verify(readerPostTableWrapper, never()).removeGapMarkerForTag(requestedTag)
+            verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts)
+            verify(readerPostTableWrapper).setGapMarkerForTag(any(), any(), eq(requestedTag))
+
+            assertThat(result).isEqualTo(updateResult)
+        }
+    }
+
+    @Test
+    fun `given posts changed, tag provided and NEWER with gap, when saveUpdatedPosts, then keep 1 gap only and save`() {
+        // Given
+        val serverPosts = ReaderPostList().apply {
+            repeat(4) { add(mock()) }
+        }
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        whenever(readerPostTableWrapper.getNumPostsWithTag(requestedTag)).thenReturn(5)
+        whenever(readerPostTableWrapper.hasOverlap(serverPosts, requestedTag)).thenReturn(false)
+        whenever(readerPostTableWrapper.getGapMarkerIdsForTag(requestedTag)).thenReturn(mock())
+
+        listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult ->
+            whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult)
+            clearInvocations(readerPostTableWrapper)
+
+            // When
+            val result = localSource.saveUpdatedPosts(
+                serverPosts,
+                ReaderPostServiceStarter.UpdateAction.REQUEST_NEWER,
+                requestedTag,
+            )
+
+            // Then
+            verify(readerPostTableWrapper).deletePostsBeforeGapMarkerForTag(requestedTag)
+            verify(readerPostTableWrapper).removeGapMarkerForTag(requestedTag)
+            verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts)
+            verify(readerPostTableWrapper).setGapMarkerForTag(any(), any(), eq(requestedTag))
+
+            assertThat(result).isEqualTo(updateResult)
+        }
+    }
+
+    @Test
+    fun `given posts changed, tag provided and update bookmark, when saveUpdatedPosts, then update bookmark`() {
+        // Given
+        val serverPosts = ReaderPostList()
+        val requestedTag = ReaderTag("tag", "tag", "tag", "endpoint", ReaderTagType.FOLLOWED)
+
+        listOf(UpdateResult.CHANGED, UpdateResult.HAS_NEW).forEach { updateResult ->
+            whenever(readerPostTableWrapper.comparePosts(serverPosts)).thenReturn(updateResult)
+            whenever(appPrefsWrapper.shouldUpdateBookmarkPostsPseudoIds(requestedTag)).thenReturn(true)
+
+            // it doesn't matter which update action was used, so let's test all of them
+            ReaderPostServiceStarter.UpdateAction.values().forEach { updateAction ->
+                clearInvocations(readerPostTableWrapper, appPrefsWrapper)
+
+                // When
+                val result = localSource.saveUpdatedPosts(
+                    serverPosts,
+                    updateAction,
+                    requestedTag,
+                )
+
+                // Then
+                verify(readerPostTableWrapper).addOrUpdatePosts(requestedTag, serverPosts)
+                verify(readerPostTableWrapper).updateBookmarkedPostPseudoId(serverPosts)
+                verify(appPrefsWrapper).setBookmarkPostsPseudoIdsUpdated()
+
+                assertThat(result).isEqualTo(updateResult)
+            }
+        }
+    }
+}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt
new file mode 100644
index 000000000000..d4008189759b
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/reader/viewmodels/ReaderTagsFeedViewModelTest.kt
@@ -0,0 +1,234 @@
+package org.wordpress.android.ui.reader.viewmodels
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.kotlin.doSuspendableAnswer
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.models.ReaderPostList
+import org.wordpress.android.models.ReaderTag
+import org.wordpress.android.models.ReaderTagType
+import org.wordpress.android.ui.reader.exceptions.ReaderPostFetchException
+import org.wordpress.android.ui.reader.repository.ReaderPostRepository
+import org.wordpress.android.ui.reader.viewmodels.ReaderTagsFeedViewModel.FetchState
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ReaderTagsFeedViewModelTest : BaseUnitTest() {
+    @Mock
+    lateinit var readerPostRepository: ReaderPostRepository
+
+    private lateinit var viewModel: ReaderTagsFeedViewModel
+
+    private val collectedUiStates: MutableList<ReaderTagsFeedViewModel.UiState> = mutableListOf()
+
+    @Before
+    fun setUp() {
+        viewModel = ReaderTagsFeedViewModel(testDispatcher(), readerPostRepository)
+    }
+
+    @Test
+    fun `given valid tag, when fetchTag, then UI state should update properly`() = testCollectingUiStates {
+        // Given
+        val tag = ReaderTag(
+            "tag",
+            "tag",
+            "tag",
+            "endpoint",
+            ReaderTagType.FOLLOWED,
+        )
+        val posts = ReaderPostList()
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer {
+            delay(100)
+            posts
+        }
+
+        // When
+        viewModel.fetchTag(tag)
+        advanceUntilIdle()
+
+        // Then
+        assertThat(collectedUiStates).contains(
+            ReaderTagsFeedViewModel.UiState(
+                mapOf(
+                    tag to FetchState.Loading,
+                )
+            ),
+            ReaderTagsFeedViewModel.UiState(
+                mapOf(
+                    tag to FetchState.Success(posts),
+                )
+            ),
+        )
+    }
+
+    @Test
+    fun `given invalid tag, when fetchTag, then UI state should update properly`() = testCollectingUiStates {
+        // Given
+        val tag = ReaderTag(
+            "tag",
+            "tag",
+            "tag",
+            "endpoint",
+            ReaderTagType.FOLLOWED,
+        )
+        val error = ReaderPostFetchException("error")
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag)).doSuspendableAnswer {
+            delay(100)
+            throw error
+        }
+
+        // When
+        viewModel.fetchTag(tag)
+        advanceUntilIdle()
+
+        // Then
+        assertThat(collectedUiStates).contains(
+            ReaderTagsFeedViewModel.UiState(
+                mapOf(
+                    tag to FetchState.Loading,
+                )
+            ),
+            ReaderTagsFeedViewModel.UiState(
+                mapOf(
+                    tag to FetchState.Error(error),
+                )
+            ),
+        )
+    }
+
+    @Test
+    fun `given valid tags, when fetchAll, then UI state should update properly`() = testCollectingUiStates {
+        // Given
+        val tag1 = ReaderTag(
+            "tag1",
+            "tag1",
+            "tag1",
+            "endpoint1",
+            ReaderTagType.FOLLOWED,
+        )
+        val tag2 = ReaderTag(
+            "tag2",
+            "tag2",
+            "tag2",
+            "endpoint2",
+            ReaderTagType.FOLLOWED,
+        )
+        val posts1 = ReaderPostList()
+        val posts2 = ReaderPostList()
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
+            delay(100)
+            posts1
+        }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer {
+            delay(200)
+            posts2
+        }
+
+        // When
+        viewModel.fetchAll(listOf(tag1, tag2))
+        advanceUntilIdle()
+
+        // Then
+
+        // tag 1
+        assertThat(collectedUiStates).anyMatch {
+            it.tagStates[tag1] == FetchState.Loading
+        }
+        assertThat(collectedUiStates).anyMatch {
+            it.tagStates[tag1] == FetchState.Success(posts1)
+        }
+
+        // tag 2
+        assertThat(collectedUiStates).anyMatch {
+            it.tagStates[tag2] == FetchState.Loading
+        }
+        assertThat(collectedUiStates).anyMatch {
+            it.tagStates[tag2] == FetchState.Success(posts1)
+        }
+
+        assertThat(collectedUiStates.last()).isEqualTo(
+            ReaderTagsFeedViewModel.UiState(
+                mapOf(
+                    tag1 to FetchState.Success(posts1),
+                    tag2 to FetchState.Success(posts2),
+                )
+            )
+        )
+    }
+
+    @Test
+    fun `given valid and invalid tags, when fetchAll, then UI state should update properly`() = testCollectingUiStates {
+        // Given
+        val tag1 = ReaderTag(
+            "tag1",
+            "tag1",
+            "tag1",
+            "endpoint1",
+            ReaderTagType.FOLLOWED,
+        )
+        val tag2 = ReaderTag(
+            "tag2",
+            "tag2",
+            "tag2",
+            "endpoint2",
+            ReaderTagType.FOLLOWED,
+        )
+        val posts1 = ReaderPostList()
+        val error2 = ReaderPostFetchException("error")
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag1)).doSuspendableAnswer {
+            delay(100)
+            posts1
+        }
+        whenever(readerPostRepository.fetchNewerPostsForTag(tag2)).doSuspendableAnswer {
+            delay(200)
+            throw error2
+        }
+
+        // When
+        viewModel.fetchAll(listOf(tag1, tag2))
+        advanceUntilIdle()
+
+        // Then
+
+        // tag 1
+        assertThat(collectedUiStates).anyMatch {
+            it.tagStates[tag1] == FetchState.Loading
+        }
+        assertThat(collectedUiStates).anyMatch {
+            it.tagStates[tag1] == FetchState.Success(posts1)
+        }
+
+        // tag 2
+        assertThat(collectedUiStates).anyMatch {
+            it.tagStates[tag2] == FetchState.Loading
+        }
+        assertThat(collectedUiStates).anyMatch {
+            it.tagStates[tag2] == FetchState.Error(error2)
+        }
+
+        assertThat(collectedUiStates.last()).isEqualTo(
+            ReaderTagsFeedViewModel.UiState(
+                mapOf(
+                    tag1 to FetchState.Success(posts1),
+                    tag2 to FetchState.Error(error2),
+                )
+            )
+        )
+    }
+
+    private fun testCollectingUiStates(block: suspend TestScope.() -> Unit) = test {
+        val collectedUiStatesJob = launch {
+            collectedUiStates.clear()
+            viewModel.uiStateFlow.toList(collectedUiStates)
+        }
+        this.block()
+        collectedUiStatesJob.cancel()
+    }
+}