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
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ import au.com.shiftyjelly.pocketcasts.ui.helper.FragmentHostListener
import au.com.shiftyjelly.pocketcasts.ui.helper.NavigationBarColor
import au.com.shiftyjelly.pocketcasts.ui.helper.StatusBarIconColor
import au.com.shiftyjelly.pocketcasts.ui.theme.Theme
import au.com.shiftyjelly.pocketcasts.utils.AccountEncouragement
import au.com.shiftyjelly.pocketcasts.utils.Network
import au.com.shiftyjelly.pocketcasts.utils.Util
import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature
Expand Down Expand Up @@ -229,6 +230,7 @@ import io.reactivex.disposables.CompositeDisposable
import io.reactivex.rxkotlin.addTo
import io.reactivex.rxkotlin.subscribeBy
import io.reactivex.schedulers.Schedulers
import java.time.Instant
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
Expand Down Expand Up @@ -354,6 +356,9 @@ class MainActivity :
get() = binding.bottomContainer.height - binding.bottomContainer.paddingBottom

private var bottomSheetTag: String? = null

// hasCompletedOnboarding() flips true as onboarding finishes, which would chain the modal onto the same launch.
private var launchedInitialOnboarding: Boolean = false
Comment thread
yaelirub marked this conversation as resolved.
private var pendingBottomSheetFragment: Fragment? = null

override val coroutineContext: CoroutineContext
Expand Down Expand Up @@ -480,6 +485,7 @@ class MainActivity :
val needsLoginPromptAfterRestore = settings.getNeedsLoginPromptAfterRestore()
// Only show if savedInstanceState is null in order to avoid creating onboarding activity twice.
if (showOnboarding && savedInstanceState == null) {
launchedInitialOnboarding = true
openOnboardingFlow(OnboardingFlow.InitialOnboarding)
}

