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 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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -45,6 +45,6 @@ fun Routing.setupAuthMethods(
|
||||
setupSavedMovie(savedMovieFactory)
|
||||
setupTagsRouting(userTagFactory, movieTagFactory)
|
||||
setupUpdates(updateFetcherFactory)
|
||||
setupChat(chatRepositoryFactory)
|
||||
setupChat(chatRepositoryFactory, processUserMessageUseCase)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-6
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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.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,
|
||||
|
||||
+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
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+1
@@ -21,4 +21,5 @@ object RouteIds {
|
||||
const val REGISTER = "register"
|
||||
const val EDIT_PASSWORD = "editPassword"
|
||||
const val TAG_OVERVIEW = "tags"
|
||||
const val CHAT = "chat"
|
||||
}
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
|
||||
+6
@@ -82,6 +82,12 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
TextPreference(
|
||||
text = "chat",
|
||||
modifier = Modifier.clickable { comp.showChat() }
|
||||
)
|
||||
}
|
||||
}
|
||||
section {
|
||||
val needAuth = tokenInfo == null
|
||||
|
||||
+19
-25
@@ -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",
|
||||
|
||||
+27
-8
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
+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
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
+17
-17
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+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.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
|
||||
}
|
||||
+33
-29
@@ -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 {
|
||||
|
||||
+21
-22
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-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