diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/Create.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/Create.kt index b252ad890..152c7c805 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/Create.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/Create.kt @@ -282,6 +282,12 @@ internal fun createFeedsClient( maxStrongSubscriptions = Integer.MAX_VALUE, maxWeakSubscriptions = Integer.MAX_VALUE, ), + stateEventsSubscriptionManager = + StreamSubscriptionManager( + logProvider.taggedLogger("StateEventSubscriptions"), + maxStrongSubscriptions = Integer.MAX_VALUE, + maxWeakSubscriptions = Integer.MAX_VALUE, + ), feedWatchHandler = feedWatchHandler, errorBus = errorBus, scope = clientScope, diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImpl.kt index b84d00be6..07acd7394 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImpl.kt @@ -90,7 +90,9 @@ import io.getstream.feeds.android.client.internal.state.MemberListImpl import io.getstream.feeds.android.client.internal.state.ModerationConfigListImpl import io.getstream.feeds.android.client.internal.state.PollListImpl import io.getstream.feeds.android.client.internal.state.PollVoteListImpl +import io.getstream.feeds.android.client.internal.state.event.toModel import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener import io.getstream.feeds.android.network.models.ActivityRequest import io.getstream.feeds.android.network.models.AddActivityRequest import io.getstream.feeds.android.network.models.DeleteActivitiesRequest @@ -108,6 +110,7 @@ import kotlinx.coroutines.launch internal class FeedsClientImpl( private val coreClient: StreamClient, private val feedsEventsSubscriptionManager: StreamSubscriptionManager, + private val stateEventsSubscriptionManager: StreamSubscriptionManager, override val apiKey: StreamApiKey, override val user: User, private val connectionRecoveryHandler: ConnectionRecoveryHandler, @@ -148,6 +151,11 @@ internal class FeedsClientImpl( logger.v { "[onEvent] Received event from core: $event" } _events.tryEmit(event) feedsEventsSubscriptionManager.forEach { it.onEvent(event) } + event.toModel()?.let { stateEvent -> + stateEventsSubscriptionManager.forEach { listener -> + listener.onEvent(stateEvent) + } + } } else { logger.e { "[onEvent] Received non-WSEvent: $event" } } @@ -214,7 +222,8 @@ internal class FeedsClientImpl( activitiesRepository = activitiesRepository, commentsRepository = commentsRepository, pollsRepository = pollsRepository, - subscriptionManager = feedsEventsSubscriptionManager, + subscriptionManager = stateEventsSubscriptionManager, + socketSubscriptionManager = feedsEventsSubscriptionManager, commentList = ActivityCommentListImpl( query = @@ -225,7 +234,7 @@ internal class FeedsClientImpl( ), currentUserId = user.id, commentsRepository = commentsRepository, - subscriptionManager = feedsEventsSubscriptionManager, + subscriptionManager = stateEventsSubscriptionManager, ), ) @@ -286,7 +295,7 @@ internal class FeedsClientImpl( query = query, currentUserId = user.id, commentsRepository = commentsRepository, - subscriptionManager = feedsEventsSubscriptionManager, + subscriptionManager = stateEventsSubscriptionManager, ) override fun commentReplyList(query: CommentRepliesQuery): CommentReplyList = diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepository.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepository.kt index db131b54b..a35593f41 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepository.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepository.kt @@ -125,7 +125,7 @@ internal interface CommentsRepository { suspend fun addCommentReaction( commentId: String, request: AddCommentReactionRequest, - ): Result> + ): Result> /** * Deletes a reaction from a comment. @@ -137,7 +137,7 @@ internal interface CommentsRepository { suspend fun deleteCommentReaction( commentId: String, type: String, - ): Result> + ): Result> /** * Queries reactions for a specific comment. diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepositoryImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepositoryImpl.kt index 1ef48386c..f009e7bd4 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepositoryImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepositoryImpl.kt @@ -143,17 +143,17 @@ internal class CommentsRepositoryImpl( override suspend fun addCommentReaction( commentId: String, request: AddCommentReactionRequest, - ): Result> = runSafely { + ): Result> = runSafely { val response = api.addCommentReaction(commentId, request) - Pair(response.reaction.toModel(), response.comment.id) + Pair(response.reaction.toModel(), response.comment.toModel()) } override suspend fun deleteCommentReaction( commentId: String, type: String, - ): Result> = runSafely { + ): Result> = runSafely { val response = api.deleteCommentReaction(id = commentId, type = type) - Pair(response.reaction.toModel(), response.comment.id) + Pair(response.reaction.toModel(), response.comment.toModel()) } override suspend fun queryCommentReactions( diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListImpl.kt index 3e26d29e8..9362a405b 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListImpl.kt @@ -22,7 +22,7 @@ import io.getstream.feeds.android.client.api.state.ActivityCommentListState import io.getstream.feeds.android.client.api.state.query.ActivityCommentsQuery import io.getstream.feeds.android.client.internal.repository.CommentsRepository import io.getstream.feeds.android.client.internal.state.event.handler.ActivityCommentListEventHandler -import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener /** * A paginated list of activities that supports real-time updates and filtering. @@ -39,7 +39,7 @@ internal class ActivityCommentListImpl( override val query: ActivityCommentsQuery, private val currentUserId: String, private val commentsRepository: CommentsRepository, - private val subscriptionManager: StreamSubscriptionManager, + subscriptionManager: StreamSubscriptionManager, ) : ActivityCommentList { private val _state: ActivityCommentListStateImpl = @@ -73,10 +73,6 @@ internal class ActivityCommentListImpl( return queryComments(nextQuery) } - /** Internal property to access the mutable state of the comment list. */ - internal val mutableState: ActivityCommentListMutableState - get() = _state - private suspend fun queryComments( query: ActivityCommentsQuery ): Result> { diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt index c1eab84e2..4e668a6d2 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImpl.kt @@ -31,8 +31,11 @@ import io.getstream.feeds.android.client.api.state.ActivityState import io.getstream.feeds.android.client.internal.repository.ActivitiesRepository import io.getstream.feeds.android.client.internal.repository.CommentsRepository import io.getstream.feeds.android.client.internal.repository.PollsRepository +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent import io.getstream.feeds.android.client.internal.state.event.handler.ActivityEventHandler import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener +import io.getstream.feeds.android.client.internal.subscribe.onEvent import io.getstream.feeds.android.client.internal.utils.flatMap import io.getstream.feeds.android.network.models.AddCommentReactionRequest import io.getstream.feeds.android.network.models.CastPollVoteRequest @@ -58,7 +61,8 @@ import io.getstream.feeds.android.network.models.UpdatePollRequest * @property commentsRepository The repository used to fetch and manage comments. * @property pollsRepository The repository used to fetch and manage polls. * @property commentList The list of comments associated with this activity. - * @property subscriptionManager The manager for WebSocket subscriptions to receive real-time + * @property subscriptionManager The manager for state update subscriptions. + * @property socketSubscriptionManager The manager for WebSocket subscriptions to receive real-time * updates. */ internal class ActivityImpl( @@ -69,7 +73,8 @@ internal class ActivityImpl( private val commentsRepository: CommentsRepository, private val pollsRepository: PollsRepository, private val commentList: ActivityCommentListImpl, - private val subscriptionManager: StreamSubscriptionManager, + private val subscriptionManager: StreamSubscriptionManager, + socketSubscriptionManager: StreamSubscriptionManager, ) : Activity { private val _state: ActivityStateImpl = ActivityStateImpl(currentUserId, commentList.state) @@ -78,7 +83,7 @@ internal class ActivityImpl( ActivityEventHandler(fid = fid, activityId = activityId, state = _state) init { - subscriptionManager.subscribe(eventHandler) + socketSubscriptionManager.subscribe(eventHandler) } override val state: ActivityState @@ -101,8 +106,8 @@ internal class ActivityImpl( } override suspend fun getComment(commentId: String): Result { - return commentsRepository.getComment(commentId).onSuccess { - commentList.mutableState.onCommentUpdated(it) + return commentsRepository.getComment(commentId).onSuccess { comment -> + subscriptionManager.onEvent(StateUpdateEvent.CommentUpdated(comment)) } } @@ -112,7 +117,7 @@ internal class ActivityImpl( ): Result { return commentsRepository .addComment(request = request, attachmentUploadProgress = attachmentUploadProgress) - .onSuccess { commentList.mutableState.onCommentAdded(ThreadedCommentData(it)) } + .onSuccess { subscriptionManager.onEvent(StateUpdateEvent.CommentAdded(it)) } } override suspend fun addCommentsBatch( @@ -121,10 +126,7 @@ internal class ActivityImpl( ): Result> { return commentsRepository.addCommentsBatch(requests, attachmentUploadProgress).onSuccess { comments -> - val threadedComments = comments.map(::ThreadedCommentData) - threadedComments.forEach { threadedComment -> - commentList.mutableState.onCommentAdded(threadedComment) - } + comments.forEach { subscriptionManager.onEvent(StateUpdateEvent.CommentAdded(it)) } } } @@ -132,7 +134,7 @@ internal class ActivityImpl( return commentsRepository .deleteComment(commentId, hardDelete) .onSuccess { (comment, activity) -> - commentList.mutableState.onCommentRemoved(comment.id) + subscriptionManager.onEvent(StateUpdateEvent.CommentDeleted(comment)) _state.onActivityUpdated(activity) } .map {} @@ -143,7 +145,7 @@ internal class ActivityImpl( request: UpdateCommentRequest, ): Result { return commentsRepository.updateComment(commentId, request).onSuccess { - commentList.mutableState.onCommentUpdated(it) + subscriptionManager.onEvent(StateUpdateEvent.CommentUpdated(it)) } } @@ -153,7 +155,11 @@ internal class ActivityImpl( ): Result { return commentsRepository .addCommentReaction(commentId, request) - .onSuccess { commentList.mutableState.onCommentReactionAdded(it.second, it.first) } + .onSuccess { (reaction, comment) -> + subscriptionManager.onEvent( + StateUpdateEvent.CommentReactionAdded(comment, reaction) + ) + } .map { it.first } } @@ -163,7 +169,11 @@ internal class ActivityImpl( ): Result { return commentsRepository .deleteCommentReaction(commentId, type) - .onSuccess { commentList.mutableState.onCommentReactionRemoved(it.second, it.first) } + .onSuccess { (reaction, comment) -> + subscriptionManager.onEvent( + StateUpdateEvent.CommentReactionDeleted(comment, reaction) + ) + } .map { it.first } } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEvent.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEvent.kt new file mode 100644 index 000000000..1b98e0d86 --- /dev/null +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/StateUpdateEvent.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.feeds.android.client.internal.state.event + +import io.getstream.feeds.android.client.api.model.CommentData +import io.getstream.feeds.android.client.api.model.FeedsReactionData +import io.getstream.feeds.android.client.api.model.toModel +import io.getstream.feeds.android.network.models.CommentAddedEvent +import io.getstream.feeds.android.network.models.CommentDeletedEvent +import io.getstream.feeds.android.network.models.CommentReactionAddedEvent +import io.getstream.feeds.android.network.models.CommentReactionDeletedEvent +import io.getstream.feeds.android.network.models.CommentUpdatedEvent +import io.getstream.feeds.android.network.models.WSEvent + +/** + * Represents an event that may trigger a state update. These events are typically the result of + * receiving a WebSocket event or having executed a successful API call that can modify the state. + */ +internal sealed interface StateUpdateEvent { + + data class CommentAdded(val comment: CommentData) : StateUpdateEvent + + data class CommentDeleted(val comment: CommentData) : StateUpdateEvent + + data class CommentUpdated(val comment: CommentData) : StateUpdateEvent + + data class CommentReactionAdded(val comment: CommentData, val reaction: FeedsReactionData) : + StateUpdateEvent + + data class CommentReactionDeleted(val comment: CommentData, val reaction: FeedsReactionData) : + StateUpdateEvent +} + +internal fun WSEvent.toModel(): StateUpdateEvent? = + when (this) { + is CommentAddedEvent -> StateUpdateEvent.CommentAdded(comment.toModel()) + + is CommentUpdatedEvent -> StateUpdateEvent.CommentUpdated(comment.toModel()) + + is CommentDeletedEvent -> StateUpdateEvent.CommentDeleted(comment.toModel()) + + is CommentReactionAddedEvent -> + StateUpdateEvent.CommentReactionAdded(comment.toModel(), reaction.toModel()) + + is CommentReactionDeletedEvent -> + StateUpdateEvent.CommentReactionDeleted(comment.toModel(), reaction.toModel()) + + else -> null + } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandler.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandler.kt index 63618b250..714210ff6 100644 --- a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandler.kt +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandler.kt @@ -16,51 +16,45 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.ThreadedCommentData -import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.ActivityCommentListStateUpdates -import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener -import io.getstream.feeds.android.network.models.CommentAddedEvent -import io.getstream.feeds.android.network.models.CommentDeletedEvent -import io.getstream.feeds.android.network.models.CommentReactionAddedEvent -import io.getstream.feeds.android.network.models.CommentReactionDeletedEvent -import io.getstream.feeds.android.network.models.CommentUpdatedEvent -import io.getstream.feeds.android.network.models.WSEvent +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener internal class ActivityCommentListEventHandler( private val objectId: String, private val objectType: String, private val state: ActivityCommentListStateUpdates, -) : FeedsEventListener { +) : StateUpdateEventListener { - override fun onEvent(event: WSEvent) { + override fun onEvent(event: StateUpdateEvent) { when (event) { - is CommentAddedEvent -> { + is StateUpdateEvent.CommentAdded -> { if (event.comment.objectId == objectId && event.comment.objectType == objectType) { - state.onCommentAdded(ThreadedCommentData(event.comment.toModel())) + state.onCommentAdded(ThreadedCommentData(event.comment)) } } - is CommentDeletedEvent -> { + is StateUpdateEvent.CommentDeleted -> { if (event.comment.objectId == objectId && event.comment.objectType == objectType) { state.onCommentRemoved(event.comment.id) } } - is CommentUpdatedEvent -> { + is StateUpdateEvent.CommentUpdated -> { if (event.comment.objectId == objectId && event.comment.objectType == objectType) { - state.onCommentUpdated(event.comment.toModel()) + state.onCommentUpdated(event.comment) } } - is CommentReactionAddedEvent -> { + is StateUpdateEvent.CommentReactionAdded -> { if (event.comment.objectId == objectId && event.comment.objectType == objectType) { - state.onCommentReactionAdded(event.comment.id, event.reaction.toModel()) + state.onCommentReactionAdded(event.comment.id, event.reaction) } } - is CommentReactionDeletedEvent -> { + is StateUpdateEvent.CommentReactionDeleted -> { if (event.comment.objectId == objectId && event.comment.objectType == objectType) { - state.onCommentReactionRemoved(event.comment.id, event.reaction.toModel()) + state.onCommentReactionRemoved(event.comment.id, event.reaction) } } } diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/subscribe/StateUpdateEventListener.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/subscribe/StateUpdateEventListener.kt new file mode 100644 index 000000000..6c2d9a788 --- /dev/null +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/subscribe/StateUpdateEventListener.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.feeds.android.client.internal.subscribe + +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent + +/** Listener interface for state update events. */ +internal interface StateUpdateEventListener { + + /** + * Called when a new state update event is received. + * + * @param event The event. + * @see [StateUpdateEvent] + */ + fun onEvent(event: StateUpdateEvent) +} diff --git a/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/subscribe/StateUpdateListenerExtensions.kt b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/subscribe/StateUpdateListenerExtensions.kt new file mode 100644 index 000000000..8fe55d970 --- /dev/null +++ b/stream-feeds-android-client/src/main/kotlin/io/getstream/feeds/android/client/internal/subscribe/StateUpdateListenerExtensions.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.feeds.android.client.internal.subscribe + +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent + +/** Notifies all subscribed [StateUpdateEventListener]s of the given [StateUpdateEvent]. */ +internal fun StreamSubscriptionManager.onEvent(event: StateUpdateEvent) { + forEach { it.onEvent(event) } +} diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImplTest.kt index 2c7accf26..67fdcc2d2 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/client/FeedsClientImplTest.kt @@ -55,6 +55,7 @@ import io.getstream.feeds.android.client.internal.repository.FilesRepository import io.getstream.feeds.android.client.internal.repository.ModerationRepository import io.getstream.feeds.android.client.internal.repository.PollsRepository import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener import io.getstream.feeds.android.client.internal.test.TestData.activityData import io.getstream.feeds.android.client.internal.test.TestData.appData import io.getstream.feeds.android.network.models.ActivityRequest @@ -82,6 +83,9 @@ internal class FeedsClientImplTest { private val coreClient: StreamClient = mockk(relaxed = true) private val feedsEventsSubscriptionManager: StreamSubscriptionManager = mockk(relaxed = true) + private val stateEventsSubscriptionManager: + StreamSubscriptionManager = + mockk(relaxed = true) private val apiKey: StreamApiKey = StreamApiKey.fromString("test-api-key") private val user: User = User(id = "test-user", type = UserAuthType.REGULAR) private val connectionRecoveryHandler: ConnectionRecoveryHandler = mockk(relaxed = true) @@ -105,6 +109,7 @@ internal class FeedsClientImplTest { FeedsClientImpl( coreClient = coreClient, feedsEventsSubscriptionManager = feedsEventsSubscriptionManager, + stateEventsSubscriptionManager = stateEventsSubscriptionManager, apiKey = apiKey, user = user, connectionRecoveryHandler = connectionRecoveryHandler, @@ -161,6 +166,7 @@ internal class FeedsClientImplTest { FeedsClientImpl( coreClient = coreClient, feedsEventsSubscriptionManager = feedsEventsSubscriptionManager, + stateEventsSubscriptionManager = stateEventsSubscriptionManager, apiKey = apiKey, user = anonymousUser, connectionRecoveryHandler = connectionRecoveryHandler, diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepositoryImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepositoryImplTest.kt index 60f589ba8..bb638d509 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepositoryImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/repository/CommentsRepositoryImplTest.kt @@ -314,7 +314,7 @@ internal class CommentsRepositoryImplTest { apiFunction = { feedsApi.addCommentReaction("commentId", request) }, repositoryCall = { repository.addCommentReaction("commentId", request) }, apiResult = apiResult, - repositoryResult = Pair(apiResult.reaction.toModel(), apiResult.comment.id), + repositoryResult = Pair(apiResult.reaction.toModel(), apiResult.comment.toModel()), ) } @@ -332,7 +332,7 @@ internal class CommentsRepositoryImplTest { apiFunction = { feedsApi.deleteCommentReaction("commentId", "like") }, repositoryCall = { repository.deleteCommentReaction("commentId", "like") }, apiResult = apiResult, - repositoryResult = Pair(apiResult.reaction.toModel(), apiResult.comment.id), + repositoryResult = Pair(apiResult.reaction.toModel(), apiResult.comment.toModel()), ) } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListImplTest.kt index db7bb72bc..9a9f935c8 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityCommentListImplTest.kt @@ -21,7 +21,7 @@ import io.getstream.feeds.android.client.api.model.PaginationResult import io.getstream.feeds.android.client.api.model.ThreadedCommentData import io.getstream.feeds.android.client.api.state.query.ActivityCommentsQuery import io.getstream.feeds.android.client.internal.repository.CommentsRepository -import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener import io.getstream.feeds.android.client.internal.test.TestData.threadedCommentData import io.mockk.coEvery import io.mockk.coVerify @@ -32,7 +32,7 @@ import org.junit.Test internal class ActivityCommentListImplTest { private val commentsRepository: CommentsRepository = mockk() - private val subscriptionManager: StreamSubscriptionManager = + private val subscriptionManager: StreamSubscriptionManager = mockk(relaxed = true) private val currentUserId = "user-1" private val query = diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImplTest.kt index 3ad4dbb49..52513e390 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/ActivityImplTest.kt @@ -24,7 +24,9 @@ import io.getstream.feeds.android.client.api.model.request.ActivityAddCommentReq import io.getstream.feeds.android.client.internal.repository.ActivitiesRepository import io.getstream.feeds.android.client.internal.repository.CommentsRepository import io.getstream.feeds.android.client.internal.repository.PollsRepository +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent import io.getstream.feeds.android.client.internal.subscribe.FeedsEventListener +import io.getstream.feeds.android.client.internal.subscribe.StateUpdateEventListener import io.getstream.feeds.android.client.internal.test.TestData.activityData import io.getstream.feeds.android.client.internal.test.TestData.commentData import io.getstream.feeds.android.client.internal.test.TestData.defaultPaginationResult @@ -32,6 +34,7 @@ import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionDat import io.getstream.feeds.android.client.internal.test.TestData.pollData import io.getstream.feeds.android.client.internal.test.TestData.pollOptionData import io.getstream.feeds.android.client.internal.test.TestData.pollVoteData +import io.getstream.feeds.android.client.internal.test.TestSubscriptionManager import io.getstream.feeds.android.network.models.AddCommentReactionRequest import io.getstream.feeds.android.network.models.CastPollVoteRequest import io.getstream.feeds.android.network.models.CreatePollOptionRequest @@ -44,6 +47,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull @@ -56,9 +60,9 @@ internal class ActivityImplTest { private val commentListState = mockk(relaxed = true) private val activityCommentListImpl: ActivityCommentListImpl = mockk { every { state } returns commentListState - every { mutableState } returns commentListState } - private val subscriptionManager: StreamSubscriptionManager = + private val stateEventListener: StateUpdateEventListener = mockk(relaxed = true) + private val socketSubscriptionManager: StreamSubscriptionManager = mockk(relaxed = true) private val activity = @@ -70,7 +74,8 @@ internal class ActivityImplTest { commentsRepository = commentsRepository, pollsRepository = pollsRepository, commentList = activityCommentListImpl, - subscriptionManager = subscriptionManager, + subscriptionManager = TestSubscriptionManager(stateEventListener), + socketSubscriptionManager = socketSubscriptionManager, ) @Test @@ -82,7 +87,7 @@ internal class ActivityImplTest { activity.addComment(request, progress) - coVerify { commentListState.onCommentAdded(ThreadedCommentData(commentData)) } + verify { stateEventListener.onEvent(StateUpdateEvent.CommentAdded(commentData)) } } @Test @@ -98,10 +103,8 @@ internal class ActivityImplTest { activity.addCommentsBatch(requests) - coVerify { - commentData.forEach { data -> - commentListState.onCommentAdded(ThreadedCommentData(data)) - } + commentData.forEach { data -> + verify { stateEventListener.onEvent(StateUpdateEvent.CommentAdded(data)) } } } @@ -154,7 +157,7 @@ internal class ActivityImplTest { val result = activity.getComment(commentId) assertEquals(comment, result.getOrNull()) - coVerify { commentListState.onCommentUpdated(comment) } + verify { stateEventListener.onEvent(StateUpdateEvent.CommentUpdated(comment)) } } @Test @@ -169,7 +172,7 @@ internal class ActivityImplTest { val result = activity.deleteComment(commentId, hardDelete) assertEquals(Unit, result.getOrNull()) - coVerify { commentListState.onCommentRemoved(commentId) } + verify { stateEventListener.onEvent(StateUpdateEvent.CommentDeleted(deleteData.first)) } assertEquals(expectedActivity, activity.state.activity.value) } @@ -184,7 +187,7 @@ internal class ActivityImplTest { val result = activity.updateComment(commentId, request) assertEquals(updatedComment, result.getOrNull()) - coVerify { commentListState.onCommentUpdated(updatedComment) } + verify { stateEventListener.onEvent(StateUpdateEvent.CommentUpdated(updatedComment)) } } @Test @@ -192,13 +195,18 @@ internal class ActivityImplTest { val commentId = "comment1" val request = AddCommentReactionRequest(type = "like") val reactionData = feedsReactionData(type = "like") + val commentData = commentData(commentId) coEvery { commentsRepository.addCommentReaction(commentId, request) } returns - Result.success(Pair(reactionData, commentId)) + Result.success(Pair(reactionData, commentData)) val result = activity.addCommentReaction(commentId, request) assertEquals(reactionData, result.getOrNull()) - coVerify { commentListState.onCommentReactionAdded(commentId, reactionData) } + verify { + stateEventListener.onEvent( + StateUpdateEvent.CommentReactionAdded(commentData, reactionData) + ) + } } @Test @@ -206,13 +214,18 @@ internal class ActivityImplTest { val commentId = "comment1" val type = "like" val reactionData = feedsReactionData(type = type) + val commentData = commentData(commentId) coEvery { commentsRepository.deleteCommentReaction(commentId, type) } returns - Result.success(Pair(reactionData, commentId)) + Result.success(Pair(reactionData, commentData)) val result = activity.deleteCommentReaction(commentId, type) assertEquals(reactionData, result.getOrNull()) - coVerify { commentListState.onCommentReactionRemoved(commentId, reactionData) } + verify { + stateEventListener.onEvent( + StateUpdateEvent.CommentReactionDeleted(commentData, reactionData) + ) + } } @Test diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt index 430f50d6e..38065c85c 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/FeedImplTest.kt @@ -645,7 +645,7 @@ internal class FeedImplTest { val reaction = feedsReactionData() coEvery { commentsRepository.addCommentReaction(commentId, request) } returns - Result.success(Pair(reaction, commentId)) + Result.success(Pair(reaction, commentData(commentId))) val result = feed.addCommentReaction(commentId, request) @@ -660,7 +660,7 @@ internal class FeedImplTest { val reaction = feedsReactionData() coEvery { commentsRepository.deleteCommentReaction(commentId, type) } returns - Result.success(Pair(reaction, commentId)) + Result.success(Pair(reaction, commentData(commentId))) val result = feed.deleteCommentReaction(commentId, type) diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandlerTest.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandlerTest.kt index 575ed26dd..97db8f650 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandlerTest.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/state/event/handler/ActivityCommentListEventHandlerTest.kt @@ -16,21 +16,12 @@ package io.getstream.feeds.android.client.internal.state.event.handler import io.getstream.feeds.android.client.api.model.ThreadedCommentData -import io.getstream.feeds.android.client.api.model.toModel import io.getstream.feeds.android.client.internal.state.ActivityCommentListStateUpdates -import io.getstream.feeds.android.client.internal.test.TestData.activityResponse -import io.getstream.feeds.android.client.internal.test.TestData.commentResponse -import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionResponse -import io.getstream.feeds.android.network.models.CommentAddedEvent -import io.getstream.feeds.android.network.models.CommentDeletedEvent -import io.getstream.feeds.android.network.models.CommentReactionAddedEvent -import io.getstream.feeds.android.network.models.CommentReactionDeletedEvent -import io.getstream.feeds.android.network.models.CommentUpdatedEvent -import io.getstream.feeds.android.network.models.WSEvent -import io.mockk.called +import io.getstream.feeds.android.client.internal.state.event.StateUpdateEvent +import io.getstream.feeds.android.client.internal.test.TestData.commentData +import io.getstream.feeds.android.client.internal.test.TestData.feedsReactionData import io.mockk.mockk import io.mockk.verify -import java.util.Date import org.junit.Test internal class ActivityCommentListEventHandlerTest { @@ -41,34 +32,19 @@ internal class ActivityCommentListEventHandlerTest { private val handler = ActivityCommentListEventHandler(objectId, objectType, state) @Test - fun `on CommentAddedEvent for matching object, then call onCommentAdded`() { - val comment = commentResponse().copy(objectId = objectId, objectType = objectType) - val event = - CommentAddedEvent( - createdAt = Date(), - fid = "user:feed-1", - comment = comment, - activity = activityResponse(), - type = "feeds.comment.added", - ) + fun `on CommentAdded for matching object, then call onCommentAdded`() { + val comment = commentData(objectId = objectId, objectType = objectType) + val event = StateUpdateEvent.CommentAdded(comment) handler.onEvent(event) - verify { state.onCommentAdded(ThreadedCommentData(comment.toModel())) } + verify { state.onCommentAdded(ThreadedCommentData(comment)) } } @Test - fun `on CommentAddedEvent for different object, then do not call onCommentAdded`() { - val comment = - commentResponse().copy(objectId = "different-activity", objectType = objectType) - val event = - CommentAddedEvent( - createdAt = Date(), - fid = "user:feed-1", - comment = comment, - activity = activityResponse(), - type = "feeds.comment.added", - ) + fun `on CommentAdded for different object, then do not call onCommentAdded`() { + val comment = commentData(objectId = "different-activity", objectType = objectType) + val event = StateUpdateEvent.CommentAdded(comment) handler.onEvent(event) @@ -76,15 +52,9 @@ internal class ActivityCommentListEventHandlerTest { } @Test - fun `on CommentDeletedEvent for matching object, then call onCommentRemoved`() { - val comment = commentResponse().copy(objectId = objectId, objectType = objectType) - val event = - CommentDeletedEvent( - createdAt = Date(), - fid = "user:feed-1", - comment = comment, - type = "feeds.comment.deleted", - ) + fun `on CommentDeleted for matching object, then call onCommentRemoved`() { + val comment = commentData(objectId = objectId, objectType = objectType) + val event = StateUpdateEvent.CommentDeleted(comment) handler.onEvent(event) @@ -92,67 +62,34 @@ internal class ActivityCommentListEventHandlerTest { } @Test - fun `on CommentUpdatedEvent for matching object, then call onCommentUpdated`() { - val comment = commentResponse().copy(objectId = objectId, objectType = objectType) - val event = - CommentUpdatedEvent( - createdAt = Date(), - fid = "user:feed-1", - comment = comment, - type = "feeds.comment.updated", - ) + fun `on CommentUpdated for matching object, then call onCommentUpdated`() { + val comment = commentData(objectId = objectId, objectType = objectType) + val event = StateUpdateEvent.CommentUpdated(comment) handler.onEvent(event) - verify { state.onCommentUpdated(comment.toModel()) } + verify { state.onCommentUpdated(comment) } } @Test - fun `on CommentReactionAddedEvent for matching object, then call onCommentReactionAdded`() { - val comment = commentResponse().copy(objectId = objectId, objectType = objectType) - val reaction = feedsReactionResponse() - val event = - CommentReactionAddedEvent( - createdAt = Date(), - fid = "user:feed-1", - activity = activityResponse(), - comment = comment, - reaction = reaction, - type = "feeds.comment.reaction.added", - ) + fun `on CommentReactionAdded for matching object, then call onCommentReactionAdded`() { + val comment = commentData(objectId = objectId, objectType = objectType) + val reaction = feedsReactionData() + val event = StateUpdateEvent.CommentReactionAdded(comment, reaction) handler.onEvent(event) - verify { state.onCommentReactionAdded(comment.id, reaction.toModel()) } + verify { state.onCommentReactionAdded(comment.id, reaction) } } @Test - fun `on CommentReactionDeletedEvent for matching object, then call onCommentReactionRemoved`() { - val comment = commentResponse().copy(objectId = objectId, objectType = objectType) - val reaction = feedsReactionResponse() - val event = - CommentReactionDeletedEvent( - createdAt = Date(), - fid = "user:feed-1", - comment = comment, - reaction = reaction, - type = "feeds.comment.reaction.deleted", - ) + fun `on CommentReactionDeleted for matching object, then call onCommentReactionRemoved`() { + val comment = commentData(objectId = objectId, objectType = objectType) + val reaction = feedsReactionData() + val event = StateUpdateEvent.CommentReactionDeleted(comment, reaction) handler.onEvent(event) - verify { state.onCommentReactionRemoved(comment.id, reaction.toModel()) } - } - - @Test - fun `on unknown event, then do nothing`() { - val unknownEvent = - object : WSEvent { - override fun getWSEventType(): String = "unknown.event" - } - - handler.onEvent(unknownEvent) - - verify { state wasNot called } + verify { state.onCommentReactionRemoved(comment.id, reaction) } } } diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt index 3263f98dc..78311628a 100644 --- a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestData.kt @@ -71,6 +71,7 @@ internal object TestData { id: String = "comment-id", text: String = "Test comment", objectId: String? = null, + objectType: String = "comment", createdAt: Date = Date(1), ) = CommentData( @@ -88,7 +89,7 @@ internal object TestData { meta = null, moderation = null, objectId = objectId ?: id, - objectType = "comment", + objectType = objectType, ownReactions = emptyList(), reactionCount = 0, reactionGroups = emptyMap(), diff --git a/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestSubscriptionManager.kt b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestSubscriptionManager.kt new file mode 100644 index 000000000..782a599c5 --- /dev/null +++ b/stream-feeds-android-client/src/test/kotlin/io/getstream/feeds/android/client/internal/test/TestSubscriptionManager.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-feeds-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.feeds.android.client.internal.test + +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager + +internal class TestSubscriptionManager(vararg initialListeners: T) : + StreamSubscriptionManager { + private val subscribed: MutableSet = initialListeners.toMutableSet() + + override fun subscribe( + listener: T, + options: StreamSubscriptionManager.Options, + ): Result { + subscribed.add(listener) + + return Result.success( + object : StreamSubscription { + override fun cancel() { + subscribed.remove(listener) + } + } + ) + } + + override fun clear(): Result { + subscribed.clear() + return Result.success(Unit) + } + + override fun forEach(block: (T) -> Unit): Result { + subscribed.forEach(block) + return Result.success(Unit) + } +}