package com.suno.android.ui.screens.orpheus import androidx.lifecycle.viewModelScope import arrow.core.Either import arrow.core.flatMap import arrow.core.getOrElse import arrow.retrofit.adapter.either.networkhandling.CallError import com.suno.android.clip.UpdateClipReactionUseCase import com.suno.android.common_analytics.listening_source.ListeningSource import com.suno.android.common_analytics.listening_source.ListeningSourceCache import com.suno.android.common_analytics.orpheus.OrpheusAnalyticsManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.constants.ReactionType import com.suno.android.common_core_utils.environment.UserPrefsDataStoreManager import com.suno.android.common_core_utils.model.AsyncData import com.suno.android.common_core_utils.model.UiString import com.suno.android.common_data.billing.SelectedModelProvider import com.suno.android.common_data.billing.SunoBillingRepo import com.suno.android.common_data.mappers.clips.LocalClipData import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.clips.xAsLocalClipData import com.suno.android.common_data.repos.GenerationRepository import com.suno.android.common_data.repos.ShareLinkRepository import com.suno.android.common_data.repos.orpheus.OrpheusChatRepository import com.suno.android.common_data.repos.orpheus.OrpheusSessionStore import com.suno.android.common_data.repos.orpheus.models.MessageContentType import com.suno.android.common_data.repos.orpheus.models.MessageRole import com.suno.android.common_data.repos.orpheus.models.OrpheusMessage import com.suno.android.common_mvi.MviProcessorFactory import com.suno.android.common_mvi.MviViewModel import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_networking.remote.entities.GenParamsSpec import com.suno.android.common_res.R import com.suno.android.common_ui.components.bottom_sheet.SharePlatformConstants import com.suno.android.gating.Feature import com.suno.android.gating.FeatureManager import com.suno.android.media.MediaManager import com.suno.android.ui.screens.orpheus.OrpheusChatState.BottomSheetState.ShareLinkVisible import com.suno.android.ui.screens.orpheus.OrpheusChatState.BottomSheetState.SongActionsVisible import com.suno.android.ui.screens.orpheus.components.projects.OrpheusProjectsController import com.suno.android.ui.screens.orpheus.components.projects.OrpheusProjectsEffect import com.suno.android.usecase.GetStringFromResourcesUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentSet import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class OrpheusChatVM @Inject constructor( processorFactory: MviProcessorFactory, private val orpheusChatRepository: OrpheusChatRepository, private val sessionStore: OrpheusSessionStore, private val listeningSourceCache: ListeningSourceCache, private val mediaManager: MediaManager, private val generationRepository: GenerationRepository, private val updateClipReactionUseCase: UpdateClipReactionUseCase, private val billingRepo: SunoBillingRepo, private val selectedModelProvider: SelectedModelProvider, private val shareLinkRepository: ShareLinkRepository, private val orpheusAnalyticsManager: OrpheusAnalyticsManager, private val getStringFromResourcesUseCase: GetStringFromResourcesUseCase, private val userPrefsDataStoreManager: UserPrefsDataStoreManager, projectsControllerFactory: OrpheusProjectsController.Factory, featureManager: FeatureManager, ) : MviViewModel( processorFactory = processorFactory, initialState = OrpheusChatState( isProjectsEnabled = featureManager.hasFeature(Feature.OrpheusProjects), ), ) { init { observeMessageUpdates() observeProjectUpdates() observeThemeMode() viewModelScope.launch { val sessionExists = orpheusChatRepository.restoreOrStartNewSession() if (sessionExists) { observeMediaPlayerStateUpdates() observeModelSelectionUpdates() startPollingGeneratingSongs() observeSongGenerationStateUpdates() } } } val projectsController: OrpheusProjectsController = projectsControllerFactory.create( coroutineScope = viewModelScope, onProjectSelected = { sendEvent(OrpheusChatEvent.Internal.SelectProject(project = it)) emitEffect(OrpheusChatEffect.CloseProjectsDrawer) }, onCreateProjectClick = { sendEvent(OrpheusChatEvent.Internal.CreateProject) }, ).also { controller -> viewModelScope.launch { controller.effects.collectLatest { effect -> launch { when (effect) { is OrpheusProjectsEffect.ShowSnackbar -> emitEffect( OrpheusChatEffect.ShowSnackbar(effect.text), ) } } } } } private fun observeMessageUpdates() { upstreamFlows.bind( source = orpheusChatRepository.messages, map = OrpheusChatEvent.Internal::MessagesUpdated, ) } private fun observeProjectUpdates() { sessionStore.sessionFlow .map { it.dataOrNull?.project } .distinctUntilChanged() .onEach { project -> if (project != null) { projectsController.refresh() } sendEvent(OrpheusChatEvent.Internal.CurrentProjectChanged(project = project)) } .catch { logger.e(it) } .launchIn(viewModelScope) } private fun observeThemeMode() { upstreamFlows.bind( source = userPrefsDataStoreManager.getThemeMode(), map = OrpheusChatEvent.Internal::ThemeModeChanged, ) } private fun observeMediaPlayerStateUpdates() { upstreamFlows.bind( source = mediaManager.mediaPlayerFlow(), map = { mediaPlayerState -> val clip = mediaPlayerState.nowPlayingClipData() OrpheusChatEvent.Internal.MediaPlayerStateUpdated( clip = clip, isPlaying = mediaPlayerState.isPlaying, playbackProgress = mediaPlayerState.percentageComplete, ) }, ) } private fun startPollingGeneratingSongs() { state .flatMapLatest { chatState -> if (chatState.pollGeneratingSongs) { generationRepository.pollAllGeneratingSongs() } else { emptyFlow() } } .catch { throwable -> logger.e(throwable) } .launchIn(viewModelScope) } private fun observeSongGenerationStateUpdates() { upstreamFlows.bind( source = generationRepository.songGenerationStateFlow(), map = { songGenerationState -> OrpheusChatEvent.Internal.GeneratedClipsReady(songGenerationState.readyClipIds) }, ) } private fun observeModelSelectionUpdates() { combine( selectedModelProvider.getSelectedModelFlow(), billingRepo.billingStateFlow(), ::Pair, ).onEach { (selectedModel, billingInfo) -> if (selectedModel.externalKey != state.value.selectedModel?.externalKey) { val modelKey = requireNotNull(selectedModel.externalKey) orpheusChatRepository.registerSelectedModel(modelKey) sendEvent(OrpheusChatEvent.Internal.SelectedModelChanged(model = selectedModel)) } val creditCount = billingInfo?.totalCreditsLeft if (creditCount != null && state.value.creditCount != creditCount) { sendEvent(OrpheusChatEvent.Internal.CreditCountChanged(creditCount = creditCount)) } }.catch { throwable -> logger.e(throwable) }.launchIn(viewModelScope) } override suspend fun reduceEvent( currentState: OrpheusChatState, event: OrpheusChatEvent, emitEffect: suspend (OrpheusChatEffect) -> Unit, ): OrpheusChatState = when (event) { is OrpheusChatEvent.MessageChanged -> currentState.copy(currentMessage = event.message) is OrpheusChatEvent.SendMessage -> handleSendMessage(currentState) is OrpheusChatEvent.NewChat -> handleNewChat(currentState) is OrpheusChatEvent.ToggleClipPlayback -> handleClipPlayback(currentState = currentState, event = event) is OrpheusChatEvent.SelectClip -> handleSelectClip(currentState = currentState, event = event) is OrpheusChatEvent.UnselectClip -> currentState.copy( selectedMessageClip = null, ) is OrpheusChatEvent.LikeClip -> handleLikeClip(currentState = currentState, event = event) is OrpheusChatEvent.DislikeClip -> handleDislikeClip(currentState = currentState, event = event) is OrpheusChatEvent.ShareClip -> currentState.copy( bottomSheetState = ShareLinkVisible( messageId = event.messageId, clip = event.clip, ), ) is OrpheusChatEvent.StartShare -> handleStartShare( currentState = currentState, event = event, ) is OrpheusChatEvent.OpenClipOverflow -> currentState.copy( bottomSheetState = SongActionsVisible( messageId = event.messageId, clip = event.clip, ), ) is OrpheusChatEvent.ScrubClip -> handleScrubClip(currentState = currentState, event = event) is OrpheusChatEvent.DismissBottomSheet -> currentState.copy( bottomSheetState = OrpheusChatState.BottomSheetState.Hidden, ) is OrpheusChatEvent.ShowChatInputOptions -> currentState.copy( bottomSheetState = OrpheusChatState.BottomSheetState.ChatInputOptionsVisible, ) is OrpheusChatEvent.SongDeleted -> handleSongDeleted( currentState = currentState, messageId = event.messageId, song = event.song, ) is OrpheusChatEvent.SongRenamed -> handleSongRenamed( currentState = currentState, messageId = event.messageId, song = event.song, ) is OrpheusChatEvent.AddGeneratedClipsToMessage -> handleAddGeneratedClipsToMessage( currentState = currentState, event = event, ) is OrpheusChatEvent.CreateNewMessageWithGeneratedClips -> handleCreateNewMessageWithGeneratedClips( currentState = currentState, event = event, ) is OrpheusChatEvent.CreateMoreClips -> handleCreateMoreClips( currentState = currentState, event = event, ) is OrpheusChatEvent.ShareProject -> handleShareProject(currentState, event) is OrpheusChatEvent.ScreenOpened -> currentState.also { orpheusAnalyticsManager.trackSessionOpened() } is OrpheusChatEvent.ScreenClosed -> currentState.also { orpheusAnalyticsManager.trackSessionClosed() } is OrpheusChatEvent.SwitchedToCustomMode -> currentState.also { orpheusAnalyticsManager.trackSwitchToCustomMode() } is OrpheusChatEvent.SwitchedToOrpheusMode -> currentState.also { orpheusAnalyticsManager.trackSwitchToOrpheusMode() } is OrpheusChatEvent.Internal.MessagesUpdated -> handleMessagesUpdated( currentState = currentState, event = event, ) is OrpheusChatEvent.Internal.StreamingError -> currentState.copy(isWaitingForResponse = false) is OrpheusChatEvent.Internal.MediaPlayerStateUpdated -> currentState.copy( selectedMessageClip = currentState.selectedMessageClip?.copy( isPlaying = event.isPlaying, playbackProgress = event.playbackProgress, ), ) is OrpheusChatEvent.Internal.GeneratedClipsReady -> handleGeneratedClipsReady( currentState = currentState, event = event, ) is OrpheusChatEvent.Internal.CreateMoreClipsFinished -> currentState.copy( createMoreClipsMessageId = null, ) is OrpheusChatEvent.Internal.SelectedModelChanged -> currentState.copy( selectedModel = event.model, ) is OrpheusChatEvent.Internal.CreditCountChanged -> currentState.copy( creditCount = event.creditCount, ) is OrpheusChatEvent.Internal.CurrentProjectChanged -> currentState.copy( currentProject = event.project, ) OrpheusChatEvent.Internal.CreateProject -> handleCreateProject(currentState) is OrpheusChatEvent.Internal.SelectProject -> handleSelectProject( currentState = currentState, event = event, ) is OrpheusChatEvent.Internal.ChatLinkFetched -> handleShareProjectLinkReceived(currentState, event) is OrpheusChatEvent.Internal.ThemeModeChanged -> currentState.copy(themeMode = event.themeMode) } private fun handleSendMessage( currentState: OrpheusChatState, ): OrpheusChatState { val message = currentState.currentMessage.trim() if (message.isEmpty()) return currentState viewModelScope.launch { val messageId = orpheusChatRepository.sendMessage(content = message) messageId?.let(orpheusAnalyticsManager::trackMessageSent) } val session = sessionStore.sessionFlow.value.dataOrNull if (session?.project == null) { viewModelScope.launch { val untitledProjectName = getStringFromResourcesUseCase(R.string.untitled) orpheusChatRepository.createNewProjectInCurrentSession(untitledProjectName) } } return currentState.copy( currentMessage = "", isWaitingForResponse = true, ) } private fun handleNewChat( currentState: OrpheusChatState, ): OrpheusChatState { mediaManager.stopPlaybackAndClearPlaylist() viewModelScope.launch { val sessionActive = orpheusChatRepository.startNewSession() if (sessionActive.isRight()) { val modelKey = currentState.selectedModel?.externalKey ?: SelectedModelProvider.FALLBACK_MODEL_KEY orpheusChatRepository.registerSelectedModel(modelKey) } } return currentState.copy( messages = AsyncData.Ready(persistentListOf()), currentMessage = "", isWaitingForResponse = false, bottomSheetState = OrpheusChatState.BottomSheetState.Hidden, selectedMessageClip = null, analyticState = OrpheusChatState.AnalyticState(), ) } private fun handleClipPlayback( currentState: OrpheusChatState, event: OrpheusChatEvent.ToggleClipPlayback, ): OrpheusChatState = if (currentState.selectedMessageClip?.clip?.clipId == event.clip.clipId && currentState.selectedMessageClip.isPlaying ) { mediaManager.setIsPlaying(false) currentState.copy( selectedMessageClip = currentState.selectedMessageClip.copy( isPlaying = false, ), ) } else { // Store listening source for analytics (session id ~ chat id for data analytics) sessionStore.sessionFlow.value.dataOrNull?.sessionId?.let { sessionId -> listeningSourceCache.put( clipId = event.clip.clipId.map(), listeningSource = ListeningSource.OrpheusSource(sessionId), ) } mediaManager.setSinglePlayingClipData(event.clip) currentState.copy( selectedMessageClip = SelectedMessageClip( messageId = event.messageId, clip = event.clip, isPlaying = true, ), ) } private fun handleSelectClip( currentState: OrpheusChatState, event: OrpheusChatEvent.SelectClip, ): OrpheusChatState = when { !event.clip.status.isReady -> currentState currentState.selectedMessageClip?.clip?.clipId == event.clip.clipId -> currentState.copy( selectedMessageClip = null, ) else -> { mediaManager.setIsPlaying(false) // if the event messageId is null, search for a message for this clip id val messageId = event.messageId ?: currentState.messages.dataOrNull?.find { message -> event.clip.clipId in message.generatedClips.asSequence().map { it.clipId } }?.messageId // if a message could not be found for this clip id, // the clip is not a part of the orpheus chat log, no-op if (messageId == null) { currentState } else { currentState.copy( selectedMessageClip = SelectedMessageClip( messageId = messageId, clip = event.clip, ), ) } } } private fun handleScrubClip( currentState: OrpheusChatState, event: OrpheusChatEvent.ScrubClip, ): OrpheusChatState { if (currentState.selectedMessageClip?.clip?.clipId == event.clip.clipId) { mediaManager.scrubToPercent(percent = event.scrubPercent) } return currentState } private fun handleLikeClip( currentState: OrpheusChatState, event: OrpheusChatEvent.LikeClip, ): OrpheusChatState { updateClipReaction( messageId = event.messageId, clip = event.clip, requestedReaction = ReactionType.LIKE, ) return currentState } private fun handleDislikeClip( currentState: OrpheusChatState, event: OrpheusChatEvent.DislikeClip, ): OrpheusChatState { updateClipReaction( messageId = event.messageId, clip = event.clip, requestedReaction = ReactionType.DISLIKE, ) return currentState } private fun updateClipReaction( messageId: Id, clip: LocalClipData, requestedReaction: ReactionType, ) { viewModelScope.launch { val previousReaction = clip.reaction val newReaction = if (clip.reaction == requestedReaction) ReactionType.NOTHING else requestedReaction orpheusChatRepository.updateClipReactionInMessage( messageId = messageId, clipId = clip.clipId, newReaction = newReaction, ) updateClipReactionUseCase( clipId = clip.clipId, currentReactionType = clip.reaction, requestedReactionType = newReaction, ).getOrElse { orpheusChatRepository.updateClipReactionInMessage( messageId = messageId, clipId = clip.clipId, newReaction = previousReaction, ) } } } private fun handleMessagesUpdated( currentState: OrpheusChatState, event: OrpheusChatEvent.Internal.MessagesUpdated, ): OrpheusChatState = when (event.messages) { is AsyncData.Ready -> { val messages = event.messages.data val clipStatuses = messages .asSequence() .filter { it.contentType == MessageContentType.GeneratedClips } .flatMap { message -> message.generatedClips } .associate { clip -> clip.clipId to clip.status } val generatingClips = clipStatuses.filter { it.value.isGenerating } if (generatingClips.isNotEmpty()) { generationRepository.upsertClipStatuses(generatingClips) } val lastMessage = messages.lastOrNull() val receivedNewResponse = currentState.isWaitingForResponse && lastMessage?.role == MessageRole.Assistant && (lastMessage.content.isNotBlank() || lastMessage.generatedClips.isNotEmpty()) val isWaitingForResponse = if (receivedNewResponse) false else currentState.isWaitingForResponse // Track analytics for new assistant responses only val updatedAnalyticState = if (lastMessage != null) { trackAssistantLatencyAnalytics( newMessage = lastMessage, analyticState = currentState.analyticState, ) } else { currentState.analyticState } currentState.copy( messages = AsyncData.Ready(messages), isWaitingForResponse = isWaitingForResponse, pollGeneratingSongs = clipStatuses.any { !it.value.isTerminal }, analyticState = updatedAnalyticState, ) } is AsyncData.Error -> currentState.copy( messages = AsyncData.Error(Unit), ) AsyncData.Loading -> currentState.copy( messages = AsyncData.Loading, ) } private fun handleGeneratedClipsReady( currentState: OrpheusChatState, event: OrpheusChatEvent.Internal.GeneratedClipsReady, ): OrpheusChatState { viewModelScope.launch { orpheusChatRepository.updateClipsStatusInMessages(event.readyClipIds) } return currentState } private fun handleAddGeneratedClipsToMessage( currentState: OrpheusChatState, event: OrpheusChatEvent.AddGeneratedClipsToMessage, ): OrpheusChatState { viewModelScope.launch { orpheusChatRepository.fetchAndUpdateMessageClips( messageId = event.messageId, clipIds = event.clipIds, replaceClips = false, ) } return currentState } private fun handleCreateNewMessageWithGeneratedClips( currentState: OrpheusChatState, event: OrpheusChatEvent.CreateNewMessageWithGeneratedClips, ): OrpheusChatState { viewModelScope.launch { orpheusChatRepository.createNewMessageWithGeneratedClips( clipIds = event.clipIds, ) } return currentState } private fun handleCreateMoreClips( currentState: OrpheusChatState, event: OrpheusChatEvent.CreateMoreClips, ): OrpheusChatState { val firstClip = event.message.generatedClips.firstOrNull() if (firstClip == null) { logger.w { "No clips found in message ${event.message.messageId.value} to created more from" } return currentState } viewModelScope.launch { val modelKey = currentState.selectedModel?.externalKey ?: SelectedModelProvider.FALLBACK_MODEL_KEY val genParams = GenParamsSpec( prompt = firstClip.prompt ?: "", modelVersionName = modelKey, tags = firstClip.tags ?: "", title = firstClip.nowPlayingTitle, gptPrompt = firstClip.gptPrompt, makeInstrumental = false, generationType = GenParamsSpec.GenerationType.TEXT, ) val response = generationRepository.startSongGeneration(genParams).getOrElse { logger.w { "Failed to create more clips: ${it?.detail ?: "Unknown error"}" } sendEvent(OrpheusChatEvent.Internal.CreateMoreClipsFinished) return@launch } val newClipIds = response.clips.mapTo(mutableSetOf()) { Id(it.id) } orpheusChatRepository.fetchAndUpdateMessageClips( messageId = event.message.messageId, clipIds = newClipIds, replaceClips = false, ) sendEvent(OrpheusChatEvent.Internal.CreateMoreClipsFinished) } return currentState.copy(createMoreClipsMessageId = event.message.messageId) } private fun handleSongDeleted( currentState: OrpheusChatState, messageId: Id, song: SongListData, ): OrpheusChatState { mediaManager.removeClipById(song.id) orpheusChatRepository.removeClipFromMessage( messageId = messageId, clipId = song.id, ) return currentState } private fun handleSongRenamed( currentState: OrpheusChatState, messageId: Id, song: SongListData, ): OrpheusChatState { val clip = song.xAsLocalClipData() val updatedClip = clip.copy(nowPlayingTitle = song.title) mediaManager.updateClip( oldLocalClipData = clip, newLocalClipData = updatedClip, ) orpheusChatRepository.updateClipInMessage( messageId = messageId, updatedClip = updatedClip, ) return currentState } private fun handleStartShare( currentState: OrpheusChatState, event: OrpheusChatEvent.StartShare, ): OrpheusChatState { when (event.platform) { is SharePlatformConstants.Link -> { viewModelScope.launch { val shareLink = shareLinkRepository .getSongShareLink( contentId = event.clip.clipId, platform = event.platform.backendValue, ) .mapLeft(CallError::toThrowable) .flatMap { link -> link?.let { Either.Right(it) } ?: Either.Left(NullPointerException("Link is null")) } .getOrElse { error -> logger.e(error) { "Failed to get song share URL" } return@launch } emitEffect( OrpheusChatEffect.ShareLink( clipId = event.clip.clipId, sharePlatform = event.platform, link = shareLink, ), ) sendEvent(OrpheusChatEvent.DismissBottomSheet) } } is SharePlatformConstants.Video -> { // Video sharing not yet implemented for Orpheus clips } } return currentState } private fun handleCreateProject( currentState: OrpheusChatState, ): OrpheusChatState { viewModelScope.launch { emitEffect(OrpheusChatEffect.CloseProjectsDrawer) orpheusChatRepository.startNewSession() } return currentState } private fun handleSelectProject( currentState: OrpheusChatState, event: OrpheusChatEvent.Internal.SelectProject, ): OrpheusChatState { mediaManager.stopPlaybackAndClearPlaylist() viewModelScope.launch { orpheusChatRepository.switchToSessionForProject(event.project) } return currentState } override fun onCleared() { super.onCleared() orpheusChatRepository.close() } private fun handleShareProject( currentState: OrpheusChatState, event: OrpheusChatEvent.ShareProject, ): OrpheusChatState { viewModelScope.launch { val link = orpheusChatRepository.getChatLinkForProject(event.project.id) .getOrElse { emitEffect( OrpheusChatEffect.ShowSnackbar( message = UiString.Resource(R.string.orpheus_project_share_link_error), ), ) return@launch } sendEvent( OrpheusChatEvent.Internal.ChatLinkFetched( link = link, ), ) } return currentState } private fun handleShareProjectLinkReceived( currentState: OrpheusChatState, event: OrpheusChatEvent.Internal.ChatLinkFetched, ): OrpheusChatState { emitEffect(OrpheusChatEffect.ShareChatLink(link = event.link)) return currentState } /** * Processes orpheus assistant message response to track begin vs end responses * @return updated [OrpheusChatState.AnalyticState] */ private fun trackAssistantLatencyAnalytics( newMessage: OrpheusMessage, analyticState: OrpheusChatState.AnalyticState, ): OrpheusChatState.AnalyticState { if (newMessage.role != MessageRole.Assistant) return analyticState val id = newMessage.messageId val isBeginTracked = id in analyticState.trackedBeginMessageIds val isEndTracked = id in analyticState.trackedEndMessageIds return when { // Already fully tracked, no change isEndTracked -> { analyticState } // New streaming message, track begin and update record newMessage.isStreaming && !isBeginTracked -> { orpheusAnalyticsManager.trackResponseBegin(id) analyticState.copy( trackedBeginMessageIds = (analyticState.trackedBeginMessageIds + id).toPersistentSet(), ) } // Still streaming, no change newMessage.isStreaming && isBeginTracked -> { analyticState } // Streaming ended, track end and update record !newMessage.isStreaming && isBeginTracked -> { orpheusAnalyticsManager.trackResponseEnd(id) analyticState.copy( trackedBeginMessageIds = (analyticState.trackedBeginMessageIds - id).toPersistentSet(), trackedEndMessageIds = (analyticState.trackedEndMessageIds + id).toPersistentSet(), ) } // `!newMessage.isStreaming && !isBeginTracked always true`, one-shot non-streaming assistant message else -> { orpheusAnalyticsManager.run { trackResponseBegin(id) trackResponseEnd(id) } analyticState.copy(trackedEndMessageIds = (analyticState.trackedEndMessageIds + id).toPersistentSet()) } } } }