From d90a787e12d7116cef9d99eef692e6139ee5727b Mon Sep 17 00:00:00 2001 From: Aswin C Date: Wed, 28 Oct 2026 01:23:55 +0530 Subject: [PATCH 1/2] fix(catalog): open an install link only on a fresh install --- core/catalog/README.md | 2 +- .../core/catalog/InstallReferrerManager.kt | 21 ++++++++++++++++++- .../catalog/InstallReferrerManagerTest.kt | 14 +++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/core/catalog/README.md b/core/catalog/README.md index e0be6eac3..228c1a7b3 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -45,7 +45,7 @@ Failed consent writes keep activation off, and local notification flags prevent - Android Auto browse labels (`auto_*` strings) live in this module's resources and are read by `:core:playback`. `auto_app_name` is the browse root title and stays `boxlore`, matching the launcher label. - `backup.LibraryBackupFolderLogic` maps `SubscriptionFolder` to `SubscriptionFolderBackup` and restores folder definitions, memberships, and metadata. - `SharedAppDependencies` and `SharedAppDependenciesHolder` expose application-scoped instances to workers and services (including `userSyncCoordinator`). -- `InstallReferrerManager` parses Play Install Referrer deep links (with an episode link's `show` as `ReferralIntent.showId`, a canonical Podcast Index show ID below 2^52) and exposes optional `onInstallReferrerResolved` (channel + raw referrer). `:app` wires that callback into analytics; catalog must not depend on `:core:analytics`. +- `InstallReferrerManager` parses Play Install Referrer deep links (with an episode link's `show` as `ReferralIntent.showId`, a canonical Podcast Index show ID below 2^52) and exposes optional `onInstallReferrerResolved` (channel + raw referrer). It opens the shared page only for a fresh install (`isFreshInstall`: Play began the install within the last day), because Clear data resets the processed mark while Play keeps returning the original install's referrer; attribution is still reported. `:app` wires that callback into analytics; catalog must not depend on `:core:analytics`. - `RoomLocalCatalog` implements `LocalCatalogPort`; its metadata enrichment transaction updates only an existing subscribed row, so a late Podcast Info response cannot create or restore a subscription after unsubscribe. `RoomEpisodeOfflineLookup` implements `EpisodeOfflineLookupPort`. - `ports.ListeningHistoryBackupPort`, `ports.SmartDownloadSyncPort`, `ports.QueueSyncPort`, `ports.ActivePlaybackSyncPort`, and `ports.PodcastNotificationSyncPort` keep backup, download, notification topic bindings, and playback seams cleanly decoupled without introducing forbidden `:core:catalog` to `:core:playback` dependency cycles. `ListeningHistoryBackupPort.getAllHistory` may count an episode stored under several IDs once (playback projects it); library export reads `getStoredHistory`, every stored row as it is. - `sync.UserSyncCoordinator` orchestrates Boxlore Realtime Cloud Sync: batch-bounded dirty push (each push reads only its own batch of up to 100 rows, most recently played first and without descriptions, `ListeningHistoryDao.getDirtyListeningHistoryBatch`) with multi-column optimistic concurrency flag clearing, delta pull, cloud account deletion (`deleteCloudSyncData`), account switch local data purge (history, sessions, daily totals and, through `EpisodeIdentityDao.deleteAllGroupStates`, the shared like and completion settings of episodes stored under several IDs) and active playback session teardown via `activePlaybackSyncPort.stopAndClearActiveSession()`, and coordination across `SubscriptionSyncResolver` (LWW subscription and tombstone resolution, RSS ingestion), `HistorySyncResolver` (active playing episode shield, which covers every stored ID of the playing episode through its `episodeIds` lookup because progress is written to the episode's owner ID; progress LWW; independent like status LWW), and `QueueSyncResolver` (monotonic versioning, 3-way tombstone merge, head-position retention of active episode, and 5-tier metadata hydration). Results are communicated via `sync.SyncResult`, with reactive UI state modelled via `sync.CloudSyncUiStatus`. History and queue artwork never syncs player-only `.collage` links: uploads drop them, downloaded rows store null instead, and `HistorySyncResolver` lets a real address from either side replace one (`HistorySyncResolverTest` covers both). A peer's titled queue item whose cover was dropped takes this device's catalogue cover (`QueueSyncResolverTest`). diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt index 331797a24..9e4bff026 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt @@ -80,7 +80,13 @@ class InstallReferrerManager(private val context: Context,) { Log.d(TAG, "Retrieved referrer: $referrerUrl") if (!referrerUrl.isNullOrEmpty()) { - handleReferrer(referrerUrl) + // Clear data resets the processed mark but Play keeps the referrer: + // only a fresh install opens the shared page. + if (isFreshInstall(response.installBeginTimestampSeconds, System.currentTimeMillis())) { + handleReferrer(referrerUrl) + } else { + Log.d(TAG, "Install referrer is from an older install; not opening it again.") + } notifyAttribution(referrerUrl) } else { notifyAttribution(null) @@ -133,6 +139,19 @@ class InstallReferrerManager(private val context: Context,) { internal fun emitAttributionForTest(referrerUrl: String?) = notifyAttribution(referrerUrl) companion object { + /** How long after Play began the install its shared link still opens on first launch. */ + const val FRESH_INSTALL_WINDOW_MS = 24L * 60L * 60L * 1000L + + /** + * Whether the install Play began at [installBeginSeconds] (0 when Play doesn't say) is the + * one this first launch belongs to. After Clear data the app starts as if new, but Play still + * returns the original install's referrer, so a later launch would open that page again. + */ + fun isFreshInstall(installBeginSeconds: Long, nowMillis: Long): Boolean { + if (installBeginSeconds <= 0L) return true + return nowMillis - installBeginSeconds * 1000L <= FRESH_INSTALL_WINDOW_MS + } + fun deriveInstallChannel(referrerUrl: String?): String { if (referrerUrl.isNullOrBlank()) return "unknown" val decoded = diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt index 393c78db0..abbb67994 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt @@ -6,7 +6,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -157,4 +159,16 @@ class InstallReferrerManagerTest { assertEquals("organic", seenChannel) assertEquals("utm_source=google-play", seenRaw) } + + @Test + fun onlyAFreshInstallOpensItsSharedPage() { + val now = 10L * 24 * 60 * 60 * 1000 + val begunSeconds = now / 1000 - 60 + assertTrue(InstallReferrerManager.isFreshInstall(begunSeconds, now)) + assertTrue("Play didn't say when the install began", InstallReferrerManager.isFreshInstall(0L, now)) + assertFalse( + "Clear data days later never replays the install's link", + InstallReferrerManager.isFreshInstall(begunSeconds - 2 * 24 * 60 * 60, now), + ) + } } From 668c298730ddbd822e3f7420c772606a5928a35e Mon Sep 17 00:00:00 2001 From: Aswin C Date: Thu, 29 Oct 2026 19:32:41 +0530 Subject: [PATCH 2/2] fix(catalog): never treat a future install time as fresh --- core/catalog/README.md | 2 +- .../core/catalog/InstallReferrerManager.kt | 50 +++++++++++-------- .../catalog/InstallReferrerManagerTest.kt | 37 ++++++++++++++ 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/core/catalog/README.md b/core/catalog/README.md index 228c1a7b3..fe72a2112 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -45,7 +45,7 @@ Failed consent writes keep activation off, and local notification flags prevent - Android Auto browse labels (`auto_*` strings) live in this module's resources and are read by `:core:playback`. `auto_app_name` is the browse root title and stays `boxlore`, matching the launcher label. - `backup.LibraryBackupFolderLogic` maps `SubscriptionFolder` to `SubscriptionFolderBackup` and restores folder definitions, memberships, and metadata. - `SharedAppDependencies` and `SharedAppDependenciesHolder` expose application-scoped instances to workers and services (including `userSyncCoordinator`). -- `InstallReferrerManager` parses Play Install Referrer deep links (with an episode link's `show` as `ReferralIntent.showId`, a canonical Podcast Index show ID below 2^52) and exposes optional `onInstallReferrerResolved` (channel + raw referrer). It opens the shared page only for a fresh install (`isFreshInstall`: Play began the install within the last day), because Clear data resets the processed mark while Play keeps returning the original install's referrer; attribution is still reported. `:app` wires that callback into analytics; catalog must not depend on `:core:analytics`. +- `InstallReferrerManager` parses Play Install Referrer deep links (with an episode link's `show` as `ReferralIntent.showId`, a canonical Podcast Index show ID below 2^52) and exposes optional `onInstallReferrerResolved` (channel + raw referrer). It opens the shared page only for a fresh install (`isFreshInstall`: Play began the install within the last day, or Play gives no install time; a time later than the device clock is not fresh), because Clear data resets the processed mark while Play keeps returning the original install's referrer; attribution is still reported. `:app` wires that callback into analytics; catalog must not depend on `:core:analytics`. - `RoomLocalCatalog` implements `LocalCatalogPort`; its metadata enrichment transaction updates only an existing subscribed row, so a late Podcast Info response cannot create or restore a subscription after unsubscribe. `RoomEpisodeOfflineLookup` implements `EpisodeOfflineLookupPort`. - `ports.ListeningHistoryBackupPort`, `ports.SmartDownloadSyncPort`, `ports.QueueSyncPort`, `ports.ActivePlaybackSyncPort`, and `ports.PodcastNotificationSyncPort` keep backup, download, notification topic bindings, and playback seams cleanly decoupled without introducing forbidden `:core:catalog` to `:core:playback` dependency cycles. `ListeningHistoryBackupPort.getAllHistory` may count an episode stored under several IDs once (playback projects it); library export reads `getStoredHistory`, every stored row as it is. - `sync.UserSyncCoordinator` orchestrates Boxlore Realtime Cloud Sync: batch-bounded dirty push (each push reads only its own batch of up to 100 rows, most recently played first and without descriptions, `ListeningHistoryDao.getDirtyListeningHistoryBatch`) with multi-column optimistic concurrency flag clearing, delta pull, cloud account deletion (`deleteCloudSyncData`), account switch local data purge (history, sessions, daily totals and, through `EpisodeIdentityDao.deleteAllGroupStates`, the shared like and completion settings of episodes stored under several IDs) and active playback session teardown via `activePlaybackSyncPort.stopAndClearActiveSession()`, and coordination across `SubscriptionSyncResolver` (LWW subscription and tombstone resolution, RSS ingestion), `HistorySyncResolver` (active playing episode shield, which covers every stored ID of the playing episode through its `episodeIds` lookup because progress is written to the episode's owner ID; progress LWW; independent like status LWW), and `QueueSyncResolver` (monotonic versioning, 3-way tombstone merge, head-position retention of active episode, and 5-tier metadata hydration). Results are communicated via `sync.SyncResult`, with reactive UI state modelled via `sync.CloudSyncUiStatus`. History and queue artwork never syncs player-only `.collage` links: uploads drop them, downloaded rows store null instead, and `HistorySyncResolver` lets a real address from either side replace one (`HistorySyncResolverTest` covers both). A peer's titled queue item whose cover was dropped takes this device's catalogue cover (`QueueSyncResolverTest`). diff --git a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt index 9e4bff026..8a17c88c4 100644 --- a/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt +++ b/core/catalog/src/main/java/cx/aswin/boxlore/core/catalog/InstallReferrerManager.kt @@ -76,24 +76,12 @@ class InstallReferrerManager(private val context: Context,) { InstallReferrerClient.InstallReferrerResponse.OK -> { try { val response = referrerClient.installReferrer - val referrerUrl = response.installReferrer - Log.d(TAG, "Retrieved referrer: $referrerUrl") - - if (!referrerUrl.isNullOrEmpty()) { - // Clear data resets the processed mark but Play keeps the referrer: - // only a fresh install opens the shared page. - if (isFreshInstall(response.installBeginTimestampSeconds, System.currentTimeMillis())) { - handleReferrer(referrerUrl) - } else { - Log.d(TAG, "Install referrer is from an older install; not opening it again.") - } - notifyAttribution(referrerUrl) - } else { - notifyAttribution(null) - } - - // Mark as processed so we don't handle it on subsequent launches - prefs.edit().putBoolean(KEY_REFERRER_PROCESSED, true).apply() + Log.d(TAG, "Retrieved referrer: ${response.installReferrer}") + processReferrer( + response.installReferrer, + response.installBeginTimestampSeconds, + System.currentTimeMillis(), + ) } catch (e: Exception) { // Transient fetch failure: do not $set_once unknown — leave // referrer unprocessed so a later launch can resolve the channel. @@ -122,6 +110,26 @@ class InstallReferrerManager(private val context: Context,) { ) } + /** + * Handles the referrer Play returned: opens its shared page only for a fresh install, reports + * attribution either way and marks the referrer processed so later launches skip it. + */ + internal fun processReferrer(referrerUrl: String?, installBeginSeconds: Long, nowMillis: Long) { + if (!referrerUrl.isNullOrEmpty()) { + // Clear data resets the processed mark but Play keeps the referrer: + // only a fresh install opens the shared page. + if (isFreshInstall(installBeginSeconds, nowMillis)) { + handleReferrer(referrerUrl) + } else { + Log.d(TAG, "Install referrer is from an older install; not opening it again.") + } + notifyAttribution(referrerUrl) + } else { + notifyAttribution(null) + } + prefs.edit().putBoolean(KEY_REFERRER_PROCESSED, true).apply() + } + private fun endReferrerConnection(referrerClient: InstallReferrerClient) { try { referrerClient.endConnection() @@ -145,11 +153,13 @@ class InstallReferrerManager(private val context: Context,) { /** * Whether the install Play began at [installBeginSeconds] (0 when Play doesn't say) is the * one this first launch belongs to. After Clear data the app starts as if new, but Play still - * returns the original install's referrer, so a later launch would open that page again. + * returns the original install's referrer, so a later launch would open that page again. Play + * records the time on the device, so a time after [nowMillis] means the clock went back and + * the install isn't treated as fresh. */ fun isFreshInstall(installBeginSeconds: Long, nowMillis: Long): Boolean { if (installBeginSeconds <= 0L) return true - return nowMillis - installBeginSeconds * 1000L <= FRESH_INSTALL_WINDOW_MS + return nowMillis - installBeginSeconds * 1000L in 0L..FRESH_INSTALL_WINDOW_MS } fun deriveInstallChannel(referrerUrl: String?): String { diff --git a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt index abbb67994..3300d0e5e 100644 --- a/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt +++ b/core/catalog/src/test/java/cx/aswin/boxlore/core/catalog/InstallReferrerManagerTest.kt @@ -2,6 +2,7 @@ package cx.aswin.boxlore.core.catalog import android.content.Context import androidx.test.core.app.ApplicationProvider +import cx.aswin.boxlore.core.prefs.PrefsFileMigrator import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -170,5 +171,41 @@ class InstallReferrerManagerTest { "Clear data days later never replays the install's link", InstallReferrerManager.isFreshInstall(begunSeconds - 2 * 24 * 60 * 60, now), ) + assertFalse( + "A clock moved back before the install isn't a fresh install", + InstallReferrerManager.isFreshInstall(now / 1000 + 60, now), + ) + } + + @Test + fun aReplayedReferrerIsReportedButNeverOpened() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val mgr = InstallReferrerManager(context) + var seenChannel: String? = null + mgr.onInstallReferrerResolved = { channel, _ -> seenChannel = channel } + val now = 10L * 24 * 60 * 60 * 1000 + + mgr.processReferrer("type=episode&id=67890", installBeginSeconds = now / 1000 - 3 * 24 * 60 * 60, nowMillis = now) + + assertEquals("share", seenChannel) + assertTrue(mgr.referralFlow.replayCache.isEmpty()) + assertTrue(referrerProcessed(context)) + } + + @Test + fun aFreshInstallOpensItsReferrer() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + val mgr = InstallReferrerManager(context) + + mgr.processReferrer("type=episode&id=67890", installBeginSeconds = 0L, nowMillis = 1_000L) + + val intent = withTimeout(2_000) { mgr.referralFlow.first() } + assertEquals("67890", intent.id) + assertTrue(referrerProcessed(context)) } + + private fun referrerProcessed(context: Context): Boolean = + context + .getSharedPreferences(PrefsFileMigrator.Files.REFERRER, Context.MODE_PRIVATE) + .getBoolean("referrer_processed", false) }