diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsModal.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsModal.kt index 206b2b5e643..d4604bcc623 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsModal.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsModal.kt @@ -21,6 +21,7 @@ import au.com.shiftyjelly.pocketcasts.analytics.SourceView import au.com.shiftyjelly.pocketcasts.models.entity.PodcastEpisode import au.com.shiftyjelly.pocketcasts.models.type.EpisodePlayingStatus import au.com.shiftyjelly.pocketcasts.theme.TvTheme +import com.automattic.eventhorizon.EpisodeViewSourceType import java.util.Date import au.com.shiftyjelly.pocketcasts.localization.R as LR @@ -62,6 +63,9 @@ private fun ColumnScope.TvEpisodeActionsModalContent( var pendingConfirmation by remember { mutableStateOf(null) } var returnFocusLabel by remember { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } + LaunchedEffect(episode.uuid) { + actions.trackActionsShown(actionContext.episodeViewSource) + } LaunchedEffect(pendingConfirmation) { if (pendingConfirmation == null) { focusRequester.requestFocus() @@ -317,6 +321,7 @@ private object NoOpTvEpisodeActions : TvEpisodeActions { override fun archive(episode: PodcastEpisode) = Unit override fun unarchive(episode: PodcastEpisode) = Unit override fun removeFromUpNext(episode: PodcastEpisode, source: SourceView) = Unit + override fun trackActionsShown(source: EpisodeViewSourceType) = Unit } private val ContentPadding = PaddingValues(horizontal = 24.dp, vertical = 27.dp) diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsViewModel.kt index df2273a8a28..725faccc92b 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsViewModel.kt @@ -8,6 +8,9 @@ import au.com.shiftyjelly.pocketcasts.repositories.di.IoDispatcher import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager +import com.automattic.eventhorizon.EpisodeActionsShownEvent +import com.automattic.eventhorizon.EpisodeViewSourceType +import com.automattic.eventhorizon.EventHorizon import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.CancellationException @@ -16,12 +19,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import timber.log.Timber -enum class TvEpisodeActionContext(val source: SourceView) { - PodcastDetails(SourceView.PODCAST_SCREEN), - SearchResults(SourceView.SEARCH_RESULTS), - Playlist(SourceView.FILTERS), - UpNext(SourceView.UP_NEXT), - NowPlaying(SourceView.PLAYER), +enum class TvEpisodeActionContext(val source: SourceView, val episodeViewSource: EpisodeViewSourceType) { + PodcastDetails(SourceView.PODCAST_SCREEN, EpisodeViewSourceType.PodcastScreen), + SearchResults(SourceView.SEARCH_RESULTS, EpisodeViewSourceType.Search), + Playlist(SourceView.FILTERS, EpisodeViewSourceType.Filters), + UpNext(SourceView.UP_NEXT, EpisodeViewSourceType.UpNext), + NowPlaying(SourceView.PLAYER, EpisodeViewSourceType.NowPlaying), } interface TvEpisodeActions { @@ -33,6 +36,7 @@ interface TvEpisodeActions { fun archive(episode: PodcastEpisode) fun unarchive(episode: PodcastEpisode) fun removeFromUpNext(episode: PodcastEpisode, source: SourceView) + fun trackActionsShown(source: EpisodeViewSourceType) } @HiltViewModel @@ -40,6 +44,7 @@ class TvEpisodeActionsViewModel @Inject constructor( private val episodeManager: EpisodeManager, private val playbackManager: PlaybackManager, private val podcastManager: PodcastManager, + private val eventHorizon: EventHorizon, @ApplicationScope private val applicationScope: CoroutineScope, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : ViewModel(), @@ -77,6 +82,10 @@ class TvEpisodeActionsViewModel @Inject constructor( playbackManager.removeEpisode(episodeToRemove = episode, source = source) } + override fun trackActionsShown(source: EpisodeViewSourceType) { + eventHorizon.track(EpisodeActionsShownEvent(source = source)) + } + private fun launchWrite(block: suspend () -> Unit) { applicationScope.launch(ioDispatcher) { try { diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoModal.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoModal.kt index a4ec19c0532..c4aec0fe01d 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoModal.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoModal.kt @@ -59,11 +59,13 @@ import au.com.shiftyjelly.pocketcasts.localization.R as LR @Composable fun TvEpisodeInfoModal( episode: PodcastEpisode, + actionContext: TvEpisodeActionContext, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, viewModel: TvEpisodeInfoViewModel = hiltViewModel(), ) { LaunchedEffect(episode.uuid) { + viewModel.trackDetailShown(actionContext.episodeViewSource) viewModel.load(episode.podcastUuid, episode.uuid) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoViewModel.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoViewModel.kt index fe673f05dcc..ab567631b15 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoViewModel.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.shownotes.ShowNotesManager import au.com.shiftyjelly.pocketcasts.servers.shownotes.ShowNotesState +import com.automattic.eventhorizon.EpisodeDetailShownEvent +import com.automattic.eventhorizon.EpisodeViewSourceType +import com.automattic.eventhorizon.EventHorizon import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -17,12 +20,17 @@ import kotlinx.coroutines.launch class TvEpisodeInfoViewModel @Inject constructor( private val podcastManager: PodcastManager, private val showNotesManager: ShowNotesManager, + private val eventHorizon: EventHorizon, ) : ViewModel() { private val _uiState = MutableStateFlow(null) val uiState: StateFlow = _uiState.asStateFlow() private var loadedEpisodeUuid: String? = null + fun trackDetailShown(source: EpisodeViewSourceType) { + eventHorizon.track(EpisodeDetailShownEvent(source = source)) + } + fun load(podcastUuid: String, episodeUuid: String) { if (episodeUuid == loadedEpisodeUuid) { return diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/nowplaying/TvNowPlayingScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/nowplaying/TvNowPlayingScreen.kt index 15db1000c8e..11db338bad9 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/nowplaying/TvNowPlayingScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/nowplaying/TvNowPlayingScreen.kt @@ -356,6 +356,7 @@ private fun TvNowPlayingContent( if (isDetailsModalVisible) { TvEpisodeInfoModal( episode = episode, + actionContext = TvEpisodeActionContext.NowPlaying, onDismissRequest = { isDetailsModalVisible = false }, ) } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/playlists/details/TvPlaylistDetailsScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/playlists/details/TvPlaylistDetailsScreen.kt index 9228783e545..db7866c6a87 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/playlists/details/TvPlaylistDetailsScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/playlists/details/TvPlaylistDetailsScreen.kt @@ -334,6 +334,7 @@ private fun EpisodeList( detailsEpisode?.let { episode -> TvEpisodeInfoModal( episode = episode, + actionContext = TvEpisodeActionContext.Playlist, onDismissRequest = { detailsEpisode = null }, ) } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/TvPodcastDetailsScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/TvPodcastDetailsScreen.kt index 17a7a402654..342b871e569 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/TvPodcastDetailsScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/podcasts/TvPodcastDetailsScreen.kt @@ -342,6 +342,7 @@ private fun EpisodeList( detailsEpisode?.let { episode -> TvEpisodeInfoModal( episode = episode, + actionContext = TvEpisodeActionContext.PodcastDetails, onDismissRequest = { detailsEpisode = null }, ) } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt index 1d236d3cb00..fb23542ddb1 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/search/TvSearchScreen.kt @@ -167,6 +167,7 @@ fun TvSearchScreen( detailsEpisode?.let { episode -> TvEpisodeInfoModal( episode = episode, + actionContext = TvEpisodeActionContext.SearchResults, onDismissRequest = { detailsEpisode = null }, ) } diff --git a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/upnext/TvUpNextScreen.kt b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/upnext/TvUpNextScreen.kt index 5254697bd1f..0b85b9d6181 100644 --- a/tv/src/main/java/au/com/shiftyjelly/pocketcasts/upnext/TvUpNextScreen.kt +++ b/tv/src/main/java/au/com/shiftyjelly/pocketcasts/upnext/TvUpNextScreen.kt @@ -184,6 +184,7 @@ private fun UpNextList( detailsEpisode?.let { episode -> TvEpisodeInfoModal( episode = episode, + actionContext = TvEpisodeActionContext.UpNext, onDismissRequest = { detailsEpisode = null }, ) } diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionTypeTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionTypeTest.kt index 3eb013c2e99..d62a04e669b 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionTypeTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionTypeTest.kt @@ -1,12 +1,31 @@ package au.com.shiftyjelly.pocketcasts.component import au.com.shiftyjelly.pocketcasts.analytics.SourceView +import com.automattic.eventhorizon.EpisodeViewSourceType import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Test class TvEpisodeActionTypeTest { + @Test + fun `every action context maps to the matching analytics and event sources`() { + val expected = mapOf( + TvEpisodeActionContext.PodcastDetails to (SourceView.PODCAST_SCREEN to EpisodeViewSourceType.PodcastScreen), + TvEpisodeActionContext.SearchResults to (SourceView.SEARCH_RESULTS to EpisodeViewSourceType.Search), + TvEpisodeActionContext.Playlist to (SourceView.FILTERS to EpisodeViewSourceType.Filters), + TvEpisodeActionContext.UpNext to (SourceView.UP_NEXT to EpisodeViewSourceType.UpNext), + TvEpisodeActionContext.NowPlaying to (SourceView.PLAYER to EpisodeViewSourceType.NowPlaying), + ) + + assertEquals(TvEpisodeActionContext.entries.toSet(), expected.keys) + TvEpisodeActionContext.entries.forEach { context -> + val (source, episodeViewSource) = expected.getValue(context) + assertEquals(source, context.source) + assertEquals(episodeViewSource, context.episodeViewSource) + } + } + @Test fun `podcast details shows played and archive toggles but no go to podcast`() { val actions = tvEpisodeActionTypes(TvEpisodeActionContext.PodcastDetails, showGoToPodcast = false) diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsViewModelTest.kt index d7026221abe..f1f6b3cd151 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeActionsViewModelTest.kt @@ -6,6 +6,9 @@ import au.com.shiftyjelly.pocketcasts.repositories.playback.PlaybackManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.EpisodeManager import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule +import com.automattic.eventhorizon.EpisodeActionsShownEvent +import com.automattic.eventhorizon.EpisodeViewSourceType +import com.automattic.eventhorizon.EventHorizon import java.util.Date import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -25,6 +28,7 @@ class TvEpisodeActionsViewModelTest { private val episodeManager = mock() private val playbackManager = mock() private val podcastManager = mock() + private val eventHorizon = mock() private val episode = PodcastEpisode( uuid = "episode-uuid", @@ -37,10 +41,18 @@ class TvEpisodeActionsViewModelTest { episodeManager = episodeManager, playbackManager = playbackManager, podcastManager = podcastManager, + eventHorizon = eventHorizon, applicationScope = CoroutineScope(coroutineRule.testDispatcher), ioDispatcher = coroutineRule.testDispatcher, ) + @Test + fun `tracking actions shown records the event with the source`() = runTest { + viewModel().trackActionsShown(EpisodeViewSourceType.Search) + + verify(eventHorizon).track(EpisodeActionsShownEvent(source = EpisodeViewSourceType.Search)) + } + @Test fun `play starts playback of the episode`() = runTest { viewModel().play(episode, SourceView.UP_NEXT) diff --git a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoViewModelTest.kt b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoViewModelTest.kt index 66685c7840f..a47ff972900 100644 --- a/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoViewModelTest.kt +++ b/tv/src/test/java/au/com/shiftyjelly/pocketcasts/component/TvEpisodeInfoViewModelTest.kt @@ -7,6 +7,9 @@ import au.com.shiftyjelly.pocketcasts.repositories.podcast.PodcastManager import au.com.shiftyjelly.pocketcasts.repositories.shownotes.ShowNotesManager import au.com.shiftyjelly.pocketcasts.servers.shownotes.ShowNotesState import au.com.shiftyjelly.pocketcasts.sharedtest.MainCoroutineRule +import com.automattic.eventhorizon.EpisodeDetailShownEvent +import com.automattic.eventhorizon.EpisodeViewSourceType +import com.automattic.eventhorizon.EventHorizon import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -19,6 +22,7 @@ import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever @@ -30,12 +34,20 @@ class TvEpisodeInfoViewModelTest { private val podcastManager = mock() private val showNotesManager = mock() + private val eventHorizon = mock() @Test fun `initial state is null`() = runTest { assertNull(createViewModel().uiState.value) } + @Test + fun `tracking shown records the detail shown event with the source`() = runTest { + createViewModel().trackDetailShown(EpisodeViewSourceType.PodcastScreen) + + verify(eventHorizon).track(EpisodeDetailShownEvent(source = EpisodeViewSourceType.PodcastScreen)) + } + @Test fun `load resolves the podcast title and show notes`() = runTest { stubTitle("Buzzcast") @@ -148,5 +160,6 @@ class TvEpisodeInfoViewModelTest { private fun createViewModel() = TvEpisodeInfoViewModel( podcastManager = podcastManager, showNotesManager = showNotesManager, + eventHorizon = eventHorizon, ) }