diff --git a/core/catalog/README.md b/core/catalog/README.md index e0be6eac3..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). `: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 331797a24..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,18 +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()) { - handleReferrer(referrerUrl) - 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. @@ -116,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() @@ -133,6 +147,21 @@ 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. 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 in 0L..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..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,11 +2,14 @@ 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 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 +160,52 @@ 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), + ) + 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) }