chat ui
This commit is contained in:
+5
-11
@@ -2,33 +2,27 @@ package ru.shadowsparky.vbox.backend.data.chat
|
|||||||
|
|
||||||
import org.koin.core.annotation.Factory
|
import org.koin.core.annotation.Factory
|
||||||
import ru.shadowsparky.chat.backend.domain.MessageStorage
|
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.ChatRepository
|
||||||
import ru.shadowsparky.chat.domain.Message
|
import ru.shadowsparky.chat.domain.Message
|
||||||
|
|
||||||
@Factory
|
@Factory
|
||||||
class BackendChatRepositoryFactory(
|
class BackendChatRepositoryFactory(private val storage: MessageStorage) {
|
||||||
private val storage: MessageStorage,
|
|
||||||
private val processUserMessageUseCase: ProcessUserMessageUseCase
|
|
||||||
) {
|
|
||||||
fun create(userId: Long): BackendChatRepository {
|
fun create(userId: Long): BackendChatRepository {
|
||||||
return BackendChatRepository(userId, storage, processUserMessageUseCase)
|
return BackendChatRepository(userId, storage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BackendChatRepository(
|
class BackendChatRepository(
|
||||||
private val userId: Long,
|
private val userId: Long,
|
||||||
private val storage: MessageStorage,
|
private val storage: MessageStorage
|
||||||
private val processUserMessageUseCase: ProcessUserMessageUseCase
|
|
||||||
) : ChatRepository {
|
) : ChatRepository {
|
||||||
|
|
||||||
override suspend fun query(): List<Message> {
|
override suspend fun query(afterId: Long?): List<Message> {
|
||||||
val history = storage.query(userId, limit = 10)
|
val history = storage.query(userId, afterId, limit = 10)
|
||||||
return history.sortedBy { it.timestamp }
|
return history.sortedBy { it.timestamp }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun send(message: Message) {
|
override suspend fun send(message: Message) {
|
||||||
storage.insert(userId, message)
|
storage.insert(userId, message)
|
||||||
processUserMessageUseCase.execute(userId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -14,9 +14,13 @@ class SqlMessageStorage(
|
|||||||
) : MessageStorage {
|
) : MessageStorage {
|
||||||
private val queries = appDatabase.chatQueries
|
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 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 ->
|
dbMessages.map { dbMsg ->
|
||||||
Message(
|
Message(
|
||||||
id = dbMsg.id,
|
id = dbMsg.id,
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import org.koin.core.annotation.Single
|
|||||||
import ru.shadowsparky.backend.data.TokenVerifier
|
import ru.shadowsparky.backend.data.TokenVerifier
|
||||||
import ru.shadowsparky.backend.domain.JwtInfo
|
import ru.shadowsparky.backend.domain.JwtInfo
|
||||||
import ru.shadowsparky.backend.domain.LoginVerifier
|
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.BackendRemoteEventHandler
|
||||||
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory
|
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory
|
||||||
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
|
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.chat.BackendChatRepositoryFactory
|
||||||
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
|
||||||
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
|
||||||
@@ -27,7 +27,8 @@ class RoutingEntryPoint(
|
|||||||
val movieTagFactory: MovieTagRepositoryFactory,
|
val movieTagFactory: MovieTagRepositoryFactory,
|
||||||
val userTagFactory: UserTagRepositoryFactory,
|
val userTagFactory: UserTagRepositoryFactory,
|
||||||
val updateFetcherFactory: BackendUpdateFetcherFactory,
|
val updateFetcherFactory: BackendUpdateFetcherFactory,
|
||||||
val chatRepositoryFactory: BackendChatRepositoryFactory
|
val chatRepositoryFactory: BackendChatRepositoryFactory,
|
||||||
|
val processUserMessageUseCase: ProcessUserMessageUseCase
|
||||||
)
|
)
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
|
|||||||
+1
-1
@@ -45,6 +45,6 @@ fun Routing.setupAuthMethods(
|
|||||||
setupSavedMovie(savedMovieFactory)
|
setupSavedMovie(savedMovieFactory)
|
||||||
setupTagsRouting(userTagFactory, movieTagFactory)
|
setupTagsRouting(userTagFactory, movieTagFactory)
|
||||||
setupUpdates(updateFetcherFactory)
|
setupUpdates(updateFetcherFactory)
|
||||||
setupChat(chatRepositoryFactory)
|
setupChat(chatRepositoryFactory, processUserMessageUseCase)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-6
@@ -1,23 +1,35 @@
|
|||||||
package ru.shadowsparky.vbox.backend.presentation.routing
|
package ru.shadowsparky.vbox.backend.presentation.routing
|
||||||
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.server.request.receive
|
import io.ktor.server.request.receive
|
||||||
import io.ktor.server.response.respond
|
import io.ktor.server.response.respond
|
||||||
import io.ktor.server.routing.Route
|
import io.ktor.server.routing.Route
|
||||||
import io.ktor.server.routing.get
|
import io.ktor.server.routing.get
|
||||||
import io.ktor.server.routing.post
|
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.data.chat.BackendChatRepositoryFactory
|
||||||
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
|
||||||
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
|
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") {
|
get("$DYNAMIC_PREFIX/chat/query") {
|
||||||
val repository = chatRepositoryFactory.create(call.obtainUserId())
|
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") {
|
post("$DYNAMIC_PREFIX/chat/send") {
|
||||||
val repository = chatRepositoryFactory.create(call.obtainUserId())
|
val userId = call.obtainUserId()
|
||||||
repository.send(call.receive())
|
val info = call.receive<Message>()
|
||||||
call.respond(HttpStatusCode.OK)
|
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
|
SELECT id, role, content, created_at
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE chat_id = :chatId
|
WHERE chat_id = :chatId
|
||||||
ORDER BY created_at DESC
|
AND (:afterId IS NULL OR id > :afterId)
|
||||||
|
ORDER BY id ASC
|
||||||
LIMIT :limit;
|
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"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||||
<base-config>
|
<base-config cleartextTrafficPermitted="true">
|
||||||
<trust-anchors>
|
<trust-anchors>
|
||||||
<certificates src="system" />
|
<certificates src="system" />
|
||||||
<certificates
|
<certificates
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ kotlin {
|
|||||||
implementation(project(":libs:http-client"))
|
implementation(project(":libs:http-client"))
|
||||||
implementation(project(":feature:video-player"))
|
implementation(project(":feature:video-player"))
|
||||||
implementation(project(":feature:updater:updater-client"))
|
implementation(project(":feature:updater:updater-client"))
|
||||||
|
implementation(project(":feature:chat:chat-client"))
|
||||||
|
implementation(project(":feature:chat:chat-common"))
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
|
|||||||
+43
@@ -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>()
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -12,6 +12,7 @@ import androidx.compose.runtime.CompositionLocalProvider
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.seiko.imageloader.LocalImageLoader
|
import com.seiko.imageloader.LocalImageLoader
|
||||||
|
import ru.shadowsparky.chat.presentation.ChatScreen
|
||||||
import ru.shadowsparky.koin
|
import ru.shadowsparky.koin
|
||||||
import ru.shadowsparky.ui.components.BackButton
|
import ru.shadowsparky.ui.components.BackButton
|
||||||
import ru.shadowsparky.ui.nav.Nav
|
import ru.shadowsparky.ui.nav.Nav
|
||||||
@@ -63,6 +64,7 @@ private fun NavInternal(root: VBoxRootComponent) {
|
|||||||
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
|
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
|
||||||
is Child.LoadingChild -> LoadingScreen(child.component)
|
is Child.LoadingChild -> LoadingScreen(child.component)
|
||||||
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
|
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
|
||||||
|
is Child.ChatChild -> ChatScreen(child.component)
|
||||||
is Child.VideosChild,
|
is Child.VideosChild,
|
||||||
is Child.SearchChild,
|
is Child.SearchChild,
|
||||||
is Child.SettingsChild,
|
is Child.SettingsChild,
|
||||||
|
|||||||
+30
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -1,5 +1,6 @@
|
|||||||
package ru.shadowsparky.vbox.shared.presentation.nav
|
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.list.VideoScreenComponent
|
||||||
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingComponent
|
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingComponent
|
||||||
import ru.shadowsparky.vbox.shared.presentation.offline.OfflineVideoComponent
|
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 LoadingChild(val component: LoadingComponent) : Child()
|
||||||
class PlayerChild(val component: PlayerComponent) : Child()
|
class PlayerChild(val component: PlayerComponent) : Child()
|
||||||
class TagChild(val component: TagComponent) : Child()
|
class TagChild(val component: TagComponent) : Child()
|
||||||
|
class ChatChild(val component: ChatComponent) : Child()
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -21,6 +21,9 @@ sealed class Route : ru.shadowsparky.ui.nav.Route() {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class OfflineVideo(override val routeId: String = RouteIds.OFFLINE_VIDEO) : Route()
|
data class OfflineVideo(override val routeId: String = RouteIds.OFFLINE_VIDEO) : Route()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Chat(override val routeId: String = RouteIds.CHAT) : Route()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Loading(
|
data class Loading(
|
||||||
val authRequired: Boolean = true,
|
val authRequired: Boolean = true,
|
||||||
|
|||||||
+1
@@ -21,4 +21,5 @@ object RouteIds {
|
|||||||
const val REGISTER = "register"
|
const val REGISTER = "register"
|
||||||
const val EDIT_PASSWORD = "editPassword"
|
const val EDIT_PASSWORD = "editPassword"
|
||||||
const val TAG_OVERVIEW = "tags"
|
const val TAG_OVERVIEW = "tags"
|
||||||
|
const val CHAT = "chat"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -37,12 +37,11 @@ class SettingsComponent(
|
|||||||
val doRegister: () -> Unit,
|
val doRegister: () -> Unit,
|
||||||
val editPassword: () -> Unit,
|
val editPassword: () -> Unit,
|
||||||
val showOffline: () -> Unit,
|
val showOffline: () -> Unit,
|
||||||
val showTags: () -> Unit
|
val showTags: () -> Unit,
|
||||||
|
val showChat: () -> Unit
|
||||||
) : ComponentContext by context {
|
) : ComponentContext by context {
|
||||||
val updateComponent = updateComponentFactory.create(childContext("UpdateComponent"))
|
val updateComponent = updateComponentFactory.create(childContext("UpdateComponent"))
|
||||||
|
|
||||||
val hasUpdateSupport = updateComponent.hasSupport
|
|
||||||
|
|
||||||
val serverInfo = serverConfigurationRepository.serverConfiguration.map { it.asHttpStr() }
|
val serverInfo = serverConfigurationRepository.serverConfiguration.map { it.asHttpStr() }
|
||||||
private val componentScope = createComponentScope()
|
private val componentScope = createComponentScope()
|
||||||
|
|
||||||
@@ -101,7 +100,8 @@ class SettingsComponentFactory(
|
|||||||
tokenStorage = tokenStorage,
|
tokenStorage = tokenStorage,
|
||||||
showOffline = { root.nav(Route.OfflineVideo()) },
|
showOffline = { root.nav(Route.OfflineVideo()) },
|
||||||
offlineSupport = offlineStorage != null,
|
offlineSupport = offlineStorage != null,
|
||||||
showTags = { root.nav(Route.Settings.TagOverview()) }
|
showTags = { root.nav(Route.Settings.TagOverview()) },
|
||||||
|
showChat = { root.nav(Route.Chat())}
|
||||||
)
|
)
|
||||||
return Child.SettingsChild(comp)
|
return Child.SettingsChild(comp)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -82,6 +82,12 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
TextPreference(
|
||||||
|
text = "chat",
|
||||||
|
modifier = Modifier.clickable { comp.showChat() }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
section {
|
section {
|
||||||
val needAuth = tokenInfo == null
|
val needAuth = tokenInfo == null
|
||||||
|
|||||||
+19
-25
@@ -1,31 +1,40 @@
|
|||||||
package ru.shadowsparky.chat.backend.data
|
package ru.shadowsparky.chat.backend.data
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
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.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.header
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import org.koin.core.annotation.Single
|
||||||
import org.koin.core.annotation.Factory
|
|
||||||
import ru.shadowsparky.chat.backend.domain.ChatService
|
import ru.shadowsparky.chat.backend.domain.ChatService
|
||||||
import ru.shadowsparky.chat.domain.Message
|
import ru.shadowsparky.chat.domain.Message
|
||||||
|
import ru.shadowsparky.http.domain.HttpClientFactory
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@Factory
|
@Single
|
||||||
class GigaChatService(
|
class GigaChatService(
|
||||||
private val tokenManager: GigaChatTokenManager,
|
private val tokenManager: GigaChatTokenManager,
|
||||||
private val json: Json
|
private val httpClientFactory: HttpClientFactory
|
||||||
) : ChatService {
|
) : 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 {
|
override suspend fun getCompletion(userId: Long, messages: List<Message>): String {
|
||||||
val clientId = "vbox-$userId"
|
val clientId = "vbox-$userId"
|
||||||
@@ -44,21 +53,6 @@ class GigaChatService(
|
|||||||
?: error("unexpected response $response")
|
?: 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
|
@Serializable
|
||||||
private data class GigaChatRequest(
|
private data class GigaChatRequest(
|
||||||
val model: String = "GigaChat-2",
|
val model: String = "GigaChat-2",
|
||||||
|
|||||||
+27
-8
@@ -1,13 +1,13 @@
|
|||||||
package ru.shadowsparky.chat.backend.data
|
package ru.shadowsparky.chat.backend.data
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
import io.ktor.client.call.body
|
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.forms.FormDataContent
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.header
|
||||||
import io.ktor.client.request.post
|
import io.ktor.client.request.post
|
||||||
import io.ktor.client.request.setBody
|
import io.ktor.client.request.setBody
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpHeaders
|
|
||||||
import io.ktor.http.Parameters
|
import io.ktor.http.Parameters
|
||||||
import io.ktor.http.contentType
|
import io.ktor.http.contentType
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -17,16 +17,35 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.koin.core.annotation.Single
|
import org.koin.core.annotation.Single
|
||||||
import ru.shadowsparky.backend.data.EnvFetcher
|
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
|
import java.util.UUID
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
class GigaChatTokenManager(
|
class GigaChatTokenManager(
|
||||||
private val authClient: HttpClient,
|
|
||||||
private val json: Json,
|
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 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 val mutex = Mutex()
|
||||||
private var cachedToken: String? = null
|
private var cachedToken: String? = null
|
||||||
|
|
||||||
@@ -34,14 +53,14 @@ class GigaChatTokenManager(
|
|||||||
cachedToken ?: fetchNewToken()
|
cachedToken ?: fetchNewToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun forceRefreshToken(): String = mutex.withLock {
|
suspend fun forceRefreshToken(): String {
|
||||||
fetchNewToken()
|
return refreshTokenRunner.runOrJoin {
|
||||||
|
BearerTokens(fetchNewToken(), null)
|
||||||
|
}?.accessToken ?: throw BadRequestException("Unable to refresh access token")
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchNewToken(): String {
|
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") {
|
val responseString: String = authClient.post("https://ngw.devices.sberbank.ru:9443/api/v2/oauth") {
|
||||||
header(HttpHeaders.Authorization, auth)
|
|
||||||
header("RqUID", UUID.randomUUID().toString())
|
header("RqUID", UUID.randomUUID().toString())
|
||||||
contentType(ContentType.Application.FormUrlEncoded)
|
contentType(ContentType.Application.FormUrlEncoded)
|
||||||
setBody(FormDataContent(Parameters.build {
|
setBody(FormDataContent(Parameters.build {
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ package ru.shadowsparky.chat.backend.domain
|
|||||||
import ru.shadowsparky.chat.domain.Message
|
import ru.shadowsparky.chat.domain.Message
|
||||||
|
|
||||||
interface MessageStorage {
|
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
|
suspend fun insert(userId: Long, message: Message): Long
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ class ProcessUserMessageUseCase(
|
|||||||
private val chatService: ChatService
|
private val chatService: ChatService
|
||||||
) {
|
) {
|
||||||
suspend fun execute(userId: Long): Message {
|
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 sortedHistory = history.sortedBy { it.timestamp }
|
||||||
val systemMessage = Message(
|
val systemMessage = Message(
|
||||||
role = ChatRoles.SYSTEM,
|
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>
|
||||||
+105
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+222
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -1,6 +1,17 @@
|
|||||||
package ru.shadowsparky.chat.domain
|
package ru.shadowsparky.chat.domain
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
interface ChatRepository {
|
interface ChatRepository {
|
||||||
suspend fun query(): List<Message>
|
suspend fun query(afterId: Long?): List<Message>
|
||||||
suspend fun send(message: Message)
|
suspend fun send(message: Message)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ARG_AFTER_ID = "after_id"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MessagesResponse(
|
||||||
|
val content: List<Message>
|
||||||
|
)
|
||||||
|
|||||||
+17
-17
@@ -1,22 +1,22 @@
|
|||||||
package ru.shadowsparky.updater.presentation
|
package ru.shadowsparky.updater.presentation
|
||||||
|
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.Res
|
import androiddev.feature.updater.updater_client.generated.resources.Res
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_close
|
import androiddev.feature.updater.updater_client.generated.resources.updater_btn_close
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_download
|
import androiddev.feature.updater.updater_client.generated.resources.updater_btn_download
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_grant
|
import androiddev.feature.updater.updater_client.generated.resources.updater_btn_grant
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_install
|
import androiddev.feature.updater.updater_client.generated.resources.updater_btn_install
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_btn_retry
|
import androiddev.feature.updater.updater_client.generated.resources.updater_btn_retry
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_empty
|
import androiddev.feature.updater.updater_client.generated.resources.updater_changelog_empty
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_changelog_title
|
import androiddev.feature.updater.updater_client.generated.resources.updater_changelog_title
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_desc_permission
|
import androiddev.feature.updater.updater_client.generated.resources.updater_desc_permission
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_download_progress
|
import androiddev.feature.updater.updater_client.generated.resources.updater_download_progress
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_available
|
import androiddev.feature.updater.updater_client.generated.resources.updater_title_available
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_completed
|
import androiddev.feature.updater.updater_client.generated.resources.updater_title_completed
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_downloading
|
import androiddev.feature.updater.updater_client.generated.resources.updater_title_downloading
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_error
|
import androiddev.feature.updater.updater_client.generated.resources.updater_title_error
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_not_found
|
import androiddev.feature.updater.updater_client.generated.resources.updater_title_not_found
|
||||||
import androiddev.libs.updater.updater_client.generated.resources.updater_title_permission
|
import androiddev.feature.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.updater_title_ready
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
package ru.shadowsparky.videoplayer
|
package ru.shadowsparky.videoplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androiddev.libs.video_player.generated.resources.Res
|
import androiddev.feature.video_player.generated.resources.Res
|
||||||
import androiddev.libs.video_player.generated.resources.player_audio_track
|
import androiddev.feature.video_player.generated.resources.player_audio_track
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
|||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
package ru.shadowsparky.videoplayer.ui
|
package ru.shadowsparky.videoplayer.ui
|
||||||
|
|
||||||
import androiddev.libs.video_player.generated.resources.Res
|
import androiddev.feature.video_player.generated.resources.Res
|
||||||
import androiddev.libs.video_player.generated.resources.lock_24px
|
import androiddev.feature.video_player.generated.resources.lock_24px
|
||||||
import androiddev.libs.video_player.generated.resources.lock_open_24px
|
import androiddev.feature.video_player.generated.resources.lock_open_24px
|
||||||
import androiddev.libs.video_player.generated.resources.music_note_24px
|
import androiddev.feature.video_player.generated.resources.music_note_24px
|
||||||
import androiddev.libs.video_player.generated.resources.pause_24px
|
import androiddev.feature.video_player.generated.resources.pause_24px
|
||||||
import androiddev.libs.video_player.generated.resources.play_arrow_24px
|
import androiddev.feature.video_player.generated.resources.play_arrow_24px
|
||||||
import androiddev.libs.video_player.generated.resources.replay_24px
|
import androiddev.feature.video_player.generated.resources.replay_24px
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
|
|||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
package ru.shadowsparky.videoplayer.ui
|
package ru.shadowsparky.videoplayer.ui
|
||||||
|
|
||||||
import androiddev.libs.video_player.generated.resources.Res
|
import androiddev.feature.video_player.generated.resources.Res
|
||||||
import androiddev.libs.video_player.generated.resources.player_error_summary
|
import androiddev.feature.video_player.generated.resources.player_error_summary
|
||||||
import androiddev.libs.video_player.generated.resources.player_error_title
|
import androiddev.feature.video_player.generated.resources.player_error_title
|
||||||
import androiddev.libs.video_player.generated.resources.player_retry
|
import androiddev.feature.video_player.generated.resources.player_retry
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
|||||||
+30
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-26
@@ -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.Configuration
|
||||||
import org.koin.core.annotation.Module
|
import org.koin.core.annotation.Module
|
||||||
import org.koin.core.annotation.Single
|
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.data.RefreshTokenRunner
|
||||||
|
import ru.shadowsparky.http.domain.HttpClientFactory
|
||||||
import ru.shadowsparky.http.domain.HttpException
|
import ru.shadowsparky.http.domain.HttpException
|
||||||
import ru.shadowsparky.http.domain.LogoutAction
|
import ru.shadowsparky.http.domain.LogoutAction
|
||||||
import ru.shadowsparky.http.domain.RefreshTokenAction
|
import ru.shadowsparky.http.domain.RefreshTokenAction
|
||||||
@@ -25,8 +24,15 @@ import ru.shadowsparky.koin
|
|||||||
class HttpModule {
|
class HttpModule {
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
fun createHttpClient(settings: HttpClientSettings): HttpClient {
|
fun createHttpClient(
|
||||||
return HttpClientFactory(settings).create()
|
httpClientFactory: HttpClientFactory,
|
||||||
|
bearerAuthProvider: BearerAuthProvider
|
||||||
|
): HttpClient {
|
||||||
|
return httpClientFactory.create(
|
||||||
|
bearerAuthProvider,
|
||||||
|
setupWebSocket = true,
|
||||||
|
setupValidator = true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Single
|
@Single
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
package ru.shadowsparky.http.data
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClient
|
|
||||||
|
|
||||||
expect class HttpClientFactory(settings: HttpClientSettings) {
|
|
||||||
fun create(): HttpClient
|
|
||||||
}
|
|
||||||
+16
-12
@@ -24,10 +24,13 @@ import kotlinx.serialization.json.jsonPrimitive
|
|||||||
import org.koin.core.annotation.Factory
|
import org.koin.core.annotation.Factory
|
||||||
import ru.shadowsparky.domain.AppConfig
|
import ru.shadowsparky.domain.AppConfig
|
||||||
import ru.shadowsparky.domain.Log
|
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.HttpException
|
||||||
import ru.shadowsparky.http.domain.UserAgentProvider
|
import ru.shadowsparky.http.domain.UserAgentProvider
|
||||||
import ru.shadowsparky.koin
|
import ru.shadowsparky.koin
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.DurationUnit
|
||||||
|
|
||||||
private const val MESSAGE_LONG = "message"
|
private const val MESSAGE_LONG = "message"
|
||||||
private const val MESSAGE = "msg"
|
private const val MESSAGE = "msg"
|
||||||
@@ -36,20 +39,23 @@ private const val MESSAGE = "msg"
|
|||||||
class HttpClientSettings(
|
class HttpClientSettings(
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
private val userAgentProvider: UserAgentProvider?,
|
private val userAgentProvider: UserAgentProvider?,
|
||||||
private val bearerAuthProvider: BearerAuthProvider,
|
|
||||||
private val appenderList: List<ClientKtorExtension>,
|
|
||||||
private val log: Log,
|
private val log: Log,
|
||||||
private val appConfig: AppConfig?
|
private val appConfig: AppConfig?
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun setup(
|
fun setup(
|
||||||
clientConfig: HttpClientConfig<*>
|
clientConfig: HttpClientConfig<*>,
|
||||||
|
bearerAuthProvider: BearerAuthProvider?,
|
||||||
|
setupWebSocket: Boolean,
|
||||||
|
setupValidator: Boolean
|
||||||
) = with(clientConfig) {
|
) = with(clientConfig) {
|
||||||
|
if (bearerAuthProvider != null) {
|
||||||
install(Auth) {
|
install(Auth) {
|
||||||
providers.add(bearerAuthProvider)
|
providers.add(bearerAuthProvider)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if (setupValidator) {
|
||||||
expectSuccess = false
|
expectSuccess = false
|
||||||
|
|
||||||
HttpResponseValidator {
|
HttpResponseValidator {
|
||||||
validateResponse {
|
validateResponse {
|
||||||
val code = it.status.value
|
val code = it.status.value
|
||||||
@@ -68,7 +74,7 @@ class HttpClientSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
install(Logging) {
|
install(Logging) {
|
||||||
val isDebug = koin.getOrNull<AppConfig>()?.debug ?: true
|
val isDebug = koin.getOrNull<AppConfig>()?.debug ?: true
|
||||||
if (isDebug) {
|
if (isDebug) {
|
||||||
@@ -85,9 +91,10 @@ class HttpClientSettings(
|
|||||||
json(json)
|
json(json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (setupWebSocket) {
|
||||||
install(WebSockets) {
|
install(WebSockets) {
|
||||||
// как часто слать ping кадры
|
pingIntervalMillis = HTTP_PING_TIMEOUT_SECS.seconds.toLong(DurationUnit.MILLISECONDS)
|
||||||
pingIntervalMillis = 30_000 // или: pingInterval = 20.seconds (в новых API)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultRequest {
|
defaultRequest {
|
||||||
@@ -98,14 +105,11 @@ class HttpClientSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Таймауты на уровне Ktor
|
|
||||||
install(HttpTimeout) {
|
install(HttpTimeout) {
|
||||||
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
requestTimeoutMillis = HttpTimeoutConfig.INFINITE_TIMEOUT_MS
|
||||||
socketTimeoutMillis = 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 {
|
private val AppConfig.userAgent: String get() = buildString {
|
||||||
|
|||||||
+19
-20
@@ -1,39 +1,38 @@
|
|||||||
package ru.shadowsparky.http.data
|
package ru.shadowsparky.http.data
|
||||||
|
|
||||||
import io.ktor.client.plugins.auth.providers.BearerTokens
|
import io.ktor.client.plugins.auth.providers.BearerTokens
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.completeWith
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import org.koin.core.annotation.Factory
|
import org.koin.core.annotation.Factory
|
||||||
import ru.shadowsparky.domain.DispatcherProvider
|
|
||||||
|
|
||||||
|
|
||||||
@Factory
|
@Factory
|
||||||
class RefreshTokenRunner(private val dispatcherProvider: DispatcherProvider) {
|
class RefreshTokenRunner {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.io)
|
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
private var inFlight: Deferred<BearerTokens?>? = null
|
private var inFlight: CompletableDeferred<BearerTokens?>? = null
|
||||||
|
|
||||||
suspend fun runOrJoin(block: suspend () -> BearerTokens?): BearerTokens? {
|
suspend fun runOrJoin(block: suspend () -> BearerTokens?): BearerTokens? {
|
||||||
val deferred = lock.withLock {
|
val myDeferred = lock.withLock {
|
||||||
inFlight?.takeIf { it.isActive } ?: startNew(block).also { inFlight = it }
|
val current = inFlight
|
||||||
|
if (current != null) {
|
||||||
|
return current.await()
|
||||||
}
|
}
|
||||||
return deferred.await()
|
CompletableDeferred<BearerTokens?>().also { inFlight = it }
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
private fun startNew(block: suspend () -> BearerTokens?): Deferred<BearerTokens?> {
|
val tokens = block()
|
||||||
val d = scope.async(dispatcherProvider.io) { block() }
|
myDeferred.complete(tokens)
|
||||||
d.invokeOnCompletion {
|
return tokens
|
||||||
scope.launch {
|
} catch (e: Throwable) {
|
||||||
|
myDeferred.completeWith(Result.failure(e))
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
lock.withLock {
|
lock.withLock {
|
||||||
if (inFlight === d) inFlight = null
|
if (inFlight === myDeferred) {
|
||||||
|
inFlight = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return d
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
package ru.shadowsparky.http.domain
|
|
||||||
|
|
||||||
import io.ktor.client.HttpClientConfig
|
|
||||||
|
|
||||||
interface ClientKtorExtension {
|
|
||||||
fun append(clientConfig: HttpClientConfig<*>)
|
|
||||||
}
|
|
||||||
+15
@@ -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
|
||||||
|
}
|
||||||
-23
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+107
@@ -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-----
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
-12
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+20
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user