Repository: Automattic/pocket-casts-android
Pull request #3904: Switch to version 3 of the Discover content JSON
Author: login: geekygecko
Labels: name: [Type] Feature
description: Adding a new feature.
name: [Area] Discover Tab
name: [Project] Recommendations
Created: 2025-04-17T10:27:06Z | Merged: 2025-04-17T21:52:57Z

## Description

The new recommendations are only available in version 3 of the Discover content JSON. This change switches over to the new JSON. Future pull requests will handle the new recommendations lists.

## Testing Instructions

1. Go to the Discover section 
2. ✅ Scroll down and check the "You Might Like" list title is visible
3. Go to Profile -> settings -> Beta features
4. Toggle off Recommendations
5. Restart the app
6. ✅ Check the "You Might Like" list is not visible

## Screenshots 

| Feature Flag On | Feature Flag Off |
| --- | --- |
| ![Screenshot_20250417_195350](https://github.com/user-attachments/assets/cb140e41-e630-4f0b-a4a3-e0514bd47bd2) | ![Screenshot_20250417_195420](https://github.com/user-attachments/assets/53669582-dfa4-4cbe-a229-f608004feb1d) | 

## Checklist
- [x] If this is a user-facing change, I have added an entry in CHANGELOG.md
- [x] Ensure the linter passes (`./gradlew spotlessApply` to automatically apply formatting/linting)
- [x] I have considered whether it makes sense to add tests for my changes
- [x] All strings that need to be localized are in `modules/services/localization/src/main/res/values/strings.xml`
- [x] Any jetpack compose components I added or changed are covered by compose previews
- [x] I have updated (or requested that someone edit) [the spreadsheet](https://docs.google.com/spreadsheets/d/107jqrutZhU0fVZJ19SBqxxVKbV2NWSdQC9MFYdLiAxc/edit?usp=sharing) to reflect any new or changed analytics.
 


Diff:
diff --git a/app/src/main/java/au/com/shiftyjelly/pocketcasts/PocketCastsApplication.kt b/app/src/main/java/au/com/shiftyjelly/pocketcasts/PocketCastsApplication.kt
index 7d2ad57c134..45b5dcecba1 100644
--- a/app/src/main/java/au/com/shiftyjelly/pocketcasts/PocketCastsApplication.kt
+++ b/app/src/main/java/au/com/shiftyjelly/pocketcasts/PocketCastsApplication.kt
@@ -263,7 +263,7 @@ class PocketCastsApplication : Application(), Configuration.Provider {
         userEpisodeManager.monitorUploads(applicationContext)
         downloadManager.beginMonitoringWorkManager(applicationContext)
         userManager.beginMonitoringAccountManager(playbackManager)
-        CuratedPodcastsSyncWorker.enqueuPeriodicWork(this)
+        CuratedPodcastsSyncWorker.enqueuePeriodicWork(this)
         engageSdkBridge.registerIntegration()
 
         keepPlayerWidgetsUpdated()
diff --git a/modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawler.kt b/modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawler.kt
index f40825b8876..76de8a50749 100644
--- a/modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawler.kt
+++ b/modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawler.kt
@@ -5,7 +5,7 @@ import au.com.shiftyjelly.pocketcasts.servers.BuildConfig
 import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverRow
 import au.com.shiftyjelly.pocketcasts.servers.model.ListFeed
 import au.com.shiftyjelly.pocketcasts.servers.model.ListType
-import au.com.shiftyjelly.pocketcasts.servers.server.ListWebService
+import au.com.shiftyjelly.pocketcasts.servers.server.ListRepository
 import javax.inject.Inject
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Deferred
@@ -15,15 +15,15 @@ import kotlinx.coroutines.coroutineScope
 import timber.log.Timber
 
 class CuratedPodcastsCrawler(
-    private val service: ListWebService,
+    private val listRepository: ListRepository,
     private val staticHostUrl: String,
 ) {
     @Inject constructor(
-        service: ListWebService,
-    ) : this(service, BuildConfig.SERVER_STATIC_URL)
+        listRepository: ListRepository,
+    ) : this(listRepository, BuildConfig.SERVER_STATIC_URL)
 
-    suspend fun crawl(platform: String): Result<List<CuratedPodcast>> = coroutineScope {
-        runCatching { service.getDiscoverFeed(platform) }.mapCatching { discover ->
+    suspend fun crawl(): Result<List<CuratedPodcast>> = coroutineScope {
+        runCatching { listRepository.getDiscoverFeed() }.mapCatching { discover ->
             val feeds = discover.layout
                 .filterDisplayablePodcasts()
                 .mapNotNull { row -> row.id?.let { id -> fetchFeed(id, row.source) } }
@@ -68,6 +68,6 @@ class CuratedPodcastsCrawler(
         } else {
             url
         }
-        return async { runCatching { service.getListFeed(engageUrl) } }
+        return async { runCatching { listRepository.getListFeed(engageUrl) } }
     }
 }
diff --git a/modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsSyncWorker.kt b/modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsSyncWorker.kt
index 93a279e6786..289f5f6c63a 100644
--- a/modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsSyncWorker.kt
+++ b/modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsSyncWorker.kt
@@ -24,7 +24,7 @@ class CuratedPodcastsSyncWorker @AssistedInject constructor(
 
     override suspend fun doWork(): WorkerResult {
         Timber.tag(TAG).d("Starting crawling curated podcasts")
-        return crawler.crawl("android")
+        return crawler.crawl()
             .map { manager.replaceCuratedPodcasts(it) }
             .onSuccess { Timber.tag(TAG).d("Curated podcasts crawling finished") }
             .onFailure { Timber.tag(TAG).d(it, "Failed to crawl curated podcasts") }
@@ -38,7 +38,7 @@ class CuratedPodcastsSyncWorker @AssistedInject constructor(
         private const val TAG = "CuratedCrawler"
         private const val PERIODIC_WORK_NAME = "CuratedPodcastsSyncOneOffPeriodic"
 
-        fun enqueuPeriodicWork(context: Context) {
+        fun enqueuePeriodicWork(context: Context) {
             val tag = PERIODIC_WORK_NAME
             val request = PeriodicWorkRequestBuilder<CuratedPodcastsSyncWorker>(1, TimeUnit.DAYS)
                 .addTag(tag)
diff --git a/modules/features/discover/src/test/kotlin/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawlerTest.kt b/modules/features/discover/src/test/kotlin/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawlerTest.kt
index 7551af4f5d3..1a56167fa54 100644
--- a/modules/features/discover/src/test/kotlin/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawlerTest.kt
+++ b/modules/features/discover/src/test/kotlin/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawlerTest.kt
@@ -2,6 +2,7 @@ package au.com.shiftyjelly.pocketcasts.discover.worker
 
 import au.com.shiftyjelly.pocketcasts.models.entity.CuratedPodcast
 import au.com.shiftyjelly.pocketcasts.servers.di.ServersModule
+import au.com.shiftyjelly.pocketcasts.servers.server.ListRepository
 import au.com.shiftyjelly.pocketcasts.servers.server.ListWebService
 import kotlinx.coroutines.test.runTest
 import okhttp3.mockwebserver.MockResponse
@@ -30,24 +31,15 @@ class CuratedPodcastsCrawlerTest {
             .addConverterFactory(MoshiConverterFactory.create(moshi))
             .build()
             .create<ListWebService>()
-        crawler = CuratedPodcastsCrawler(service, staticHostUrl = server.url("/static").toString())
-    }
-
-    @Test
-    fun `use specified crawling platform`() = runTest {
-        server.enqueue(MockResponse())
-
-        crawler.crawl("some-platform")
-        val request = server.takeRequest()
-
-        assertEquals("/discover/some-platform/content_v2.json", request.path)
+        val listRepository = ListRepository(listWebService = service, platform = "android")
+        crawler = CuratedPodcastsCrawler(listRepository, staticHostUrl = server.url("/static").toString())
     }
 
     @Test
     fun `crawl empty page`() = runTest {
         enqueueDiscoverPage("[]")
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(emptyList(), podcasts)
     }
@@ -91,7 +83,7 @@ class CuratedPodcastsCrawlerTest {
         """.trimMargin("|"),
         )
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(
             listOf(
@@ -173,7 +165,7 @@ class CuratedPodcastsCrawlerTest {
         """.trimMargin("|"),
         )
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(
             listOf(
@@ -242,7 +234,7 @@ class CuratedPodcastsCrawlerTest {
         """.trimMargin("|"),
         )
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(
             listOf(
@@ -262,7 +254,7 @@ class CuratedPodcastsCrawlerTest {
     fun `fail to crawl if discover page is not fetched`() = runTest {
         server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.SHUTDOWN_INPUT_AT_END))
 
-        val result = crawler.crawl("android")
+        val result = crawler.crawl()
 
         assertTrue(result.isFailure)
     }
@@ -300,7 +292,7 @@ class CuratedPodcastsCrawlerTest {
         server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.SHUTDOWN_INPUT_AT_END))
         server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.SHUTDOWN_INPUT_AT_END))
 
-        val result = crawler.crawl("android")
+        val result = crawler.crawl()
 
         assertTrue(result.isFailure)
     }
@@ -325,7 +317,7 @@ class CuratedPodcastsCrawlerTest {
         """.trimMargin("|"),
         )
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(emptyList(), podcasts)
     }
@@ -350,7 +342,7 @@ class CuratedPodcastsCrawlerTest {
         """.trimMargin("|"),
         )
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(emptyList(), podcasts)
     }
@@ -375,7 +367,7 @@ class CuratedPodcastsCrawlerTest {
         """.trimMargin("|"),
         )
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(emptyList(), podcasts)
     }
@@ -414,7 +406,7 @@ class CuratedPodcastsCrawlerTest {
         """.trimMargin("|"),
         )
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(
             listOf(
@@ -464,7 +456,7 @@ class CuratedPodcastsCrawlerTest {
         """.trimMargin("|"),
         )
 
-        val podcasts = crawler.crawl("android").getOrThrow()
+        val podcasts = crawler.crawl().getOrThrow()
 
         assertEqualsInAnyOrder(
             listOf(
diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListRepository.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListRepository.kt
index 1cb70915a43..c61642b80b6 100644
--- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListRepository.kt
+++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListRepository.kt
@@ -3,11 +3,14 @@ package au.com.shiftyjelly.pocketcasts.servers.server
 import au.com.shiftyjelly.pocketcasts.servers.model.Discover
 import au.com.shiftyjelly.pocketcasts.servers.model.DiscoverCategory
 import au.com.shiftyjelly.pocketcasts.servers.model.ListFeed
+import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature
+import au.com.shiftyjelly.pocketcasts.utils.featureflag.FeatureFlag
 
 class ListRepository(private val listWebService: ListWebService, private val platform: String) {
 
     suspend fun getDiscoverFeed(): Discover {
-        return listWebService.getDiscoverFeed(platform)
+        val version = if (FeatureFlag.isEnabled(Feature.RECOMMENDATIONS)) 3 else 2
+        return listWebService.getDiscoverFeed(platform = platform, version = version)
     }
 
     suspend fun getListFeed(url: String): ListFeed {
diff --git a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt
index 5726d7edea0..6e3c9275c46 100644
--- a/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt
+++ b/modules/services/servers/src/main/java/au/com/shiftyjelly/pocketcasts/servers/server/ListWebService.kt
@@ -8,8 +8,8 @@ import retrofit2.http.Path
 import retrofit2.http.Url
 
 interface ListWebService {
-    @GET("/discover/{platform}/content_v2.json")
-    suspend fun getDiscoverFeed(@Path("platform") platform: String): Discover
+    @GET("/discover/{platform}/content_v{version}.json")
+    suspend fun getDiscoverFeed(@Path("platform") platform: String, @Path("version") version: Int): Discover
 
     @GET
     suspend fun getListFeed(@Url url: String): ListFeed


Reviews:
user: login: geekygecko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T10:28:11Z
user: login: geekygecko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-17T10:29:08Z
user: login: MiSikora
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T13:12:41Z

Review comments:
diff_hunk: @@ -15,15 +15,15 @@ import kotlinx.coroutines.coroutineScope
 import timber.log.Timber
 
 class CuratedPodcastsCrawler(
-    private val service: ListWebService,
+    private val listRepository: ListRepository,
     private val staticHostUrl: String,
 ) {
     @Inject constructor(
-        service: ListWebService,
-    ) : this(service, BuildConfig.SERVER_STATIC_URL)
+        listRepository: ListRepository,
+    ) : this(listRepository, BuildConfig.SERVER_STATIC_URL)
 
-    suspend fun crawl(platform: String): Result<List<CuratedPodcast>> = coroutineScope {
-        runCatching { service.getDiscoverFeed(platform) }.mapCatching { discover ->
+    suspend fun crawl(): Result<List<CuratedPodcast>> = coroutineScope {
+        runCatching { listRepository.getDiscoverFeed() }.mapCatching { discover ->
path: modules/features/discover/src/main/java/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawler.kt
user: login: geekygecko
body: The discover feed now takes a version number so I switched it over to using the list repository instead of accessing the Retrofit class directly.
created_at: 2025-04-17T10:28:11Z
author_association: MEMBER
diff_hunk: @@ -30,24 +31,15 @@ class CuratedPodcastsCrawlerTest {
             .addConverterFactory(MoshiConverterFactory.create(moshi))
             .build()
             .create<ListWebService>()
-        crawler = CuratedPodcastsCrawler(service, staticHostUrl = server.url("/static").toString())
-    }
-
-    @Test
-    fun `use specified crawling platform`() = runTest {
-        server.enqueue(MockResponse())
-
-        crawler.crawl("some-platform")
-        val request = server.takeRequest()
-
-        assertEquals("/discover/some-platform/content_v2.json", request.path)
path: modules/features/discover/src/test/kotlin/au/com/shiftyjelly/pocketcasts/discover/worker/CuratedPodcastsCrawlerTest.kt
user: login: geekygecko
body: I don't believe this needs to support other platforms but please let me know if that's wrong. 
created_at: 2025-04-17T10:29:08Z
author_association: MEMBER

PR comments:
user: login: wpmobilebot
created_at: 2025-04-17T10:29:29Z
body: <!-- REUSE_ID: app-prototype-build-link -->

<details><summary>📲 You can test the changes from this Pull Request in <b>📱 Mobile</b> by scanning the QR code below to install the corresponding build.</summary>
<table>
<tr>
  <td rowspan='4' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fcdn.a8c-ci.services%2F8af446086b2a2e077ae6d4c1b8fadd0fdea615fb%2Fpocketcasts-app-prototype-build-pr3904-5d1e921.apk' width='250' height='250' /></td>
  <td><b>App Name</b></td><td> 📱 Mobile</td>
</tr>
<tr><td><b>Build Type</b></td><td>debugProd</td></tr>
<tr><td><b>Commit</b></td><td>5d1e921642c10871ec40b44be0f7ef5f892a0b20</td></tr>
<tr><td><b>Direct Download</b></td><td><a href='https://cdn.a8c-ci.services/8af446086b2a2e077ae6d4c1b8fadd0fdea615fb/pocketcasts-app-prototype-build-pr3904-5d1e921.apk'><code>pocketcasts-app-prototype-build-pr3904-5d1e921.apk</code></a></td></tr>
</table>

</details>
<details><summary>📲 You can test the changes from this Pull Request in <b>🚗 Automotive</b> by scanning the QR code below to install the corresponding build.</summary>
<table>
<tr>
  <td rowspan='4' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fcdn.a8c-ci.services%2F4ee1b6d78a0959a907f50a696d705f8c75f56c77%2Fpocketcasts-automotive-prototype-build-pr3904-5d1e921.apk' width='250' height='250' /></td>
  <td><b>App Name</b></td><td> 🚗 Automotive</td>
</tr>
<tr><td><b>Build Type</b></td><td>debugProd</td></tr>
<tr><td><b>Commit</b></td><td>5d1e921642c10871ec40b44be0f7ef5f892a0b20</td></tr>
<tr><td><b>Direct Download</b></td><td><a href='https://cdn.a8c-ci.services/4ee1b6d78a0959a907f50a696d705f8c75f56c77/pocketcasts-automotive-prototype-build-pr3904-5d1e921.apk'><code>pocketcasts-automotive-prototype-build-pr3904-5d1e921.apk</code></a></td></tr>
</table>

</details>
<details><summary>📲 You can test the changes from this Pull Request in <b>⌚ Wear</b> by scanning the QR code below to install the corresponding build.</summary>
<table>
<tr>
  <td rowspan='4' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fcdn.a8c-ci.services%2Ffed995626e931f22c8a6527d801082253889a6fc%2Fpocketcasts-wear-prototype-build-pr3904-5d1e921.apk' width='250' height='250' /></td>
  <td><b>App Name</b></td><td> ⌚ Wear</td>
</tr>
<tr><td><b>Build Type</b></td><td>debugProd</td></tr>
<tr><td><b>Commit</b></td><td>5d1e921642c10871ec40b44be0f7ef5f892a0b20</td></tr>
<tr><td><b>Direct Download</b></td><td><a href='https://cdn.a8c-ci.services/fed995626e931f22c8a6527d801082253889a6fc/pocketcasts-wear-prototype-build-pr3904-5d1e921.apk'><code>pocketcasts-wear-prototype-build-pr3904-5d1e921.apk</code></a></td></tr>
</table>

</details>

author_association: COLLABORATOR