This commit is contained in:
2026-07-29 16:21:59 +03:00
parent b83a40f4bd
commit 6485f4ec26
41 changed files with 797 additions and 224 deletions
@@ -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<Message> {
val history = storage.query(userId, limit = 10)
override suspend fun query(afterId: Long?): List<Message> {
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)
}
}
@@ -14,9 +14,13 @@ class SqlMessageStorage(
) : MessageStorage {
private val queries = appDatabase.chatQueries
override suspend fun query(userId: Long, limit: Int): List<Message> = withContext(dispatcherProvider.io) {
override suspend fun query(userId: Long, afterId: Long?, limit: Int): List<Message> = 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,
@@ -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
@@ -45,6 +45,6 @@ fun Routing.setupAuthMethods(
setupSavedMovie(savedMovieFactory)
setupTagsRouting(userTagFactory, movieTagFactory)
setupUpdates(updateFetcherFactory)
setupChat(chatRepositoryFactory)
setupChat(chatRepositoryFactory, processUserMessageUseCase)
}
}
@@ -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<Message>()
if (info.role != ChatRoles.USER) throw BadRequestException("invalid role")
val repository = chatRepositoryFactory.create(userId)
repository.send(info)
call.respond(processUserMessageUseCase.execute(userId))
}
}
@@ -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;
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
</manifest>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates
+2
View File
@@ -27,6 +27,8 @@ kotlin {
implementation(project(":libs:http-client"))
implementation(project(":feature:video-player"))
implementation(project(":feature:updater:updater-client"))
implementation(project(":feature:chat:chat-client"))
implementation(project(":feature:chat:chat-common"))
}
androidMain.dependencies {
implementation(libs.androidx.appcompat)
@@ -0,0 +1,43 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.chat.domain.MessagesResponse
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@Factory
class RemoteChatRepository(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository
) : ChatRepository {
override suspend fun query(afterId: Long?): List<Message> {
val args: Map<String, String> = if (afterId == null) {
emptyMap()
} else {
mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString())
}
return httpClient.get<MessagesResponse>(
"$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<Message>()
}
}
@@ -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,
@@ -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<Child.ChatChild, Route.Chat, Route> {
override fun create(
route: Route.Chat,
child: ComponentContext,
root: RouteNavigator<Route>
): Child.ChatChild {
val comp = ChatComponent(
child,
chatRepository
)
return Child.ChatChild(comp)
}
}
@@ -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()
}
@@ -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,
@@ -21,4 +21,5 @@ object RouteIds {
const val REGISTER = "register"
const val EDIT_PASSWORD = "editPassword"
const val TAG_OVERVIEW = "tags"
const val CHAT = "chat"
}
@@ -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)
}
@@ -82,6 +82,12 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
)
}
}
item {
TextPreference(
text = "chat",
modifier = Modifier.clickable { comp.showChat() }
)
}
}
section {
val needAuth = tokenInfo == null
@@ -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<Message>): 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",
@@ -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 {
@@ -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<Message>
suspend fun query(userId: Long, afterId: Long?, limit: Int): List<Message>
suspend fun insert(userId: Long, message: Message): Long
}
@@ -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,
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="chat_title">Чат</string>
<string name="chat_load_error">Не удалось загрузить сообщения</string>
<string name="chat_send_error">Не удалось отправить сообщение</string>
<string name="chat_retry_button">Повторить</string>
<string name="chat_input_placeholder">Сообщение...</string>
<string name="chat_send_button">Отправить</string>
<string name="chat_ok">ОК</string>
</resources>
@@ -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<Message>,
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>(ChatState.Loading)
val state: Value<ChatState> = _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)
}
}
@@ -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)
}
}
}
}
@@ -1,6 +1,17 @@
package ru.shadowsparky.chat.domain
import kotlinx.serialization.Serializable
interface ChatRepository {
suspend fun query(): List<Message>
suspend fun query(afterId: Long?): List<Message>
suspend fun send(message: Message)
companion object {
const val ARG_AFTER_ID = "after_id"
}
}
@Serializable
data class MessagesResponse(
val content: List<Message>
)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
}
}
}
}
}
@@ -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()
}
}
}
}
@@ -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
@@ -1,7 +0,0 @@
package ru.shadowsparky.http.data
import io.ktor.client.HttpClient
expect class HttpClientFactory(settings: HttpClientSettings) {
fun create(): HttpClient
}
@@ -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<ClientKtorExtension>,
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<JsonElement>()
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<JsonElement>()
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<AppConfig>()?.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 {
@@ -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<BearerTokens?>? = null
private var inFlight: CompletableDeferred<BearerTokens?>? = 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<BearerTokens?>().also { inFlight = it }
}
return deferred.await()
}
private fun startNew(block: suspend () -> BearerTokens?): Deferred<BearerTokens?> {
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
}
}
@@ -1,7 +0,0 @@
package ru.shadowsparky.http.domain
import io.ktor.client.HttpClientConfig
interface ClientKtorExtension {
fun append(clientConfig: HttpClientConfig<*>)
}
@@ -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
}
@@ -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()
}
}
}
}
@@ -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<SSLSocketFactory, X509TrustManager> {
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-----
"""
}
}
@@ -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)
}
}
}
@@ -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)
}
}
}