update events logic

This commit is contained in:
2026-08-03 21:42:50 +03:00
parent 986078a84c
commit 7fb482f46f
25 changed files with 208 additions and 303 deletions
@@ -7,79 +7,68 @@ import io.ktor.http.HttpMethod
import io.ktor.websocket.Frame
import io.ktor.websocket.readText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.shareIn
import kotlinx.serialization.json.Json
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Single
import ru.shadowsparky.domain.Log
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
import kotlin.math.pow
import kotlin.time.Duration.Companion.milliseconds
@Factory
class RemoteEventListenerFactory(
@Single
class RemoteEventListenerImpl(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val log: Log
) {
fun create(suffix: String): RemoteEventListener {
return RemoteEventListenerImpl(
httpClient,
serverConfigurationRepository,
authTokenCache,
json,
log,
suffix
)
}
}
private class RemoteEventListenerImpl(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
private val json: Json,
private val log: Log,
private val suffix: String
) : RemoteEventListener {
private val eventFlow = flow {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override val event = flow {
val config = serverConfigurationRepository.getServerConfiguration()
val token = authTokenCache.token.first() ?: return@flow
log.d(TAG, "listener $suffix started")
log.d(TAG, "listener started")
try {
httpClient.webSocket(
method = HttpMethod.Get,
request = { url(config.asWebSocketStr() + "/$suffix") }
request = { url(config.asWebSocketStr() + "/${RemoteEventHandler.ON_EVENT}") }
) {
outgoing.send(Frame.Text(token.token))
while (currentCoroutineContext().isActive) {
val text = (incoming.receive() as? Frame.Text)?.readText()
if (text != null) {
val decoded = json.decodeFromString<RemoteEvent>(text)
log.d(TAG, "receive $text $decoded")
emit(decoded)
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
try {
val decoded = json.decodeFromString<RemoteEvent>(text)
log.d(TAG, "receive $text $decoded")
emit(decoded)
} catch (e: Exception) {
log.e(TAG, e, "failed to decode message: $text")
}
}
}
}
} catch (e: Exception) {
log.e(TAG, e, "websocket closed")
log.e(TAG, e, "websocket closed with error")
throw e
} finally {
log.d(TAG, "listener $suffix finished")
log.d(TAG, "listener finished")
}
}
override val event: Flow<RemoteEvent> = eventFlow.retryExponential { true }
}.retryExponential { true }.shareIn(scope, SharingStarted.WhileSubscribed(500), 0)
private fun <T> Flow<T>.retryExponential(
maxRetries: Int = Int.MAX_VALUE,
@@ -88,7 +77,7 @@ private class RemoteEventListenerImpl(
factor: Double = 2.0,
shouldRetry: (Throwable) -> Boolean = { true }
): Flow<T> = retryWhen { cause, attempt ->
log.d(TAG, "error occurred $suffix", cause)
log.d(TAG, "error occurred, attempt=$attempt", cause)
if (!shouldRetry(cause) || attempt >= maxRetries || cause is CancellationException) {
false
} else {
@@ -96,7 +85,7 @@ private class RemoteEventListenerImpl(
.toLong()
.coerceAtMost(maxDelay)
log.d(TAG, "retry after delay $delayTime", cause)
delay(delayTime)
delay(delayTime.milliseconds)
true
}
}
@@ -14,7 +14,6 @@ import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRepository
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedRequest
import ru.shadowsparky.vbox.shared.domain.RecentlyWatchedResponse
@@ -30,7 +29,7 @@ class RemoteRecentlyWatchedRepository(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
private val authTokenCache: TokenStorage,
@Named(EventType.RECENT) private val remoteEventListener: RemoteEventListener,
private val remoteEventListener: RemoteEventListener
) : RecentlyWatchedRepository {
private suspend fun obtainConfig() = serverConfigurationRepository.getServerConfiguration()
@@ -4,13 +4,12 @@ import io.ktor.client.HttpClient
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.IsSavedResponse
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
@@ -24,11 +23,11 @@ import ru.shadowsparky.vbox.shared.domain.model.VideoDetails
class RemoteSavedMovieRepository(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository,
@Named(EventType.SAVED) private val eventListener: RemoteEventListener
private val eventListener: RemoteEventListener
) : SavedMovieRepository {
override fun getAll(): Flow<List<VideoDetails>> {
return eventListener.event.filter { it is RemoteEvent.OnSaved }
return eventListener.event.filterIsInstance<RemoteEvent.OnSaved>()
.map { getAllSingle() }
.onStart { emit(getAllSingle()) }
}
@@ -4,6 +4,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.request.delete
import io.ktor.client.request.post
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Factory
@@ -12,7 +13,7 @@ import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.SearchDeleteRequest
import ru.shadowsparky.vbox.shared.domain.SearchQueryRequest
@@ -27,11 +28,11 @@ import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
class RemoteSearchRepository(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
@Named(EventType.SEARCH) private val removeListener: RemoteEventListener
private val removeListener: RemoteEventListener
) : SearchRepository {
override fun search(query: String): Flow<List<String>> {
return removeListener.event
return removeListener.event.filterIsInstance<RemoteEvent.OnSearch>()
.map { searchSingle(query) }
.onStart { emit(searchSingle(query)) }
}
@@ -16,7 +16,6 @@ import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
@@ -32,7 +31,7 @@ import ru.shadowsparky.vbox.shared.domain.tag.TagResponse
class RemoteMovieTagRepository(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
@Named(EventType.MOVIE_TAG) private val movieTagListener: RemoteEventListener,
private val movieTagListener: RemoteEventListener,
private val dispatcherProvider: DispatcherProvider
) : MovieTagRepository {
@@ -15,7 +15,6 @@ import ru.shadowsparky.vbox.shared.data.apply
import ru.shadowsparky.vbox.shared.data.get
import ru.shadowsparky.vbox.shared.data.setJsonBody
import ru.shadowsparky.vbox.shared.di.DbType
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
@@ -32,7 +31,7 @@ import ru.shadowsparky.vbox.shared.domain.tag.UserTagRepository
class RemoteUserTagRepository(
private val httpClient: HttpClient,
private val serverConfigurationProvider: ServerConfigurationRepository,
@Named(EventType.USER_TAG) private val userTagListener: RemoteEventListener,
private val userTagListener: RemoteEventListener,
private val dispatcherProvider: DispatcherProvider
) : UserTagRepository {
@@ -1,53 +1,7 @@
package ru.shadowsparky.vbox.shared.di
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import ru.shadowsparky.vbox.shared.data.RemoteEventListenerFactory
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventHandler
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
@Module
class EventsModule {
@Factory
@Named(EventType.RECENT)
fun provideRecentRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.RECENTLY_CHANGED)
}
@Factory
@Named(EventType.SEARCH)
fun provideSearchRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.SEARCH_CHANGED)
}
@Factory
@Named(EventType.SAVED)
fun provideSavedRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.SAVED_CHANGED)
}
@Factory
@Named(EventType.MOVIE_TAG)
fun provideMovieTagRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.MOVIE_TAG_CHANGED)
}
@Factory
@Named(EventType.USER_TAG)
fun provideUserTagRemoteEventListener(
factory: RemoteEventListenerFactory
): RemoteEventListener {
return factory.create(RemoteEventHandler.USER_TAG_CHANGED)
}
}
@@ -9,7 +9,6 @@ import ru.shadowsparky.vbox.shared.data.AppDatabaseProvider
import ru.shadowsparky.vbox.shared.data.saved.PosterWithoutSuffixSavedMovieRepository
import ru.shadowsparky.vbox.shared.data.saved.RemoteSavedMovieRepository
import ru.shadowsparky.vbox.shared.data.saved.SqlSavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.EventType
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.domain.SavedMovieRepository
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
@@ -32,7 +31,7 @@ class SavedModule {
fun provideSavedRemoteRepository(
httpClient: HttpClient,
serverConfigurationRepository: ServerConfigurationRepository,
@Named(EventType.SAVED) eventListener: RemoteEventListener
eventListener: RemoteEventListener
): SavedMovieRepository {
val impl =
RemoteSavedMovieRepository(httpClient, serverConfigurationRepository, eventListener)
@@ -6,16 +6,20 @@ import com.arkivanov.decompose.childContext
import com.arkivanov.decompose.router.stack.childStackWebNavigation
import com.arkivanov.decompose.router.webhistory.WebNavigation
import com.arkivanov.decompose.router.webhistory.WebNavigationOwner
import com.arkivanov.essenty.lifecycle.coroutines.repeatOnLifecycle
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.KSerializer
import org.koin.core.Koin
import org.koin.core.annotation.Factory
import ru.shadowsparky.http.domain.TokenStorage
import ru.shadowsparky.http.domain.hasAccount
import ru.shadowsparky.ui.nav.RootComponent
import ru.shadowsparky.updater.presentation.UpdateComponentFactory
import ru.shadowsparky.vbox.shared.di.factory.ImageLoaderProvider
import ru.shadowsparky.vbox.shared.domain.HeathCheck
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.presentation.nav.Route
@OptIn(ExperimentalDecomposeApi::class)
@@ -26,6 +30,7 @@ class VBoxRootComponent(
private val healthCheck: HeathCheck,
val imageLoaderProvider: ImageLoaderProvider,
updateComponentFactory: UpdateComponentFactory,
private val remoteEventListener: RemoteEventListener,
override val serializer: KSerializer<Route> = Route.serializer(),
override val koin: Koin = ru.shadowsparky.koin
) : RootComponent<Route>(componentContext), WebNavigationOwner {
@@ -41,7 +46,20 @@ class VBoxRootComponent(
}
init {
scope.launch { updateComponent.checkUpdates(false) }
scope.launch {
repeatOnLifecycle {
if (tokenStorage.hasAccount()) {
remoteEventListener.event.collect()
}
}
}
scope.launch {
tokenStorage.token.collect {
if (it != null) {
updateComponent.checkUpdates(false)
}
}
}
}
override val webNavigation: WebNavigation<*> =
@@ -63,7 +81,8 @@ class RootFactory(
private val tokenStorage: TokenStorage,
private val heathCheck: HeathCheck,
private val imageLoaderProvider: ImageLoaderProvider,
private val updateComponentFactory: UpdateComponentFactory
private val updateComponentFactory: UpdateComponentFactory,
private val remoteEventListener: RemoteEventListener
) {
fun create(
context: ComponentContext,
@@ -82,7 +101,8 @@ class RootFactory(
listOf(Route.Loading(next = initStack.toSet())),
heathCheck,
imageLoaderProvider,
updateComponentFactory
updateComponentFactory,
remoteEventListener
)
}
}
@@ -1,6 +1,8 @@
package ru.shadowsparky.vbox.shared.presentation.chat
import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.map
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.chat.domain.ChatRepository
@@ -8,6 +10,8 @@ import ru.shadowsparky.chat.domain.ChatTextParser
import ru.shadowsparky.chat.presentation.ChatComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.domain.RemoteEvent
import ru.shadowsparky.vbox.shared.domain.RemoteEventListener
import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
@@ -16,7 +20,8 @@ import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
@Named(RouteIds.CHAT)
class ChatComponentFactory(
private val chatRepository: ChatRepository,
private val textParser: ChatTextParser
private val textParser: ChatTextParser,
private val remoteEventListener: RemoteEventListener
) : ComponentFactory<Child.ChatChild, Route.Chat, Route> {
override fun create(
route: Route.Chat,
@@ -27,7 +32,8 @@ class ChatComponentFactory(
child,
chatRepository,
textParser,
onLinkClicked = { root.nav(Route.Videos(it)) }
onLinkClicked = { root.nav(Route.Videos(it)) },
refreshFlow = remoteEventListener.event.filterIsInstance<RemoteEvent.OnChatUpdate>().map {}
)
return Child.ChatChild(comp)
}