This commit is contained in:
2026-07-29 16:21:59 +03:00
parent b83a40f4bd
commit 6485f4ec26
41 changed files with 797 additions and 224 deletions
@@ -2,33 +2,27 @@ package ru.shadowsparky.vbox.backend.data.chat
import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.backend.domain.MessageStorage
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.Message
@Factory
class BackendChatRepositoryFactory(
private val storage: MessageStorage,
private val processUserMessageUseCase: ProcessUserMessageUseCase
) {
class BackendChatRepositoryFactory(private val storage: MessageStorage) {
fun create(userId: Long): BackendChatRepository {
return BackendChatRepository(userId, storage, processUserMessageUseCase)
return BackendChatRepository(userId, storage)
}
}
class BackendChatRepository(
private val userId: Long,
private val storage: MessageStorage,
private val processUserMessageUseCase: ProcessUserMessageUseCase
private val storage: MessageStorage
) : ChatRepository {
override suspend fun query(): List<Message> {
val history = storage.query(userId, limit = 10)
override suspend fun query(afterId: Long?): List<Message> {
val history = storage.query(userId, afterId, limit = 10)
return history.sortedBy { it.timestamp }
}
override suspend fun send(message: Message) {
storage.insert(userId, message)
processUserMessageUseCase.execute(userId)
}
}
@@ -14,9 +14,13 @@ class SqlMessageStorage(
) : MessageStorage {
private val queries = appDatabase.chatQueries
override suspend fun query(userId: Long, limit: Int): List<Message> = withContext(dispatcherProvider.io) {
override suspend fun query(userId: Long, afterId: Long?, limit: Int): List<Message> = withContext(dispatcherProvider.io) {
val chatId = queries.getChatIdByUserId(userId).executeAsOneOrNull() ?: return@withContext emptyList()
val dbMessages = queries.getChatContext(chatId = chatId, limit = limit.toLong()).executeAsList()
val dbMessages = queries.getChatContext(
chatId = chatId,
afterId = afterId,
limit = limit.toLong()
).executeAsList()
dbMessages.map { dbMsg ->
Message(
id = dbMsg.id,
@@ -5,10 +5,10 @@ import org.koin.core.annotation.Single
import ru.shadowsparky.backend.data.TokenVerifier
import ru.shadowsparky.backend.domain.JwtInfo
import ru.shadowsparky.backend.domain.LoginVerifier
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
import ru.shadowsparky.vbox.backend.data.BackendRemoteEventHandler
import ru.shadowsparky.vbox.backend.data.BackendUpdateFetcherFactory
import ru.shadowsparky.vbox.backend.data.auth.AuthTokenRepositoryFactory
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepository
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory
import ru.shadowsparky.vbox.backend.data.tags.MovieTagRepositoryFactory
import ru.shadowsparky.vbox.backend.data.tags.UserTagRepositoryFactory
@@ -27,7 +27,8 @@ class RoutingEntryPoint(
val movieTagFactory: MovieTagRepositoryFactory,
val userTagFactory: UserTagRepositoryFactory,
val updateFetcherFactory: BackendUpdateFetcherFactory,
val chatRepositoryFactory: BackendChatRepositoryFactory
val chatRepositoryFactory: BackendChatRepositoryFactory,
val processUserMessageUseCase: ProcessUserMessageUseCase
)
@Single
@@ -45,6 +45,6 @@ fun Routing.setupAuthMethods(
setupSavedMovie(savedMovieFactory)
setupTagsRouting(userTagFactory, movieTagFactory)
setupUpdates(updateFetcherFactory)
setupChat(chatRepositoryFactory)
setupChat(chatRepositoryFactory, processUserMessageUseCase)
}
}
@@ -1,23 +1,35 @@
package ru.shadowsparky.vbox.backend.presentation.routing
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import ru.shadowsparky.chat.backend.domain.ProcessUserMessageUseCase
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.ChatRoles
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.chat.domain.MessagesResponse
import ru.shadowsparky.http.domain.BadRequestException
import ru.shadowsparky.vbox.backend.data.chat.BackendChatRepositoryFactory
import ru.shadowsparky.vbox.backend.presentation.obtainUserId
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
fun Route.setupChat(chatRepositoryFactory: BackendChatRepositoryFactory) {
fun Route.setupChat(
chatRepositoryFactory: BackendChatRepositoryFactory,
processUserMessageUseCase: ProcessUserMessageUseCase
) {
get("$DYNAMIC_PREFIX/chat/query") {
val repository = chatRepositoryFactory.create(call.obtainUserId())
call.respond(repository.query())
val afterId = call.queryParameters[ChatRepository.ARG_AFTER_ID]?.toLong()
call.respond(MessagesResponse(repository.query(afterId)))
}
post("$DYNAMIC_PREFIX/chat/send") {
val repository = chatRepositoryFactory.create(call.obtainUserId())
repository.send(call.receive())
call.respond(HttpStatusCode.OK)
val userId = call.obtainUserId()
val info = call.receive<Message>()
if (info.role != ChatRoles.USER) throw BadRequestException("invalid role")
val repository = chatRepositoryFactory.create(userId)
repository.send(info)
call.respond(processUserMessageUseCase.execute(userId))
}
}
@@ -18,5 +18,6 @@ getChatContext:
SELECT id, role, content, created_at
FROM messages
WHERE chat_id = :chatId
ORDER BY created_at DESC
AND (:afterId IS NULL OR id > :afterId)
ORDER BY id ASC
LIMIT :limit;
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
</manifest>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools">
<base-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates
+2
View File
@@ -27,6 +27,8 @@ kotlin {
implementation(project(":libs:http-client"))
implementation(project(":feature:video-player"))
implementation(project(":feature:updater:updater-client"))
implementation(project(":feature:chat:chat-client"))
implementation(project(":feature:chat:chat-common"))
}
androidMain.dependencies {
implementation(libs.androidx.appcompat)
@@ -0,0 +1,43 @@
package ru.shadowsparky.vbox.shared.data
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import org.koin.core.annotation.Factory
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.domain.Message
import ru.shadowsparky.chat.domain.MessagesResponse
import ru.shadowsparky.vbox.shared.domain.DYNAMIC_PREFIX
import ru.shadowsparky.vbox.shared.domain.ServerConfigurationRepository
import ru.shadowsparky.vbox.shared.domain.getServerConfiguration
@Factory
class RemoteChatRepository(
private val httpClient: HttpClient,
private val serverConfigurationRepository: ServerConfigurationRepository
) : ChatRepository {
override suspend fun query(afterId: Long?): List<Message> {
val args: Map<String, String> = if (afterId == null) {
emptyMap()
} else {
mapOf(ChatRepository.ARG_AFTER_ID to afterId.toString())
}
return httpClient.get<MessagesResponse>(
"$DYNAMIC_PREFIX/chat/query",
args,
serverConfigurationRepository.getServerConfiguration()
).content
}
override suspend fun send(message: Message) {
httpClient.post {
setBody(message)
serverConfigurationRepository.getServerConfiguration().apply(
this,
"$DYNAMIC_PREFIX/chat/send"
)
}.body<Message>()
}
}
@@ -12,6 +12,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.seiko.imageloader.LocalImageLoader
import ru.shadowsparky.chat.presentation.ChatScreen
import ru.shadowsparky.koin
import ru.shadowsparky.ui.components.BackButton
import ru.shadowsparky.ui.nav.Nav
@@ -63,6 +64,7 @@ private fun NavInternal(root: VBoxRootComponent) {
is Child.EditPasswordChild -> EditPasswordScreen(child.component)
is Child.LoadingChild -> LoadingScreen(child.component)
is Child.OfflineVideoChild -> OfflineVideoScreen(child.component)
is Child.ChatChild -> ChatScreen(child.component)
is Child.VideosChild,
is Child.SearchChild,
is Child.SettingsChild,
@@ -0,0 +1,30 @@
package ru.shadowsparky.vbox.shared.presentation.chat
import com.arkivanov.decompose.ComponentContext
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Named
import ru.shadowsparky.chat.domain.ChatRepository
import ru.shadowsparky.chat.presentation.ChatComponent
import ru.shadowsparky.ui.nav.ComponentFactory
import ru.shadowsparky.ui.nav.RouteNavigator
import ru.shadowsparky.vbox.shared.presentation.nav.Child
import ru.shadowsparky.vbox.shared.presentation.nav.Route
import ru.shadowsparky.vbox.shared.presentation.nav.RouteIds
@Factory
@Named(RouteIds.CHAT)
class ChatComponentFactory(
private val chatRepository: ChatRepository
) : ComponentFactory<Child.ChatChild, Route.Chat, Route> {
override fun create(
route: Route.Chat,
child: ComponentContext,
root: RouteNavigator<Route>
): Child.ChatChild {
val comp = ChatComponent(
child,
chatRepository
)
return Child.ChatChild(comp)
}
}
@@ -1,5 +1,6 @@
package ru.shadowsparky.vbox.shared.presentation.nav
import ru.shadowsparky.chat.presentation.ChatComponent
import ru.shadowsparky.vbox.shared.presentation.list.VideoScreenComponent
import ru.shadowsparky.vbox.shared.presentation.loading.LoadingComponent
import ru.shadowsparky.vbox.shared.presentation.offline.OfflineVideoComponent
@@ -32,4 +33,5 @@ sealed class Child : ru.shadowsparky.ui.nav.Child {
class LoadingChild(val component: LoadingComponent) : Child()
class PlayerChild(val component: PlayerComponent) : Child()
class TagChild(val component: TagComponent) : Child()
class ChatChild(val component: ChatComponent) : Child()
}
@@ -21,6 +21,9 @@ sealed class Route : ru.shadowsparky.ui.nav.Route() {
@Serializable
data class OfflineVideo(override val routeId: String = RouteIds.OFFLINE_VIDEO) : Route()
@Serializable
data class Chat(override val routeId: String = RouteIds.CHAT) : Route()
@Serializable
data class Loading(
val authRequired: Boolean = true,
@@ -21,4 +21,5 @@ object RouteIds {
const val REGISTER = "register"
const val EDIT_PASSWORD = "editPassword"
const val TAG_OVERVIEW = "tags"
const val CHAT = "chat"
}
@@ -37,12 +37,11 @@ class SettingsComponent(
val doRegister: () -> Unit,
val editPassword: () -> Unit,
val showOffline: () -> Unit,
val showTags: () -> Unit
val showTags: () -> Unit,
val showChat: () -> Unit
) : ComponentContext by context {
val updateComponent = updateComponentFactory.create(childContext("UpdateComponent"))
val hasUpdateSupport = updateComponent.hasSupport
val serverInfo = serverConfigurationRepository.serverConfiguration.map { it.asHttpStr() }
private val componentScope = createComponentScope()
@@ -101,7 +100,8 @@ class SettingsComponentFactory(
tokenStorage = tokenStorage,
showOffline = { root.nav(Route.OfflineVideo()) },
offlineSupport = offlineStorage != null,
showTags = { root.nav(Route.Settings.TagOverview()) }
showTags = { root.nav(Route.Settings.TagOverview()) },
showChat = { root.nav(Route.Chat())}
)
return Child.SettingsChild(comp)
}
@@ -82,6 +82,12 @@ fun SettingsContent(comp: SettingsComponent, paddingValues: PaddingValues) {
)
}
}
item {
TextPreference(
text = "chat",
modifier = Modifier.clickable { comp.showChat() }
)
}
}
section {
val needAuth = tokenInfo == null