From 6485f4ec26313833c0e59ca8cb35240c0e97589d Mon Sep 17 00:00:00 2001 From: Eugene Date: Wed, 29 Jul 2026 16:21:59 +0300 Subject: [PATCH] chat ui --- .../data/chat/BackendChatRepository.kt | 16 +- .../backend/data/chat/SqlMessageStorage.kt | 8 +- .../vbox/backend/di/EntryPoint.kt | 5 +- .../presentation/routing/AuthRouting.kt | 2 +- .../presentation/routing/ChatRouting.kt | 24 +- .../ru/shadowsparky/vbox/server/chat.sq | 3 +- .../androidApp/src/debug/AndroidManifest.xml | 5 + .../debug/res/xml/network_security_config.xml | 2 +- apps/vbox/client/shared/build.gradle.kts | 2 + .../vbox/shared/data/RemoteChatRepository.kt | 43 ++++ .../vbox/shared/presentation/BaseScreen.kt | 2 + .../presentation/chat/ChatComponentFactory.kt | 30 +++ .../vbox/shared/presentation/nav/Child.kt | 2 + .../vbox/shared/presentation/nav/Route.kt | 3 + .../vbox/shared/presentation/nav/RouteIds.kt | 1 + .../settings/SettingsComponent.kt | 8 +- .../presentation/settings/SettingsScreen.kt | 6 + .../chat/backend/data/GigaChatService.kt | 44 ++-- .../chat/backend/data/GigaChatTokenManager.kt | 35 ++- .../chat/backend/domain/MessageStorage.kt | 2 +- .../domain/ProcessUserMessageUseCase.kt | 2 +- .../composeResources/values/strings.xml | 10 + .../chat/presentation/ChatComponent.kt | 105 +++++++++ .../chat/presentation/ChatScreen.kt | 222 ++++++++++++++++++ .../chat/domain/ChatRepository.kt | 13 +- .../updater/presentation/UpdateDialog.kt | 34 +-- .../videoplayer/AndroidPlayerController.kt | 4 +- .../videoplayer/ui/DefaultControls.kt | 14 +- .../videoplayer/ui/VideoPlayer.kt | 8 +- .../http/data/AndroidHttpClientFactory.kt | 30 +++ .../http/data/HttpClientFactory.android.kt | 26 -- .../kotlin/ru/shadowsparky/http/HttpModule.kt | 14 +- .../http/data/HttpClientFactory.kt | 7 - .../http/data/HttpClientSettings.kt | 62 ++--- .../http/data/RefreshTokenRunner.kt | 43 ++-- .../http/domain/ClientKtorExtension.kt | 7 - .../http/domain/HttpClientFactory.kt | 15 ++ .../http/data/HttpClientFactory.desktop.kt | 23 -- .../http/data/JvmHttpClientFactory.kt | 107 +++++++++ .../http/data/HttpClientFactory.wasmJs.kt | 12 - .../http/data/WasmHttpClientFactory.kt | 20 ++ 41 files changed, 797 insertions(+), 224 deletions(-) create mode 100644 apps/vbox/client/androidApp/src/debug/AndroidManifest.xml create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/data/RemoteChatRepository.kt create mode 100644 apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt create mode 100644 feature/chat/chat-client/src/commonMain/composeResources/values/strings.xml create mode 100644 feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt create mode 100644 feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt create mode 100644 libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/AndroidHttpClientFactory.kt delete mode 100644 libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt delete mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt delete mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt create mode 100644 libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpClientFactory.kt delete mode 100644 libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt create mode 100644 libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/JvmHttpClientFactory.kt delete mode 100644 libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt create mode 100644 libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/WasmHttpClientFactory.kt diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/BackendChatRepository.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/BackendChatRepository.kt index 4836067..2c7ee5b 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/BackendChatRepository.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/BackendChatRepository.kt @@ -2,33 +2,27 @@ package ru.shadowsparky.vbox.backend.data.chat import org.koin.core.annotation.Factory import ru.shadowsparky.chat.backend.domain.MessageStorage -import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase import ru.shadowsparky.chat.domain.ChatRepository import ru.shadowsparky.chat.domain.Message @Factory -class BackendChatRepositoryFactory( - private val storage: MessageStorage, - private val processUserMessageUseCase: ProcessUserMessageUseCase -) { +class BackendChatRepositoryFactory(private val storage: MessageStorage) { fun create(userId: Long): BackendChatRepository { - return BackendChatRepository(userId, storage, processUserMessageUseCase) + return BackendChatRepository(userId, storage) } } class BackendChatRepository( private val userId: Long, - private val storage: MessageStorage, - private val processUserMessageUseCase: ProcessUserMessageUseCase + private val storage: MessageStorage ) : ChatRepository { - override suspend fun query(): List { - val history = storage.query(userId, limit = 10) + override suspend fun query(afterId: Long?): List { + val history = storage.query(userId, afterId, limit = 10) return history.sortedBy { it.timestamp } } override suspend fun send(message: Message) { storage.insert(userId, message) - processUserMessageUseCase.execute(userId) } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/SqlMessageStorage.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/SqlMessageStorage.kt index 0f846d1..3c2f3f2 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/SqlMessageStorage.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/data/chat/SqlMessageStorage.kt @@ -14,9 +14,13 @@ class SqlMessageStorage( ) : MessageStorage { private val queries = appDatabase.chatQueries - override suspend fun query(userId: Long, limit: Int): List = withContext(dispatcherProvider.io) { + override suspend fun query(userId: Long, afterId: Long?, limit: Int): List = withContext(dispatcherProvider.io) { val chatId = queries.getChatIdByUserId(userId).executeAsOneOrNull() ?: return@withContext emptyList() - val dbMessages = queries.getChatContext(chatId = chatId, limit = limit.toLong()).executeAsList() + val dbMessages = queries.getChatContext( + chatId = chatId, + afterId = afterId, + limit = limit.toLong() + ).executeAsList() dbMessages.map { dbMsg -> Message( id = dbMsg.id, diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt index 0431f83..cdda560 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/di/EntryPoint.kt @@ -5,10 +5,10 @@ import org.koin.core.annotation.Single import ru.shadowsparky.backend.data.TokenVerifier import ru.shadowsparky.backend.domain.JwtInfo import ru.shadowsparky.backend.domain.LoginVerifier +import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory -import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepository import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory @@ -27,7 +27,8 @@ class RoutingEntryPoint( val movieTagFactory: MovieTagRepositoryFactory, val userTagFactory: UserTagRepositoryFactory, val updateFetcherFactory: BackendUpdateFetcherFactory, - val chatRepositoryFactory: BackendChatRepositoryFactory + val chatRepositoryFactory: BackendChatRepositoryFactory, + val processUserMessageUseCase: ProcessUserMessageUseCase ) @Single diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt index ea873e1..c155ef5 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/AuthRouting.kt @@ -45,6 +45,6 @@ fun Routing.setupAuthMethods( setupSavedMovie(savedMovieFactory) setupTagsRouting(userTagFactory, movieTagFactory) setupUpdates(updateFetcherFactory) - setupChat(chatRepositoryFactory) + setupChat(chatRepositoryFactory, processUserMessageUseCase) } } diff --git a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/ChatRouting.kt b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/ChatRouting.kt index 497321a..5da29bd 100644 --- a/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/ChatRouting.kt +++ b/apps/vbox/backend/src/main/kotlin/ru/shadowsparky/vbox/backend/presentation/routing/ChatRouting.kt @@ -1,23 +1,35 @@ package ru.shadowsparky.vbox.backend.presentation.routing -import io.ktor.http.HttpStatusCode import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.routing.post +import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase +import ru.shadowsparky.chat.domain.ChatRepository +import ru.shadowsparky.chat.domain.ChatRoles +import ru.shadowsparky.chat.domain.Message +import ru.shadowsparky.chat.domain.MessagesResponse +import ru.shadowsparky.http.domain.BadRequestException import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory import ru.shadowsparky.vbox.backend.presentation.obtainUserId import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX -fun Route.setupChat(chatRepositoryFactory: BackendChatRepositoryFactory) { +fun Route.setupChat( + chatRepositoryFactory: BackendChatRepositoryFactory, + processUserMessageUseCase: ProcessUserMessageUseCase +) { get("$DYNAMIC_PREFIX/chat/query") { val repository = chatRepositoryFactory.create(call.obtainUserId()) - call.respond(repository.query()) + val afterId = call.queryParameters[ChatRepository.ARG_AFTER_ID]?.toLong() + call.respond(MessagesResponse(repository.query(afterId))) } post("$DYNAMIC_PREFIX/chat/send") { - val repository = chatRepositoryFactory.create(call.obtainUserId()) - repository.send(call.receive()) - call.respond(HttpStatusCode.OK) + val userId = call.obtainUserId() + val info = call.receive() + if (info.role != ChatRoles.USER) throw BadRequestException("invalid role") + val repository = chatRepositoryFactory.create(userId) + repository.send(info) + call.respond(processUserMessageUseCase.execute(userId)) } } diff --git a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/chat.sq b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/chat.sq index 47b26d8..d489452 100644 --- a/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/chat.sq +++ b/apps/vbox/backend/src/main/sqldelight/ru/shadowsparky/vbox/server/chat.sq @@ -18,5 +18,6 @@ getChatContext: SELECT id, role, content, created_at FROM messages WHERE chat_id = :chatId -ORDER BY created_at DESC +AND (:afterId IS NULL OR id > :afterId) +ORDER BY id ASC LIMIT :limit; diff --git a/apps/vbox/client/androidApp/src/debug/AndroidManifest.xml b/apps/vbox/client/androidApp/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3d0c28e --- /dev/null +++ b/apps/vbox/client/androidApp/src/debug/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/apps/vbox/client/androidApp/src/debug/res/xml/network_security_config.xml b/apps/vbox/client/androidApp/src/debug/res/xml/network_security_config.xml index 134bc5f..24579b1 100644 --- a/apps/vbox/client/androidApp/src/debug/res/xml/network_security_config.xml +++ b/apps/vbox/client/androidApp/src/debug/res/xml/network_security_config.xml @@ -1,6 +1,6 @@ - + { + val args: Map = if (afterId == null) { + emptyMap() + } else { + mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString()) + } + return httpClient.get( + "$DYNAMIC_PREFIX/chat/query", + args, + serverConfigurationRepository.getServerConfiguration() + ).content + } + + override suspend fun send(message: Message) { + httpClient.post { + setBody(message) + serverConfigurationRepository.getServerConfiguration().apply( + this, + "$DYNAMIC_PREFIX/chat/send" + ) + }.body() + } +} diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt index 71a3c97..3252037 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/BaseScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.seiko.imageloader.LocalImageLoader +import ru.shadowsparky.chat.presentation.ChatScreen import ru.shadowsparky.koin import ru.shadowsparky.ui.components.BackButton import ru.shadowsparky.ui.nav.Nav @@ -63,6 +64,7 @@ private fun NavInternal(root: VBoxRootComponent) { is Child.EditPasswordChild -> EditPasswordScreen(child.component) is Child.LoadingChild -> LoadingScreen(child.component) is Child.OfflineVideoChild -> OfflineVideoScreen(child.component) + is Child.ChatChild -> ChatScreen(child.component) is Child.VideosChild, is Child.SearchChild, is Child.SettingsChild, diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt new file mode 100644 index 0000000..cfbe741 --- /dev/null +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/chat/ChatComponentFactory.kt @@ -0,0 +1,30 @@ +package ru.shadowsparky.vbox.shared.presentation.chat + +import com.arkivanov.decompose.ComponentContext +import org.koin.core.annotation.Factory +import org.koin.core.annotation.Named +import ru.shadowsparky.chat.domain.ChatRepository +import ru.shadowsparky.chat.presentation.ChatComponent +import ru.shadowsparky.ui.nav.ComponentFactory +import ru.shadowsparky.ui.nav.RouteNavigator +import ru.shadowsparky.vbox.shared.presentation.nav.Child +import ru.shadowsparky.vbox.shared.presentation.nav.Route +import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds + +@Factory +@Named(RouteIds.CHAT) +class ChatComponentFactory( + private val chatRepository: ChatRepository +) : ComponentFactory { + override fun create( + route: Route.Chat, + child: ComponentContext, + root: RouteNavigator + ): Child.ChatChild { + val comp = ChatComponent( + child, + chatRepository + ) + return Child.ChatChild(comp) + } +} diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Child.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Child.kt index 9133d2d..8493855 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Child.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Child.kt @@ -1,5 +1,6 @@ package ru.shadowsparky.vbox.shared.presentation.nav +import ru.shadowsparky.chat.presentation.ChatComponent import ru.shadowsparky.vbox.shared.presentation.list.VideoScreenComponent import ru.shadowsparky.vbox.shared.presentation.loading.LoadingComponent import ru.shadowsparky.vbox.shared.presentation.offline.OfflineVideoComponent @@ -32,4 +33,5 @@ sealed class Child : ru.shadowsparky.ui.nav.Child { class LoadingChild(val component: LoadingComponent) : Child() class PlayerChild(val component: PlayerComponent) : Child() class TagChild(val component: TagComponent) : Child() + class ChatChild(val component: ChatComponent) : Child() } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Route.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Route.kt index 74cceaa..b0d737c 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Route.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/Route.kt @@ -21,6 +21,9 @@ sealed class Route : ru.shadowsparky.ui.nav.Route() { @Serializable data class OfflineVideo(override val routeId: String = RouteIds.OFFLINE_VIDEO) : Route() + @Serializable + data class Chat(override val routeId: String = RouteIds.CHAT) : Route() + @Serializable data class Loading( val authRequired: Boolean = true, diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/RouteIds.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/RouteIds.kt index 56d690a..e41a4d4 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/RouteIds.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/nav/RouteIds.kt @@ -21,4 +21,5 @@ object RouteIds { const val REGISTER = "register" const val EDIT_PASSWORD = "editPassword" const val TAG_OVERVIEW = "tags" + const val CHAT = "chat" } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt index 98d38d0..72467b9 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsComponent.kt @@ -37,12 +37,11 @@ class SettingsComponent( val doRegister: () -> Unit, val editPassword: () -> Unit, val showOffline: () -> Unit, - val showTags: () -> Unit + val showTags: () -> Unit, + val showChat: () -> Unit ) : ComponentContext by context { val updateComponent = updateComponentFactory.create(childContext("UpdateComponent")) - val hasUpdateSupport = updateComponent.hasSupport - val serverInfo = serverConfigurationRepository.serverConfiguration.map { it.asHttpStr() } private val componentScope = createComponentScope() @@ -101,7 +100,8 @@ class SettingsComponentFactory( tokenStorage = tokenStorage, showOffline = { root.nav(Route.OfflineVideo()) }, offlineSupport = offlineStorage != null, - showTags = { root.nav(Route.Settings.TagOverview()) } + showTags = { root.nav(Route.Settings.TagOverview()) }, + showChat = { root.nav(Route.Chat())} ) return Child.SettingsChild(comp) } diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt index 97b48d6..4d9f100 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/settings/SettingsScreen.kt @@ -82,6 +82,12 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) { ) } } + item { + TextPreference( + text = "chat", + modifier = Modifier.clickable { comp.showChat() } + ) + } } section { val needAuth = tokenInfo == null diff --git a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatService.kt b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatService.kt index a66106d..66f1072 100644 --- a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatService.kt +++ b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatService.kt @@ -1,31 +1,40 @@ package ru.shadowsparky.chat.backend.data -import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.providers.BearerAuthProvider import io.ktor.client.plugins.auth.providers.BearerTokens -import io.ktor.client.plugins.auth.providers.bearer -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import org.koin.core.annotation.Factory +import org.koin.core.annotation.Single import ru.shadowsparky.chat.backend.domain.ChatService import ru.shadowsparky.chat.domain.Message +import ru.shadowsparky.http.domain.HttpClientFactory import java.util.UUID -@Factory +@Single class GigaChatService( private val tokenManager: GigaChatTokenManager, - private val json: Json + private val httpClientFactory: HttpClientFactory ) : ChatService { - private val httpClient by lazy { createGigaChatHttpClient() } + private val httpClient by lazy { + httpClientFactory.create( + BearerAuthProvider( + refreshTokens = { + val newToken = tokenManager.forceRefreshToken() + BearerTokens(newToken, null) + }, + loadTokens = { + BearerTokens(tokenManager.getToken(), null) + }, + realm = null + ) + ) + } override suspend fun getCompletion(userId: Long, messages: List): String { val clientId = "vbox-$userId" @@ -44,21 +53,6 @@ class GigaChatService( ?: error("unexpected response $response") } - private fun createGigaChatHttpClient(): HttpClient { - return HttpClient { - install(ContentNegotiation) { json(json) } - install(Auth) { - bearer { - loadTokens { BearerTokens(tokenManager.getToken(), "") } - refreshTokens { - val newToken = tokenManager.forceRefreshToken() - BearerTokens(newToken, "") - } - } - } - } - } - @Serializable private data class GigaChatRequest( val model: String = "GigaChat-2", diff --git a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatTokenManager.kt b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatTokenManager.kt index 09d4fc5..2de9d44 100644 --- a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatTokenManager.kt +++ b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/data/GigaChatTokenManager.kt @@ -1,13 +1,13 @@ package ru.shadowsparky.chat.backend.data -import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import io.ktor.client.plugins.auth.providers.BearerTokens import io.ktor.client.request.forms.FormDataContent import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders import io.ktor.http.Parameters import io.ktor.http.contentType import kotlinx.coroutines.sync.Mutex @@ -17,16 +17,35 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.koin.core.annotation.Single import ru.shadowsparky.backend.data.EnvFetcher +import ru.shadowsparky.http.data.RefreshTokenRunner +import ru.shadowsparky.http.domain.BadRequestException +import ru.shadowsparky.http.domain.HttpClientFactory import java.util.UUID @Single class GigaChatTokenManager( - private val authClient: HttpClient, private val json: Json, - private val envFetcher: EnvFetcher + private val envFetcher: EnvFetcher, + private val httpClientFactory: HttpClientFactory, + private val refreshTokenRunner: RefreshTokenRunner ) { private val scope: String = "GIGACHAT_API_PERS" + private val authClient by lazy { + httpClientFactory.create( + BearerAuthProvider( + refreshTokens = { null }, + loadTokens = { + BearerTokens( + envFetcher.get("GIGA_CHAT_AUTH_TOKEN").ifEmpty { error("auth token not provided") }, + null + ) + }, + realm = null + ) + ) + } + private val mutex = Mutex() private var cachedToken: String? = null @@ -34,14 +53,14 @@ class GigaChatTokenManager( cachedToken ?: fetchNewToken() } - suspend fun forceRefreshToken(): String = mutex.withLock { - fetchNewToken() + suspend fun forceRefreshToken(): String { + return refreshTokenRunner.runOrJoin { + BearerTokens(fetchNewToken(), null) + }?.accessToken ?: throw BadRequestException("Unable to refresh access token") } private suspend fun fetchNewToken(): String { - val auth = envFetcher.get("GIGA_CHAT_AUTH_TOKEN").ifEmpty { error("auth token not provided") } val responseString: String = authClient.post("https://ngw.devices.sberbank.ru:9443/api/v2/oauth") { - header(HttpHeaders.Authorization, auth) header("RqUID", UUID.randomUUID().toString()) contentType(ContentType.Application.FormUrlEncoded) setBody(FormDataContent(Parameters.build { diff --git a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/MessageStorage.kt b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/MessageStorage.kt index 245a3e7..407366b 100644 --- a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/MessageStorage.kt +++ b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/MessageStorage.kt @@ -3,6 +3,6 @@ package ru.shadowsparky.chat.backend.domain import ru.shadowsparky.chat.domain.Message interface MessageStorage { - suspend fun query(userId: Long, limit: Int): List + suspend fun query(userId: Long, afterId: Long?, limit: Int): List suspend fun insert(userId: Long, message: Message): Long } diff --git a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/ProcessUserMessageUseCase.kt b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/ProcessUserMessageUseCase.kt index 9bbe0bc..deceee5 100644 --- a/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/ProcessUserMessageUseCase.kt +++ b/feature/chat/chat-backend/src/main/kotlin/ru/shadowsparky/chat/backend/domain/ProcessUserMessageUseCase.kt @@ -10,7 +10,7 @@ class ProcessUserMessageUseCase( private val chatService: ChatService ) { suspend fun execute(userId: Long): Message { - val history = storage.query(userId, limit = 10).toList() + val history = storage.query(userId, null, limit = 10).toList() val sortedHistory = history.sortedBy { it.timestamp } val systemMessage = Message( role = ChatRoles.SYSTEM, diff --git a/feature/chat/chat-client/src/commonMain/composeResources/values/strings.xml b/feature/chat/chat-client/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 0000000..3a6a229 --- /dev/null +++ b/feature/chat/chat-client/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,10 @@ + + + Чат + Не удалось загрузить сообщения + Не удалось отправить сообщение + Повторить + Сообщение... + Отправить + ОК + diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt new file mode 100644 index 0000000..cd6aac2 --- /dev/null +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatComponent.kt @@ -0,0 +1,105 @@ +package ru.shadowsparky.chat.presentation + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import com.arkivanov.essenty.lifecycle.doOnDestroy +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import ru.shadowsparky.chat.domain.ChatRepository +import ru.shadowsparky.chat.domain.ChatRoles +import ru.shadowsparky.chat.domain.Message + +sealed interface ChatState { + object Loading : ChatState + + data class Error(val message: String) : ChatState + + data class Data( + val messages: List, + val isSending: Boolean = false, + val isSubsequentLoading: Boolean = false, + val sendError: String? = null + ) : ChatState +} + +class ChatComponent( + componentContext: ComponentContext, + private val repository: ChatRepository +) : ComponentContext by componentContext { + + private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()).also { ctx -> + lifecycle.doOnDestroy { ctx.cancel() } + } + + private val _state = MutableValue(ChatState.Loading) + val state: Value = _state + + init { + loadInitialMessages() + } + + fun loadInitialMessages() { + _state.value = ChatState.Loading + scope.launch { + try { + val dbList = repository.query(afterId = null) + _state.value = ChatState.Data(messages = dbList) + } catch (e: Exception) { + _state.value = ChatState.Error(e.message ?: e.toString()) + } + } + } + + fun loadMoreMessages() { + val currentState = _state.value as? ChatState.Data ?: return + if (currentState.isSubsequentLoading || currentState.messages.isEmpty()) return + val latestMessageId = currentState.messages.last().id ?: return + _state.value = currentState.copy(isSubsequentLoading = true) + scope.launch { + try { + val newMessages = repository.query(afterId = latestMessageId) + if (newMessages.isNotEmpty()) { + _state.value = currentState.copy( + messages = currentState.messages + newMessages, + isSubsequentLoading = false + ) + } else { + _state.value = currentState.copy(isSubsequentLoading = false) + } + } catch (e: Exception) { + _state.value = currentState.copy(isSubsequentLoading = false) + } + } + } + + fun sendMessage(content: String) { + val currentState = _state.value as? ChatState.Data ?: return + if (content.isBlank() || currentState.isSending) return + _state.value = currentState.copy(isSending = true, sendError = null) + scope.launch { + try { + repository.send(Message(role = ChatRoles.USER, content = content, timestamp = 0L)) + val latestMessageId = currentState.messages.lastOrNull()?.id + val freshMessages = repository.query(afterId = latestMessageId) + _state.value = currentState.copy( + messages = currentState.messages + freshMessages, + isSending = false + ) + } catch (e: Exception) { + _state.value = currentState.copy( + isSending = false, + sendError = e.message ?: e.toString() + ) + } + } + } + + fun clearSendError() { + val currentState = _state.value as? ChatState.Data ?: return + _state.value = currentState.copy(sendError = null) + } +} diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt new file mode 100644 index 0000000..555e797 --- /dev/null +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt @@ -0,0 +1,222 @@ +package ru.shadowsparky.chat.presentation + +import androiddev.feature.chat.chat_client.generated.resources.Res +import androiddev.feature.chat.chat_client.generated.resources.chat_input_placeholder +import androiddev.feature.chat.chat_client.generated.resources.chat_load_error +import androiddev.feature.chat.chat_client.generated.resources.chat_ok +import androiddev.feature.chat.chat_client.generated.resources.chat_retry_button +import androiddev.feature.chat.chat_client.generated.resources.chat_send_button +import androiddev.feature.chat.chat_client.generated.resources.chat_title +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.arkivanov.decompose.extensions.compose.subscribeAsState +import org.jetbrains.compose.resources.stringResource +import ru.shadowsparky.chat.domain.ChatRoles +import ru.shadowsparky.chat.domain.Message +import ru.shadowsparky.ui.AppScaffold +import ru.shadowsparky.ui.BlurState +import ru.shadowsparky.ui.applyBlurEffect +import ru.shadowsparky.ui.applyBlurSource +import ru.shadowsparky.ui.components.LazyColumn +import ru.shadowsparky.ui.components.Loading +import ru.shadowsparky.ui.rememberBlurState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen(component: ChatComponent, modifier: Modifier = Modifier) { + val state by component.state.subscribeAsState() + val blur = rememberBlurState() + AppScaffold( + appBar = { + TopAppBar( + title = { Text(stringResource(Res.string.chat_title)) }, + modifier = Modifier.applyBlurEffect(blur) + ) + }, + modifier = modifier + ) { paddingValues -> + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + when (val currentState = state) { + is ChatState.Loading -> { + Loading(Modifier.padding(paddingValues).fillMaxSize()) + } + is ChatState.Error -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(paddingValues) + ) { + Text(text = stringResource(Res.string.chat_load_error), color = MaterialTheme.colorScheme.error) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { component.loadInitialMessages() }) { + Text(stringResource(Res.string.chat_retry_button)) + } + } + } + is ChatState.Data -> { + ChatContent( + paddingValues, + blur, + state = currentState, + onLoadMore = { component.loadMoreMessages() }, + onSendMessage = { text -> component.sendMessage(text) }, + onDismissError = { component.clearSendError() } + ) + } + } + } + } +} + +@Composable +private fun ChatContent( + paddingValues: PaddingValues, + blurState: BlurState?, + state: ChatState.Data, + onLoadMore: () -> Unit, + onSendMessage: (String) -> Unit, + onDismissError: () -> Unit +) { + val listState = rememberLazyListState() + var textInput by remember { mutableStateOf("") } + + val shouldLoadMore by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val totalItems = layoutInfo.totalItemsCount + val lastVisibleItemIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + + totalItems > 0 && lastVisibleItemIndex >= totalItems - 2 + } + } + + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) { + onLoadMore() + } + } + + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) { + listState.animateScrollToItem(state.messages.lastIndex) + } + } + + Column(modifier = Modifier.fillMaxSize()) { + LazyColumn( + state = listState, + modifier = Modifier.applyBlurSource(blurState) + .weight(1f) + .fillMaxWidth(), + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items = state.messages, key = { it.id ?: it.hashCode() }) { message -> + MessageBubble(message = message) + } + + if (state.isSubsequentLoading) { + item { + Box(modifier = Modifier.fillMaxWidth().padding(8.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + } + } + + state.sendError?.let { error -> + Snackbar( + action = { + TextButton(onClick = onDismissError) { Text(stringResource(Res.string.chat_ok)) } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text(error) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + TextField( + value = textInput, + onValueChange = { textInput = it }, + modifier = Modifier.weight(1f), + placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) }, + enabled = !state.isSending + ) + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = { + if (textInput.isNotBlank()) { + onSendMessage(textInput) + textInput = "" + } + }, + enabled = textInput.isNotBlank() && !state.isSending + ) { + if (state.isSending) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text(stringResource(Res.string.chat_send_button)) + } + } + } + } +} + +@Composable +private fun MessageBubble(message: Message) { + val isUser = message.role == ChatRoles.USER + val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart + val containerColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = alignment) { + Card( + colors = CardDefaults.cardColors(containerColor = containerColor), + modifier = Modifier.widthIn(max = 280.dp) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(text = message.content, style = MaterialTheme.typography.bodyLarge) + } + } + } +} diff --git a/feature/chat/chat-common/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatRepository.kt b/feature/chat/chat-common/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatRepository.kt index 7211b26..a7bc21a 100644 --- a/feature/chat/chat-common/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatRepository.kt +++ b/feature/chat/chat-common/src/commonMain/kotlin/ru/shadowsparky/chat/domain/ChatRepository.kt @@ -1,6 +1,17 @@ package ru.shadowsparky.chat.domain +import kotlinx.serialization.Serializable + interface ChatRepository { - suspend fun query(): List + suspend fun query(afterId: Long?): List suspend fun send(message: Message) + + companion object { + const val ARG_AFTER_ID = "after_id" + } } + +@Serializable +data class MessagesResponse( + val content: List +) diff --git a/feature/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt b/feature/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt index 41071c6..beaf54f 100644 --- a/feature/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt +++ b/feature/updater/updater-client/src/commonMain/kotlin/ru/shadowsparky/updater/presentation/UpdateDialog.kt @@ -1,22 +1,22 @@ package ru.shadowsparky.updater.presentation -import androiddev.libs.updater.updater_client.generated.resources.Res -import androiddev.libs.updater.updater_client.generated.resources.updater_btn_close -import androiddev.libs.updater.updater_client.generated.resources.updater_btn_download -import androiddev.libs.updater.updater_client.generated.resources.updater_btn_grant -import androiddev.libs.updater.updater_client.generated.resources.updater_btn_install -import androiddev.libs.updater.updater_client.generated.resources.updater_btn_retry -import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_empty -import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_title -import androiddev.libs.updater.updater_client.generated.resources.updater_desc_permission -import androiddev.libs.updater.updater_client.generated.resources.updater_download_progress -import androiddev.libs.updater.updater_client.generated.resources.updater_title_available -import androiddev.libs.updater.updater_client.generated.resources.updater_title_completed -import androiddev.libs.updater.updater_client.generated.resources.updater_title_downloading -import androiddev.libs.updater.updater_client.generated.resources.updater_title_error -import androiddev.libs.updater.updater_client.generated.resources.updater_title_not_found -import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission -import androiddev.libs.updater.updater_client.generated.resources.updater_title_ready +import androiddev.feature.updater.updater_client.generated.resources.Res +import androiddev.feature.updater.updater_client.generated.resources.updater_btn_close +import androiddev.feature.updater.updater_client.generated.resources.updater_btn_download +import androiddev.feature.updater.updater_client.generated.resources.updater_btn_grant +import androiddev.feature.updater.updater_client.generated.resources.updater_btn_install +import androiddev.feature.updater.updater_client.generated.resources.updater_btn_retry +import androiddev.feature.updater.updater_client.generated.resources.updater_changelog_empty +import androiddev.feature.updater.updater_client.generated.resources.updater_changelog_title +import androiddev.feature.updater.updater_client.generated.resources.updater_desc_permission +import androiddev.feature.updater.updater_client.generated.resources.updater_download_progress +import androiddev.feature.updater.updater_client.generated.resources.updater_title_available +import androiddev.feature.updater.updater_client.generated.resources.updater_title_completed +import androiddev.feature.updater.updater_client.generated.resources.updater_title_downloading +import androiddev.feature.updater.updater_client.generated.resources.updater_title_error +import androiddev.feature.updater.updater_client.generated.resources.updater_title_not_found +import androiddev.feature.updater.updater_client.generated.resources.updater_title_permission +import androiddev.feature.updater.updater_client.generated.resources.updater_title_ready import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer diff --git a/feature/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt b/feature/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt index a208802..7679ad3 100644 --- a/feature/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt +++ b/feature/video-player/src/androidMain/kotlin/ru/shadowsparky/videoplayer/AndroidPlayerController.kt @@ -1,8 +1,8 @@ package ru.shadowsparky.videoplayer import android.content.Context -import androiddev.libs.video_player.generated.resources.Res -import androiddev.libs.video_player.generated.resources.player_audio_track +import androiddev.feature.video_player.generated.resources.Res +import androiddev.feature.video_player.generated.resources.player_audio_track import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.MediaItem diff --git a/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt b/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt index ec6fe33..4a5ac30 100644 --- a/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt +++ b/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/DefaultControls.kt @@ -1,12 +1,12 @@ package ru.shadowsparky.videoplayer.ui -import androiddev.libs.video_player.generated.resources.Res -import androiddev.libs.video_player.generated.resources.lock_24px -import androiddev.libs.video_player.generated.resources.lock_open_24px -import androiddev.libs.video_player.generated.resources.music_note_24px -import androiddev.libs.video_player.generated.resources.pause_24px -import androiddev.libs.video_player.generated.resources.play_arrow_24px -import androiddev.libs.video_player.generated.resources.replay_24px +import androiddev.feature.video_player.generated.resources.Res +import androiddev.feature.video_player.generated.resources.lock_24px +import androiddev.feature.video_player.generated.resources.lock_open_24px +import androiddev.feature.video_player.generated.resources.music_note_24px +import androiddev.feature.video_player.generated.resources.pause_24px +import androiddev.feature.video_player.generated.resources.play_arrow_24px +import androiddev.feature.video_player.generated.resources.replay_24px import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.focusGroup diff --git a/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt b/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt index 4acf0a9..a6897fe 100644 --- a/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt +++ b/feature/video-player/src/commonMain/kotlin/ru/shadowsparky/videoplayer/ui/VideoPlayer.kt @@ -1,9 +1,9 @@ package ru.shadowsparky.videoplayer.ui -import androiddev.libs.video_player.generated.resources.Res -import androiddev.libs.video_player.generated.resources.player_error_summary -import androiddev.libs.video_player.generated.resources.player_error_title -import androiddev.libs.video_player.generated.resources.player_retry +import androiddev.feature.video_player.generated.resources.Res +import androiddev.feature.video_player.generated.resources.player_error_summary +import androiddev.feature.video_player.generated.resources.player_error_title +import androiddev.feature.video_player.generated.resources.player_retry import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut diff --git a/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/AndroidHttpClientFactory.kt b/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/AndroidHttpClientFactory.kt new file mode 100644 index 0000000..faf5424 --- /dev/null +++ b/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/AndroidHttpClientFactory.kt @@ -0,0 +1,30 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import org.koin.core.annotation.Factory +import ru.shadowsparky.http.domain.HTTP_TIMEOUT_SECS +import ru.shadowsparky.http.domain.HttpClientFactory +import java.util.concurrent.TimeUnit + +@Factory +class AndroidHttpClientFactory(private val settings: HttpClientSettings) : HttpClientFactory { + + override fun create( + bearerAuthProvider: BearerAuthProvider?, + setupWebSocket: Boolean, + setupValidator: Boolean + ): HttpClient { + return HttpClient(OkHttp) { + settings.setup(this, bearerAuthProvider, setupWebSocket, setupValidator) + engine { + config { + connectTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS) + readTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS) + writeTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS) + } + } + } + } +} diff --git a/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt b/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt deleted file mode 100644 index 8b68748..0000000 --- a/libs/http-client/src/androidMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.android.kt +++ /dev/null @@ -1,26 +0,0 @@ -package ru.shadowsparky.http.data - -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import okhttp3.OkHttpClient -import java.util.concurrent.TimeUnit - -actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { - - actual fun create(): HttpClient { - return HttpClient(OkHttp) { - settings.setup(this) - engine { - config { - retryOnConnectionFailure(true) - connectTimeout(15, TimeUnit.SECONDS) - readTimeout(15, TimeUnit.SECONDS) - writeTimeout(15, TimeUnit.SECONDS) - } - preconfigured = OkHttpClient.Builder() - .pingInterval(30, TimeUnit.SECONDS) - .build() - } - } - } -} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt index 1df5567..0790449 100644 --- a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/HttpModule.kt @@ -9,9 +9,8 @@ import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration import org.koin.core.annotation.Module import org.koin.core.annotation.Single -import ru.shadowsparky.http.data.HttpClientFactory -import ru.shadowsparky.http.data.HttpClientSettings import ru.shadowsparky.http.data.RefreshTokenRunner +import ru.shadowsparky.http.domain.HttpClientFactory import ru.shadowsparky.http.domain.HttpException import ru.shadowsparky.http.domain.LogoutAction import ru.shadowsparky.http.domain.RefreshTokenAction @@ -25,8 +24,15 @@ import ru.shadowsparky.koin class HttpModule { @Single - fun createHttpClient(settings: HttpClientSettings): HttpClient { - return HttpClientFactory(settings).create() + fun createHttpClient( + httpClientFactory: HttpClientFactory, + bearerAuthProvider: BearerAuthProvider + ): HttpClient { + return httpClientFactory.create( + bearerAuthProvider, + setupWebSocket = true, + setupValidator = true + ) } @Single diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt deleted file mode 100644 index b7464ad..0000000 --- a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.kt +++ /dev/null @@ -1,7 +0,0 @@ -package ru.shadowsparky.http.data - -import io.ktor.client.HttpClient - -expect class HttpClientFactory(settings: HttpClientSettings) { - fun create(): HttpClient -} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt index 980d682..81d8677 100644 --- a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/HttpClientSettings.kt @@ -24,10 +24,13 @@ import kotlinx.serialization.json.jsonPrimitive import org.koin.core.annotation.Factory import ru.shadowsparky.domain.AppConfig import ru.shadowsparky.domain.Log -import ru.shadowsparky.http.domain.ClientKtorExtension +import ru.shadowsparky.http.domain.HTTP_PING_TIMEOUT_SECS +import ru.shadowsparky.http.domain.HTTP_TIMEOUT_SECS import ru.shadowsparky.http.domain.HttpException import ru.shadowsparky.http.domain.UserAgentProvider import ru.shadowsparky.koin +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit private const val MESSAGE_LONG = "message" private const val MESSAGE = "msg" @@ -36,39 +39,42 @@ private const val MESSAGE = "msg" class HttpClientSettings( private val json: Json, private val userAgentProvider: UserAgentProvider?, - private val bearerAuthProvider: BearerAuthProvider, - private val appenderList: List, private val log: Log, private val appConfig: AppConfig? ) { fun setup( - clientConfig: HttpClientConfig<*> + clientConfig: HttpClientConfig<*>, + bearerAuthProvider: BearerAuthProvider?, + setupWebSocket: Boolean, + setupValidator: Boolean ) = with(clientConfig) { - install(Auth) { - providers.add(bearerAuthProvider) + if (bearerAuthProvider != null) { + install(Auth) { + providers.add(bearerAuthProvider) + } } - expectSuccess = false - - HttpResponseValidator { - validateResponse { - val code = it.status.value - if (code in 400..599) { - val body = it.body() - if (body is JsonObject) { - val msg = when { - body.containsKey(MESSAGE_LONG) -> body[MESSAGE_LONG]?.jsonPrimitive?.content - body.containsKey(MESSAGE) -> body[MESSAGE]?.jsonPrimitive?.content - else -> body.toString() + if (setupValidator) { + expectSuccess = false + HttpResponseValidator { + validateResponse { + val code = it.status.value + if (code in 400..599) { + val body = it.body() + if (body is JsonObject) { + val msg = when { + body.containsKey(MESSAGE_LONG) -> body[MESSAGE_LONG]?.jsonPrimitive?.content + body.containsKey(MESSAGE) -> body[MESSAGE]?.jsonPrimitive?.content + else -> body.toString() + } + throw HttpException(code, msg) + } else { + throw HttpException(code, body.toString()) } - throw HttpException(code, msg) - } else { - throw HttpException(code, body.toString()) } } } } - install(Logging) { val isDebug = koin.getOrNull()?.debug ?: true if (isDebug) { @@ -85,9 +91,10 @@ class HttpClientSettings( json(json) } - install(WebSockets) { - // как часто слать ping кадры - pingIntervalMillis = 30_000 // или: pingInterval = 20.seconds (в новых API) + if (setupWebSocket) { + install(WebSockets) { + pingIntervalMillis = HTTP_PING_TIMEOUT_SECS.seconds.toLong(DurationUnit.MILLISECONDS) + } } defaultRequest { @@ -98,14 +105,11 @@ class HttpClientSettings( } } - // Таймауты на уровне Ktor install(HttpTimeout) { requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS socketTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS - connectTimeoutMillis = 20_000 + connectTimeoutMillis = HTTP_TIMEOUT_SECS.seconds.toLong(DurationUnit.MILLISECONDS) } - - appenderList.forEach { it.append(this) } } private val AppConfig.userAgent: String get() = buildString { diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt index e441036..9fb51c9 100644 --- a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/data/RefreshTokenRunner.kt @@ -1,39 +1,38 @@ package ru.shadowsparky.http.data import io.ktor.client.plugins.auth.providers.BearerTokens -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.launch +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.completeWith import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koin.core.annotation.Factory -import ru.shadowsparky.domain.DispatcherProvider - @Factory -class RefreshTokenRunner(private val dispatcherProvider: DispatcherProvider) { - private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io) +class RefreshTokenRunner { private val lock = Mutex() - private var inFlight: Deferred? = null + private var inFlight: CompletableDeferred? = null suspend fun runOrJoin(block: suspend () -> BearerTokens?): BearerTokens? { - val deferred = lock.withLock { - inFlight?.takeIf { it.isActive } ?: startNew(block).also { inFlight = it } + val myDeferred = lock.withLock { + val current = inFlight + if (current != null) { + return current.await() + } + CompletableDeferred().also { inFlight = it } } - return deferred.await() - } - - private fun startNew(block: suspend () -> BearerTokens?): Deferred { - val d = scope.async(dispatcherProvider.io) { block() } - d.invokeOnCompletion { - scope.launch { - lock.withLock { - if (inFlight === d) inFlight = null + try { + val tokens = block() + myDeferred.complete(tokens) + return tokens + } catch (e: Throwable) { + myDeferred.completeWith(Result.failure(e)) + throw e + } finally { + lock.withLock { + if (inFlight === myDeferred) { + inFlight = null } } } - return d } } diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt deleted file mode 100644 index f5f935b..0000000 --- a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/ClientKtorExtension.kt +++ /dev/null @@ -1,7 +0,0 @@ -package ru.shadowsparky.http.domain - -import io.ktor.client.HttpClientConfig - -interface ClientKtorExtension { - fun append(clientConfig: HttpClientConfig<*>) -} diff --git a/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpClientFactory.kt b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpClientFactory.kt new file mode 100644 index 0000000..2a3994f --- /dev/null +++ b/libs/http-client/src/commonMain/kotlin/ru/shadowsparky/http/domain/HttpClientFactory.kt @@ -0,0 +1,15 @@ +package ru.shadowsparky.http.domain + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.auth.providers.BearerAuthProvider + +internal const val HTTP_TIMEOUT_SECS = 30L +internal const val HTTP_PING_TIMEOUT_SECS = 30L + +interface HttpClientFactory { + fun create( + bearerAuthProvider: BearerAuthProvider? = null, + setupWebSocket: Boolean = true, + setupValidator: Boolean = true + ): HttpClient +} diff --git a/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt b/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt deleted file mode 100644 index 56f88d7..0000000 --- a/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.desktop.kt +++ /dev/null @@ -1,23 +0,0 @@ -package ru.shadowsparky.http.data - -import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import okhttp3.OkHttpClient -import java.util.concurrent.TimeUnit - -actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { - actual fun create(): HttpClient { - return HttpClient(OkHttp) { - settings.setup(this) - engine { - config { - retryOnConnectionFailure(true) - connectTimeout(15, TimeUnit.SECONDS) - readTimeout(15, TimeUnit.SECONDS) - writeTimeout(15, TimeUnit.SECONDS) - } - preconfigured = OkHttpClient.Builder().pingInterval(30, TimeUnit.SECONDS).build() - } - } - } -} diff --git a/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/JvmHttpClientFactory.kt b/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/JvmHttpClientFactory.kt new file mode 100644 index 0000000..96500e0 --- /dev/null +++ b/libs/http-client/src/desktopMain/kotlin/ru/shadowsparky/http/data/JvmHttpClientFactory.kt @@ -0,0 +1,107 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import org.koin.core.annotation.Factory +import ru.shadowsparky.http.domain.HTTP_TIMEOUT_SECS +import ru.shadowsparky.http.domain.HttpClientFactory +import java.io.ByteArrayInputStream +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager + +@Factory +class JvmHttpClientFactory(private val settings: HttpClientSettings) : HttpClientFactory { + + override fun create( + bearerAuthProvider: BearerAuthProvider?, + setupWebSocket: Boolean, + setupValidator: Boolean + ): HttpClient { + return HttpClient(OkHttp) { + settings.setup(this, bearerAuthProvider, setupWebSocket, setupValidator) + engine { + config { + connectTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS) + readTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS) + writeTimeout(HTTP_TIMEOUT_SECS, TimeUnit.SECONDS) + val (sslSocketFactory, trustManager) = createSslConfig() + sslSocketFactory(sslSocketFactory, trustManager) + } + } + } + } + + private fun createSslConfig(): Pair { + val defaultTmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm() + val defaultTmf = TrustManagerFactory.getInstance(defaultTmfAlgorithm).apply { + init(null as KeyStore?) + } + val defaultX509TrustManager = defaultTmf.trustManagers + .first { it is X509TrustManager } as X509TrustManager + val keyStoreType = KeyStore.getDefaultType() + val combinedKeyStore = KeyStore.getInstance(keyStoreType).apply { + load(null, null) + } + defaultX509TrustManager.acceptedIssuers.forEachIndexed { index, cert -> + combinedKeyStore.setCertificateEntry("system_ca_$index", cert) + } + val cf = CertificateFactory.getInstance("X.509") + val certInputStream = ByteArrayInputStream(CUSTOM_PEM_CERTIFICATE.trimIndent().toByteArray(Charsets.UTF_8)) + val customCertificate = cf.generateCertificate(certInputStream) as X509Certificate + combinedKeyStore.setCertificateEntry("russian_trusted_root_ca", customCertificate) + val combinedTmf = TrustManagerFactory.getInstance(defaultTmfAlgorithm).apply { + init(combinedKeyStore) + } + val combinedTrustManagers = combinedTmf.trustManagers + val finalX509TrustManager = combinedTrustManagers.first { it is X509TrustManager } as X509TrustManager + val sslContext = SSLContext.getInstance("TLS").apply { + init(null, combinedTrustManagers, null) + } + return Pair(sslContext.socketFactory, finalX509TrustManager) + } + + companion object { + private const val CUSTOM_PEM_CERTIFICATE = """ +-----BEGIN CERTIFICATE----- +MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx +PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu +ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg +Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS +VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg +YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v +dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n +qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q +XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U +zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX +YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y +Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD +U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD +4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9 +G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH +BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX +ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa +OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf +BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS +BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF +AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH +tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq +W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+ +/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS +AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj +C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV +4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d +WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ +D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC +EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq +391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4= +-----END CERTIFICATE----- + """ + } +} diff --git a/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt b/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt deleted file mode 100644 index 99ac603..0000000 --- a/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/HttpClientFactory.wasmJs.kt +++ /dev/null @@ -1,12 +0,0 @@ -package ru.shadowsparky.http.data - -import io.ktor.client.HttpClient -import io.ktor.client.engine.js.Js - -actual class HttpClientFactory actual constructor(private val settings: HttpClientSettings) { - actual fun create(): HttpClient { - return HttpClient(Js) { - settings.setup(this) - } - } -} diff --git a/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/WasmHttpClientFactory.kt b/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/WasmHttpClientFactory.kt new file mode 100644 index 0000000..149b7e3 --- /dev/null +++ b/libs/http-client/src/wasmJsMain/kotlin/ru/shadowsparky/http/data/WasmHttpClientFactory.kt @@ -0,0 +1,20 @@ +package ru.shadowsparky.http.data + +import io.ktor.client.HttpClient +import io.ktor.client.engine.js.Js +import io.ktor.client.plugins.auth.providers.BearerAuthProvider +import org.koin.core.annotation.Factory +import ru.shadowsparky.http.domain.HttpClientFactory + +@Factory +class WasmHttpClientFactory(private val settings: HttpClientSettings) : HttpClientFactory { + override fun create( + bearerAuthProvider: BearerAuthProvider?, + setupWebSocket: Boolean, + setupValidator: Boolean + ): HttpClient { + return HttpClient(Js) { + settings.setup(this, bearerAuthProvider, setupWebSocket, setupValidator) + } + } +}