diff --git a/CHANGELOG.md b/CHANGELOG.md
index f4925f619c1..b20c6e2627f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,8 @@
8.17
-----
* New Features
+ * Add an optional "Rewind after interruptions" setting that jumps back a little when playback resumes after calls, alarms, navigation and other audio
+ ([#5611](https://github.com/Automattic/pocket-casts-android/pull/5611))
* Add support for Flightcast transcripts
([#5544](https://github.com/Automattic/pocket-casts-android/pull/5544))
* Stats Heatmap
diff --git a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlaybackSettingsFragment.kt b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlaybackSettingsFragment.kt
index d1d677af634..38a74c7fbd5 100644
--- a/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlaybackSettingsFragment.kt
+++ b/modules/features/settings/src/main/java/au/com/shiftyjelly/pocketcasts/settings/PlaybackSettingsFragment.kt
@@ -338,6 +338,20 @@ class PlaybackSettingsFragment : BaseFragment() {
)
}
+ SettingsItems.SETTINGS_INTERRUPTION_REWIND -> {
+ if (FeatureFlag.isEnabled(Feature.INTERRUPTION_REWIND)) {
+ InterruptionRewind(
+ saved = settings.interruptionRewindSeconds.flow.collectAsState().value,
+ onSave = { seconds ->
+ // TODO: track a SettingsGeneralInterruptionRewindChangedEvent once it is
+ // available in the Event Horizon library, it can't be added from this repo
+ // updateModifiedAt is false because this setting is device-local, not synced
+ settings.interruptionRewindSeconds.set(seconds, updateModifiedAt = false)
+ },
+ )
+ }
+ }
+
SettingsItems.SETTINGS_PLAY_UP_NEXT_EPISODE -> {
PlayUpNextOnTap(
saved = settings.tapOnUpNextShouldPlay.flow.collectAsState().value,
@@ -595,6 +609,30 @@ class PlaybackSettingsFragment : BaseFragment() {
indent = false,
)
+ @Composable
+ private fun InterruptionRewind(
+ saved: Int,
+ onSave: (Int) -> Unit,
+ ) = SettingRadioDialogRow(
+ primaryText = stringResource(LR.string.settings_interruption_rewind),
+ secondaryText = if (saved > 0) {
+ stringResource(LR.string.settings_interruption_rewind_summary, saved)
+ } else {
+ stringResource(LR.string.settings_interruption_rewind_summary_off)
+ },
+ options = listOf(0, 5, 10, 15, 30, 60),
+ savedOption = saved,
+ optionToLocalisedString = { seconds ->
+ if (seconds > 0) {
+ getString(LR.string.seconds_plural, seconds)
+ } else {
+ getString(LR.string.off)
+ }
+ },
+ onSave = onSave,
+ indent = false,
+ )
+
@Composable
private fun PlayUpNextOnTap(saved: Boolean, onSave: (Boolean) -> Unit) = SettingRow(
primaryText = stringResource(LR.string.settings_up_next_tap),
@@ -704,6 +742,7 @@ private enum class SettingsItems {
SETTINGS_OPEN_PLAYER_AUTOMATICALLY,
SETTINGS_GENERATED_CHAPTERS,
SETTINGS_INTELLIGENT_PLAYBACK,
+ SETTINGS_INTERRUPTION_REWIND,
SETTINGS_PLAY_UP_NEXT_EPISODE,
SETTINGS_ADJUST_REMAINING_TIME,
SETTINGS_GENERAL_AUTOPLAY,
diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml
index 33cd1e741ff..62dfe90c563 100644
--- a/modules/services/localization/src/main/res/values/strings.xml
+++ b/modules/services/localization/src/main/res/values/strings.xml
@@ -1601,6 +1601,9 @@
Other media actions
Intelligent playback resumption
If on, Pocket Casts will go back a little in episodes you resume so you can catch up more comfortably.
+ Rewind after interruptions
+ Go back %1$d seconds when playback is interrupted by calls, alarms, navigation and other audio.
+ Don\'t go back when playback is interrupted by calls, alarms, navigation and other audio.
Podcast Artwork
Custom for this podcast
Podcast episode grouping
diff --git a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/Settings.kt b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/Settings.kt
index 406ce5700d1..17b8a534f5d 100644
--- a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/Settings.kt
+++ b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/Settings.kt
@@ -99,6 +99,8 @@ interface Settings {
const val PREFERENCE_CHAPTERS_EXPANDED = "chaptersExpanded"
const val PREFERENCE_UPNEXT_EXPANDED = "upnextExpanded"
const val INTELLIGENT_PLAYBACK_RESUMPTION = "intelligentPlaybackResumption"
+ const val INTERRUPTION_REWIND_SECONDS = "interruptionRewindSeconds"
+ const val DEFAULT_INTERRUPTION_REWIND_SECONDS = 5
const val UP_NEXT_BADGE_MAX_COUNT = 999
const val STORAGE_ON_CUSTOM_FOLDER = "custom_folder"
@@ -510,7 +512,15 @@ interface Settings {
fun getLastPausedUUID(): String?
fun setLastPausedAt(pausedAt: Int)
fun getLastPausedAt(): Int?
+ fun setLastPauseWasInterruption(wasInterruption: Boolean)
+ fun getLastPauseWasInterruption(): Boolean
val intelligentPlaybackResumption: UserSetting
+
+ /**
+ * How many seconds to rewind when playback resumes after an audio interruption, 0 means off.
+ * Stored on device only, not synced.
+ */
+ val interruptionRewindSeconds: UserSetting
val autoAddUpNextLimit: UserSetting
val autoAddUpNextLimitBehaviour: UserSetting
fun getMaxUpNextEpisodes(): Int
diff --git a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/SettingsImpl.kt b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/SettingsImpl.kt
index f15788513ca..38632e9208a 100644
--- a/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/SettingsImpl.kt
+++ b/modules/services/preferences/src/main/java/au/com/shiftyjelly/pocketcasts/preferences/SettingsImpl.kt
@@ -949,12 +949,26 @@ class SettingsImpl @Inject constructor(
return if (lastPausedAt != 0) lastPausedAt else null
}
+ override fun setLastPauseWasInterruption(wasInterruption: Boolean) {
+ setBoolean("last_pause_was_interruption", wasInterruption)
+ }
+
+ override fun getLastPauseWasInterruption(): Boolean {
+ return getBoolean("last_pause_was_interruption", defaultValue = false)
+ }
+
override val intelligentPlaybackResumption = UserSetting.BoolPref(
sharedPrefKey = Settings.INTELLIGENT_PLAYBACK_RESUMPTION,
defaultValue = true,
sharedPrefs = sharedPreferences,
)
+ override val interruptionRewindSeconds = UserSetting.IntPref(
+ sharedPrefKey = Settings.INTERRUPTION_REWIND_SECONDS,
+ defaultValue = Settings.DEFAULT_INTERRUPTION_REWIND_SECONDS,
+ sharedPrefs = sharedPreferences,
+ )
+
private fun setDate(preference: String, date: Date?) {
val editor = sharedPreferences.edit()
if (date == null) {
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt
index 7d86f7f52ed..dcd6198d3b7 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/PlaybackManager.kt
@@ -226,6 +226,7 @@ open class PlaybackManager @Inject constructor(
private var resettingPlayer = false
private var episodeLastBufferStatus: EpisodeBufferStatus? = null
private var focusWasPlaying: Date? = null
+ private var pauseDueToInterruption = false
private var forcePlayerSwitch = false
private var updateTimerDisposable: Disposable? = null
private var bufferUpdateTimerDisposable: Disposable? = null
@@ -892,13 +893,25 @@ open class PlaybackManager @Inject constructor(
}
}
- fun pause(transientLoss: Boolean = false, sourceView: SourceView = SourceView.UNKNOWN) {
+ fun pause(
+ transientLoss: Boolean = false,
+ sourceView: SourceView = SourceView.UNKNOWN,
+ dueToInterruption: Boolean = false,
+ ) {
launch {
- pauseSuspend(transientLoss, sourceView)
+ pauseSuspend(transientLoss, sourceView, dueToInterruption)
}
}
- suspend fun pauseSuspend(transientLoss: Boolean = false, sourceView: SourceView = SourceView.UNKNOWN) {
+ suspend fun pauseSuspend(
+ transientLoss: Boolean = false,
+ sourceView: SourceView = SourceView.UNKNOWN,
+ dueToInterruption: Boolean = false,
+ ) {
+ // remembered until the player reports the pause so onPlayerPaused can tell the
+ // resumption helper whether this pause came from an audio interruption
+ pauseDueToInterruption = dueToInterruption
+
if (!transientLoss) {
focusManager.giveUpAudioFocus()
playbackStateRelay.blockingFirst().let { playbackState ->
@@ -1525,13 +1538,18 @@ open class PlaybackManager @Inject constructor(
val episode = getCurrentEpisode()
+ // consume the interruption marker so pauses that reach this callback without going
+ // through pauseSuspend (sleep timer, cast receiver, error paths) can't reuse a stale value
+ val dueToInterruption = pauseDueToInterruption
+ pauseDueToInterruption = false
+
player?.let {
val positionMs = it.getCurrentPositionMs()
if (positionMs > 0) {
updateCurrentPositionInDatabase()
episode?.let { episode ->
- resumptionHelper.paused(episode, positionMs)
+ resumptionHelper.paused(episode, positionMs, dueToInterruption = dueToInterruption)
}
}
}
@@ -1868,7 +1886,7 @@ open class PlaybackManager @Inject constructor(
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Focus lost while playing")
focusWasPlaying = Date()
- pause(transientLoss = transientLoss, sourceView = SourceView.AUTO_PAUSE)
+ pause(transientLoss = transientLoss, sourceView = SourceView.AUTO_PAUSE, dueToInterruption = true)
} else {
LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Focus lost not playing")
focusWasPlaying = null
diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelper.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelper.kt
index 885d897957c..ecb5af6766e 100644
--- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelper.kt
+++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelper.kt
@@ -2,6 +2,8 @@ package au.com.shiftyjelly.pocketcasts.repositories.playback
import au.com.shiftyjelly.pocketcasts.models.entity.BaseEpisode
import au.com.shiftyjelly.pocketcasts.preferences.Settings
+import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature
+import au.com.shiftyjelly.pocketcasts.utils.featureflag.FeatureFlag
import au.com.shiftyjelly.pocketcasts.utils.hours
import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer
import au.com.shiftyjelly.pocketcasts.utils.minutes
@@ -13,44 +15,71 @@ class ResumptionHelper(val settings: Settings) {
private var lastPauseTime: Date? = settings.getLastPauseTime()
fun adjustedStartTimeMsFor(episode: BaseEpisode): Int {
- if (!settings.intelligentPlaybackResumption.value ||
- settings.getLastPausedUUID() != episode.uuid ||
+ if (settings.getLastPausedUUID() != episode.uuid ||
(settings.getLastPausedAt() ?: 0) != episode.playedUpToMs
) {
return episode.playedUpToMs
}
- val lastPauseTime = this.lastPauseTime ?: return episode.playedUpToMs
+ // the pause length rewind and the interruption rewind can both apply to the same resume,
+ // take the larger of the two rather than stacking them
+ val rewindMs = maxOf(pauseLengthRewindMs(), interruptionRewindMs())
+ if (rewindMs <= 0) {
+ return episode.playedUpToMs
+ }
+
+ return (episode.playedUpToMs - rewindMs).toInt().coerceAtLeast(0)
+ }
+
+ fun paused(episode: BaseEpisode, atPlayedUpToMs: Int, dueToInterruption: Boolean = false) {
+ lastPauseTime = Date()
+ settings.setLastPauseTime(Date())
+ settings.setLastPausedUUID(episode.uuid)
+ settings.setLastPausedAt(atPlayedUpToMs)
+ settings.setLastPauseWasInterruption(dueToInterruption)
+ }
+
+ private fun pauseLengthRewindMs(): Long {
+ if (!settings.intelligentPlaybackResumption.value) {
+ return 0
+ }
+
+ val lastPauseTime = this.lastPauseTime ?: return 0
- val adjustedTime = when {
+ return when {
lastPauseTime.timeIntervalSinceNow() > 24.hours() -> {
LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "More than 24 hours since this episode was paused, jumping back 30 seconds")
- episode.playedUpToMs - 30.seconds()
+ 30.seconds()
}
lastPauseTime.timeIntervalSinceNow() > 1.hours() -> {
LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "More than 1 hour since this episode was paused, jumping back 15 seconds")
- episode.playedUpToMs - 15.seconds()
+ 15.seconds()
}
lastPauseTime.timeIntervalSinceNow() > 5.minutes() -> {
LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "More than 5 minutes since this episode was paused, jumping back 10 seconds")
- episode.playedUpToMs - 10.seconds()
+ 10.seconds()
}
else -> {
LogBuffer.i(LogBuffer.TAG_BACKGROUND_TASKS, "Not enough time passed since this episode was last paused, no time adjustment required")
- episode.playedUpToMs.toLong()
+ 0
}
}
-
- return adjustedTime.toInt().coerceAtLeast(0)
}
- fun paused(episode: BaseEpisode, atPlayedUpToMs: Int) {
- lastPauseTime = Date()
- settings.setLastPauseTime(Date())
- settings.setLastPausedUUID(episode.uuid)
- settings.setLastPausedAt(atPlayedUpToMs)
+ private fun interruptionRewindMs(): Long {
+ if (!FeatureFlag.isEnabled(Feature.INTERRUPTION_REWIND) || !settings.getLastPauseWasInterruption()) {
+ return 0
+ }
+
+ val rewindSeconds = settings.interruptionRewindSeconds.value
+ if (rewindSeconds <= 0) {
+ return 0
+ }
+
+ LogBuffer.i(LogBuffer.TAG_PLAYBACK, "Playback was interrupted, jumping back $rewindSeconds seconds")
+ return rewindSeconds.seconds()
}
}
diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt
index 3abfc92915b..f082aaf9f9d 100644
--- a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt
+++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/Media3SessionCallbackTest.kt
@@ -356,6 +356,7 @@ class Media3SessionCallbackTest {
verify(playbackManager).pauseSuspend(
transientLoss = any(),
sourceView = any(),
+ dueToInterruption = any(),
)
}
diff --git a/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelperTest.kt b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelperTest.kt
new file mode 100644
index 00000000000..006e95b1002
--- /dev/null
+++ b/modules/services/repositories/src/test/java/au/com/shiftyjelly/pocketcasts/repositories/playback/ResumptionHelperTest.kt
@@ -0,0 +1,206 @@
+package au.com.shiftyjelly.pocketcasts.repositories.playback
+
+import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode
+import au.com.shiftyjelly.pocketcasts.preferences.Settings
+import au.com.shiftyjelly.pocketcasts.preferences.UserSetting
+import au.com.shiftyjelly.pocketcasts.sharedtest.InMemoryFeatureFlagRule
+import au.com.shiftyjelly.pocketcasts.utils.featureflag.Feature
+import au.com.shiftyjelly.pocketcasts.utils.featureflag.FeatureFlag
+import au.com.shiftyjelly.pocketcasts.utils.hours
+import au.com.shiftyjelly.pocketcasts.utils.minutes
+import java.util.Date
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+
+class ResumptionHelperTest {
+
+ @get:Rule
+ val featureFlagRule = InMemoryFeatureFlagRule()
+
+ @Before
+ fun setUp() {
+ FeatureFlag.setEnabled(Feature.INTERRUPTION_REWIND, true)
+ }
+
+ private fun episode(playedUpToMs: Int = PLAYED_UP_TO_MS, uuid: String = EPISODE_UUID) = PodcastEpisode(
+ uuid = uuid,
+ podcastUuid = "podcast-uuid",
+ publishedDate = Date(),
+ ).apply {
+ this.playedUpToMs = playedUpToMs
+ }
+
+ private fun settingsMock(
+ lastPausedUuid: String? = EPISODE_UUID,
+ lastPausedAtMs: Int? = PLAYED_UP_TO_MS,
+ wasInterruption: Boolean = false,
+ lastPauseTime: Date? = Date(),
+ isIntelligentResumptionEnabled: Boolean = true,
+ rewindSeconds: Int = 5,
+ ): Settings {
+ val intelligentResumptionSetting = mock> {
+ on { value } doReturn isIntelligentResumptionEnabled
+ }
+ val interruptionRewindSetting = mock> {
+ on { value } doReturn rewindSeconds
+ }
+ return mock {
+ on { getLastPausedUUID() } doReturn lastPausedUuid
+ on { getLastPausedAt() } doReturn lastPausedAtMs
+ on { getLastPauseWasInterruption() } doReturn wasInterruption
+ on { getLastPauseTime() } doReturn lastPauseTime
+ on { intelligentPlaybackResumption } doReturn intelligentResumptionSetting
+ on { interruptionRewindSeconds } doReturn interruptionRewindSetting
+ }
+ }
+
+ @Test
+ fun `interruption rewind applies when the last pause was an interruption`() {
+ val settings = settingsMock(wasInterruption = true, rewindSeconds = 30)
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(PLAYED_UP_TO_MS - 30_000, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `interruption rewind does not apply to a regular pause`() {
+ val settings = settingsMock(wasInterruption = false, rewindSeconds = 30)
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `interruption rewind does not apply when turned off`() {
+ val settings = settingsMock(wasInterruption = true, rewindSeconds = 0)
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `interruption rewind does not apply when the feature flag is disabled`() {
+ FeatureFlag.setEnabled(Feature.INTERRUPTION_REWIND, false)
+ val settings = settingsMock(wasInterruption = true, rewindSeconds = 30)
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `interruption rewind applies when intelligent playback resumption is off`() {
+ val settings = settingsMock(
+ wasInterruption = true,
+ rewindSeconds = 30,
+ isIntelligentResumptionEnabled = false,
+ )
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(PLAYED_UP_TO_MS - 30_000, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `interruption rewind does not rewind past the episode start`() {
+ val settings = settingsMock(
+ wasInterruption = true,
+ rewindSeconds = 30,
+ lastPausedAtMs = 3_000,
+ )
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(0, helper.adjustedStartTimeMsFor(episode(playedUpToMs = 3_000)))
+ }
+
+ @Test
+ fun `no adjustment for a different episode than the last paused one`() {
+ val settings = settingsMock(wasInterruption = true, rewindSeconds = 30)
+ val helper = ResumptionHelper(settings)
+
+ val other = episode(uuid = "another-episode-uuid")
+
+ assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(other))
+ }
+
+ @Test
+ fun `no adjustment when the playback position moved since the last pause`() {
+ val settings = settingsMock(wasInterruption = true, rewindSeconds = 30, lastPausedAtMs = 1_000)
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `longer pause length rewind wins over a shorter interruption rewind`() {
+ val settings = settingsMock(
+ wasInterruption = true,
+ rewindSeconds = 5,
+ lastPauseTime = Date(Date().time - 25.hours()),
+ )
+ val helper = ResumptionHelper(settings)
+
+ // paused for more than 24 hours, the 30 second pause length rewind beats the 5 second interruption rewind
+ assertEquals(PLAYED_UP_TO_MS - 30_000, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `longer interruption rewind wins over a shorter pause length rewind`() {
+ val settings = settingsMock(
+ wasInterruption = true,
+ rewindSeconds = 60,
+ lastPauseTime = Date(Date().time - 6.minutes()),
+ )
+ val helper = ResumptionHelper(settings)
+
+ // paused for more than 5 minutes, the 60 second interruption rewind beats the 10 second pause length rewind
+ assertEquals(PLAYED_UP_TO_MS - 60_000, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `pause length rewind still applies to regular pauses`() {
+ val settings = settingsMock(
+ wasInterruption = false,
+ lastPauseTime = Date(Date().time - 6.minutes()),
+ )
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(PLAYED_UP_TO_MS - 10_000, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `no rewind for a short regular pause`() {
+ val settings = settingsMock(wasInterruption = false)
+ val helper = ResumptionHelper(settings)
+
+ assertEquals(PLAYED_UP_TO_MS, helper.adjustedStartTimeMsFor(episode()))
+ }
+
+ @Test
+ fun `paused persists whether the pause came from an interruption`() {
+ val settings = settingsMock()
+ val helper = ResumptionHelper(settings)
+
+ helper.paused(episode(), atPlayedUpToMs = PLAYED_UP_TO_MS, dueToInterruption = true)
+
+ verify(settings).setLastPauseWasInterruption(true)
+ }
+
+ @Test
+ fun `paused defaults to not being an interruption`() {
+ val settings = settingsMock()
+ val helper = ResumptionHelper(settings)
+
+ helper.paused(episode(), atPlayedUpToMs = PLAYED_UP_TO_MS)
+
+ verify(settings).setLastPauseWasInterruption(false)
+ }
+
+ companion object {
+ private const val EPISODE_UUID = "episode-uuid"
+ private const val PLAYED_UP_TO_MS = 600_000
+ }
+}
diff --git a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt
index d0feda7e93b..1a9b4d4c2d1 100644
--- a/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt
+++ b/modules/services/utils/src/main/java/au/com/shiftyjelly/pocketcasts/utils/featureflag/Feature.kt
@@ -369,6 +369,15 @@ enum class Feature(
hasDevToggle = true,
addedOn = LocalDate.parse("2026-07-02"),
),
+ INTERRUPTION_REWIND(
+ key = "interruption_rewind",
+ title = "Rewind after audio interruptions like calls and navigation",
+ defaultValue = isDebugOrPrototypeBuild,
+ tier = FeatureTier.Free,
+ hasFirebaseRemoteFlag = true,
+ hasDevToggle = true,
+ addedOn = LocalDate.parse("2026-07-19"),
+ ),
}
sealed class FeatureTier {