Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion core/catalog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -157,4 +160,52 @@ class InstallReferrerManagerTest {
assertEquals("organic", seenChannel)
assertEquals("utm_source=google-play", seenRaw)
}

@Test
fun onlyAFreshInstallOpensItsSharedPage() {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<Context>()
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<Context>()
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)
}
Loading