Expand All @@ -489,7 +495,8 @@ class MainActivity :
if (savedInstanceState == null && needsLoginPromptAfterRestore) {
settings.setNeedsLoginPromptAfterRestore(false)
if (!showOnboarding && !isLoggedIn) {
settings.showFreeAccountEncouragement.set(false, updateModifiedAt = true)
// Anchor the clock so encourageAccountCreation() doesn't also show the modal this launch.
settings.freeAccountEncouragementLastShown.set(Instant.now(), updateModifiedAt = true)
openOnboardingFlow(OnboardingFlow.AccountEncouragement)
}
}
Expand Down Expand Up @@ -683,21 +690,38 @@ class MainActivity :
private fun encourageAccountCreation() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
val encourageAccountCreation = settings.showFreeAccountEncouragement.value
if (!encourageAccountCreation) {
if (!FeatureFlag.isEnabled(Feature.ENCOURAGE_ACCOUNT_CREATION)) {
return@repeatOnLifecycle
}
settings.showFreeAccountEncouragement.set(false, updateModifiedAt = true)

val isSignedIn = viewModel.signInState.asFlow().first().isSignedIn
if (isSignedIn) {
if (launchedInitialOnboarding) {
return@repeatOnLifecycle
}

if (Util.isTablet(this@MainActivity)) {
AccountBenefitsFragment().show(supportFragmentManager, "account_benefits_fragment")
} else {
openOnboardingFlow(OnboardingFlow.AccountEncouragement)
val isSignedIn = viewModel.signInState.asFlow().first().isSignedIn
val isEligible = !isSignedIn && settings.hasCompletedOnboarding()

val decision = AccountEncouragement.decide(
isEligible = isEligible,
lastShown = settings.freeAccountEncouragementLastShown.value,
Comment thread
yaelirub marked this conversation as resolved.
now = Instant.now(),
)
Comment thread
yaelirub marked this conversation as resolved.
when (decision) {
AccountEncouragement.Decision.Wait -> return@repeatOnLifecycle

AccountEncouragement.Decision.Show -> {
if (bottomSheetTag != null || pendingBottomSheetFragment != null) {
return@repeatOnLifecycle
}

settings.freeAccountEncouragementLastShown.set(Instant.now(), updateModifiedAt = true)

if (Util.isTablet(this@MainActivity)) {
AccountBenefitsFragment().show(supportFragmentManager, "account_benefits_fragment")
} else {
openOnboardingFlow(OnboardingFlow.AccountEncouragement)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import au.com.shiftyjelly.pocketcasts.utils.featureflag.providers.FirebaseRemote
import au.com.shiftyjelly.pocketcasts.utils.featureflag.providers.PreferencesFeatureProvider
import au.com.shiftyjelly.pocketcasts.utils.getVersionCode
import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.Instant
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
Expand Down Expand Up @@ -129,7 +130,8 @@ class AppLifecycleObserver(
// new installations default to not displaying the tooltip
settings.showPodcastsRecentlyPlayedSortOrderTooltip.set(false, updateModifiedAt = false)

settings.showFreeAccountEncouragement.set(false, updateModifiedAt = false)
// Anchor the cadence so new installs wait a full interval; upgrading users leave it null.
settings.freeAccountEncouragementLastShown.set(Instant.now(), updateModifiedAt = false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(non-blocking) This block is guarded by getMigratedVersionCode() == 0, and that value is only written by VersionMigrationsWorker (VersionMigrationsWorker.kt:207), which is enqueued asynchronously from PocketCastsApplication.setupApp(). Every process start before the worker completes re-runs handleNewInstallOrUpgrade() — the other new-install writes here are idempotent booleans, but a timestamp isn't, so the anchor slides forward on each of those starts.

In practice the worker runs within the first session, so the drift is minutes. Worth knowing, though, that "anchored at install time" is really "anchored at the last process start before migrations ran."


when (getAppPlatform()) {
// do nothing because this already defaults to true for all users on automotive
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import au.com.shiftyjelly.pocketcasts.utils.featureflag.providers.DefaultRelease
import au.com.shiftyjelly.pocketcasts.utils.featureflag.providers.FirebaseRemoteFeatureProvider
import au.com.shiftyjelly.pocketcasts.utils.featureflag.providers.PreferencesFeatureProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
Expand Down Expand Up @@ -52,7 +53,7 @@ class AppLifecycleObserverTest {

@Mock private lateinit var showPodcastsRecentlyPlayedSortOrderSetting: UserSetting<Boolean>

@Mock private lateinit var showAccountEncouragementSetting: UserSetting<Boolean>
@Mock private lateinit var freeAccountEncouragementLastShownSetting: UserSetting<Instant?>

@Mock private lateinit var autoDownloadOnFollowPodcastSetting: UserSetting<Boolean>

Expand Down Expand Up @@ -101,7 +102,7 @@ class AppLifecycleObserverTest {
whenever(settings.offersNotification).thenReturn(offerNotificationSetting)
whenever(settings.useDarkUpNextTheme).thenReturn(useUpNextDarkThemeSetting)
whenever(settings.showPodcastsRecentlyPlayedSortOrderTooltip).thenReturn(showPodcastsRecentlyPlayedSortOrderSetting)
whenever(settings.showFreeAccountEncouragement).thenReturn(showAccountEncouragementSetting)
whenever(settings.freeAccountEncouragementLastShown).thenReturn(freeAccountEncouragementLastShownSetting)

whenever(appLifecycleOwner.lifecycle).thenReturn(appLifecycle)

Expand Down Expand Up @@ -147,6 +148,7 @@ class AppLifecycleObserverTest {
verify(newFeaturesNotificationSetting).set(true, updateModifiedAt = false)
verify(offerNotificationSetting).set(true, updateModifiedAt = false)
verify(useUpNextDarkThemeSetting).set(false, updateModifiedAt = false)
verify(freeAccountEncouragementLastShownSetting).set(any(), any(), any(), any())

verify(appLifecycleAnalytics, never()).onApplicationUpgrade(any())
verify(notificationScheduler, times(1)).setupOnboardingNotifications()
Expand Down Expand Up @@ -210,6 +212,7 @@ class AppLifecycleObserverTest {
verify(autoDownloadOnFollowPodcastSetting, never()).set(any(), any(), any(), any())
verify(dailyRemindersNotificationSetting, never()).set(any(), any(), any(), any())
verify(useUpNextDarkThemeSetting, never()).set(any(), any(), any(), any())
verify(freeAccountEncouragementLastShownSetting, never()).set(any(), any(), any(), any())
verify(notificationScheduler, never()).setupOnboardingNotifications()
verify(notificationScheduler, times(1)).setupReEngagementNotification()
verify(notificationScheduler, times(1)).setupTrendingAndRecommendationsNotifications()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,9 @@ interface Settings {
val isFreeAccountProfileBannerDismissed: UserSetting<Boolean>
val isFreeAccountFiltersBannerDismissed: UserSetting<Boolean>
val isFreeAccountHistoryBannerDismissed: UserSetting<Boolean>
val showFreeAccountEncouragement: UserSetting<Boolean>

/** Cadence anchor for the recurring account-encouragement modal; `null` means the clock has never started. */
val freeAccountEncouragementLastShown: UserSetting<Instant?>
Comment thread
yaelirub marked this conversation as resolved.

val showPlaylistsOnboarding: UserSetting<Boolean>
val saveUpNextAsPlaylist: UserSetting<Boolean>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1705,9 +1705,11 @@ class SettingsImpl @Inject constructor(
sharedPrefs = sharedPreferences,
)

override val showFreeAccountEncouragement = UserSetting.BoolPref(
sharedPrefKey = "show_free_account_encouragement",
defaultValue = true,
override val freeAccountEncouragementLastShown = UserSetting.PrefFromString<Instant?>(
sharedPrefKey = "free_account_encouragement_last_shown",
defaultValue = null,
fromString = { value -> runCatching { Instant.parse(value) }.getOrNull() },
toString = { value -> value.toString() },
sharedPrefs = sharedPreferences,
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package au.com.shiftyjelly.pocketcasts.utils

import java.time.Duration
import java.time.Instant

/** Cadence logic for the recurring account-encouragement modal, kept Android-free so it can be unit-tested. */
object AccountEncouragement {
val interval: Duration = Duration.ofDays(60)

enum class Decision {
Show,
Wait,
}

/**
* Callers record [now] as the new anchor whenever this returns [Decision.Show].
* A [lastShown] in the future (backwards clock / skewed-backup restore) shows rather than suppressing indefinitely.
*/
fun decide(
isEligible: Boolean,
lastShown: Instant?,
now: Instant,
interval: Duration = this.interval,
): Decision {
if (!isEligible) return Decision.Wait
if (lastShown == null || lastShown.isAfter(now)) return Decision.Show
return if (!now.isBefore(lastShown.plus(interval))) Decision.Show else Decision.Wait
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ enum class Feature(
hasDevToggle = true,
addedOn = LocalDate.parse("2025-11-05"),
),
ENCOURAGE_ACCOUNT_CREATION(
key = "encourage_account_creation_recurring",
title = "Recurring encourage account creation modal",
defaultValue = true,
tier = FeatureTier.Free,
hasFirebaseRemoteFlag = true,
hasDevToggle = true,
addedOn = LocalDate.parse("2026-08-19"),
),
NEW_INSTALLMENT_PLAN(
key = "new_installment_plan",
title = "New Installment Plan",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package au.com.shiftyjelly.pocketcasts.utils

import java.time.Duration
import java.time.Instant
import org.junit.Assert.assertEquals
import org.junit.Test

class AccountEncouragementTest {
private val interval: Duration = Duration.ofDays(60)
private val now: Instant = Instant.ofEpochSecond(1_700_000_000)

@Test
fun `waits when not eligible`() {
val decision = AccountEncouragement.decide(
isEligible = false,
lastShown = now.minus(interval.multipliedBy(2)),
now = now,
interval = interval,
)

assertEquals(AccountEncouragement.Decision.Wait, decision)
}

@Test
fun `shows on first eligible launch`() {
val decision = AccountEncouragement.decide(
isEligible = true,
lastShown = null,
now = now,
interval = interval,
)

assertEquals(AccountEncouragement.Decision.Show, decision)
}

@Test
fun `waits before interval elapses`() {
val decision = AccountEncouragement.decide(
isEligible = true,
lastShown = now.minus(interval).plusSeconds(1),
now = now,
interval = interval,
)

assertEquals(AccountEncouragement.Decision.Wait, decision)
}

@Test
fun `shows when interval elapsed`() {
val decision = AccountEncouragement.decide(
isEligible = true,
lastShown = now.minus(interval),
now = now,
interval = interval,
)

assertEquals(AccountEncouragement.Decision.Show, decision)
}

@Test
fun `shows when interval well exceeded`() {
val decision = AccountEncouragement.decide(
isEligible = true,
lastShown = now.minus(interval.multipliedBy(3)),
now = now,
interval = interval,
)

assertEquals(AccountEncouragement.Decision.Show, decision)
}

@Test
fun `shows when anchor is in the future`() {
// A future anchor means a backwards device clock or a restored skewed backup.
val decision = AccountEncouragement.decide(
isEligible = true,
lastShown = now.plus(interval),
now = now,
interval = interval,
)

assertEquals(AccountEncouragement.Decision.Show, decision)
}

@Test
fun `default interval is 60 days`() {
assertEquals(Duration.ofDays(60), AccountEncouragement.interval)
}
}