Compare commits

...

10 Commits

Author SHA1 Message Date
Codex
4939754de8 android: stabilize DI graph and production api config
Some checks are pending
CI / test (push) Has started running
2026-03-08 23:02:16 +03:00
Codex
9d842c1d88 android: add chat/realtime tests and update android checklist 2026-03-08 22:34:41 +03:00
Codex
2dfad1a624 android: add chat list compose screen and chat placeholder navigation 2026-03-08 22:32:15 +03:00
Codex
21aa11c342 android: add websocket realtime manager and room event handling 2026-03-08 22:29:38 +03:00
Codex
d006998867 android: add chats api and cache-first repository sync 2026-03-08 22:27:50 +03:00
Codex
f838fe1d5d android: add room schema and chat list domain models 2026-03-08 22:26:08 +03:00
Codex
390dcb8b2d android: add unit tests for token store and auth login mapping 2026-03-08 22:22:04 +03:00
Codex
54b0d4eb8c android: add auth UI flow and auth-to-chats navigation 2026-03-08 22:21:51 +03:00
Codex
0ff838baf7 android: add auth network core, token store, and DI wiring 2026-03-08 22:21:24 +03:00
Codex
acdb83e04e android: 2026-03-08 23:48:24 +03:00
61 changed files with 2987 additions and 36 deletions

66
android/CHANGELOG.md Normal file
View File

@@ -0,0 +1,66 @@
# Android Changelog
## 2026-03-08
### Step 1 - Build and app wiring
- Added Android project plugin configuration for Hilt and Kotlin serialization.
- Added dependency repositories and explicit app module include in settings.
- Added app dependencies for Retrofit/OkHttp, DataStore, coroutines, Hilt, and unit testing.
- Enabled INTERNET permission and registered MessengerApplication in manifest.
- Added MessengerApplication with HiltAndroidApp.
### Step 2 - Network/data core + DI
- Fixed DTO/Auth API serialization annotations and endpoint declarations for `/api/v1/auth/login`, `/api/v1/auth/refresh`, `/api/v1/auth/me`.
- Implemented DataStore-based token persistence with a corrected `getTokens()` read path.
- Added auth network stack: bearer interceptor, 401 authenticator with refresh flow and retry guard.
- Added clean-layer contracts and implementations: `domain/common`, `domain/auth`, `data/auth/repository`.
- Wired dependencies with Hilt modules for DataStore, OkHttp/Retrofit, and repository bindings.
### Step 3 - Minimal auth UI and navigation
- Replaced Phase 0 placeholder UI with Compose auth flow (`AuthViewModel` + login screen).
- Added loading/error states for login and startup session restore.
- Added navigation graph: `AuthGraph (login)` to placeholder `Chats` screen after successful auth.
- Implemented automatic session restore on app start using stored tokens.
### Step 4 - Unit tests
- Added `DataStoreTokenRepositoryTest` for token save/read and clear behavior.
- Added `NetworkAuthRepositoryTest` for login success path and 401 -> `InvalidCredentials` error mapping.
### Step 5 - Chat Room models and persistence core
- Added domain chat model (`ChatItem`) for chat list rendering concerns.
- Added Room entities: `chats`, `users_short` with sort-friendly indices.
- Added `ChatDao` with `observeChats()`, `upsertChats()`, and transactional `clearAndReplaceChats()`.
- Added `MessengerDatabase` and Hilt database wiring (`DatabaseModule`).
### Step 6 - Chat API and repository sync
- Added chat REST API client for `/api/v1/chats` and `/api/v1/chats/{chat_id}`.
- Added chat DTOs and remote/local mappers (`ChatReadDto -> ChatEntity/UserShortEntity -> ChatItem`).
- Implemented `NetworkChatRepository` with cache-first flow strategy (Room first, then server sync).
- Added chat domain contracts/use-cases (`ChatRepository`, observe/refresh use-cases).
- Wired chat API/repository via Hilt modules.
### Step 7 - Realtime manager and chat list updates
- Added a unified realtime manager abstraction and WebSocket implementation for `/api/v1/realtime/ws?token=...`.
- Implemented auto-reconnect with exponential backoff and max cap.
- Added realtime event parser for `receive_message`, `message_updated`, `message_deleted`, `chat_updated`, `chat_deleted`, `user_online`, `user_offline`.
- Added use-case level realtime event handling that updates Room and triggers repository refreshes when needed.
- Wired realtime manager into DI.
### Step 8 - Chat list UI and navigation
- Added Chat List screen with tabs (`All` / `Archived`), local search filter, pull-to-refresh, and state handling (loading/empty/error).
- Added chat row rendering for unread badge, mention badge (`@`), pinned/muted marks, and message preview by media type.
- Added private chat presence display (`online` / `last seen recently` fallback).
- Connected Chat List to ViewModel/use-cases with no business logic inside composables.
- Added chat click navigation to placeholder `ChatScreen(chatId)`.
### Step 9 - Tests and checklist updates
- Added unit test for chat cache-first sync strategy (`NetworkChatRepositoryTest`).
- Added unit test for realtime event parsing (`RealtimeEventParserTest`).
- Added DAO test (`ChatDaoTest`) using in-memory Room + Robolectric.
- Updated Android checklist status in `docs/android-checklist.md`.
### Step 10 - Build stabilization fixes
- Switched Android API base URL to `https://chat.daemonlord.ru/`.
- Added cleartext traffic flag in manifest for local/dev compatibility.
- Fixed Hilt dependency cycle by separating refresh `AuthApiService` with a dedicated qualifier.
- Added `CoroutineDispatcher` DI provider and qualifier for repositories.
- Fixed Material3 experimental API opt-in and removed deprecated `StateFlow.distinctUntilChanged()` usage.

View File

@@ -1,6 +1,10 @@
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.kapt")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.dagger.hilt.android")
} }
android { android {
@@ -13,6 +17,7 @@ android {
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.1.0"
buildConfigField("String", "API_BASE_URL", "\"https://chat.daemonlord.ru/\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -41,6 +46,7 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
composeOptions { composeOptions {
@@ -57,12 +63,44 @@ android {
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.10.1") implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.compose.ui:ui:1.7.6") implementation("androidx.compose.ui:ui:1.7.6")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6") implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
implementation("androidx.compose.material3:material3:1.3.1") implementation("androidx.compose.material3:material3:1.3.1")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
implementation("com.google.dagger:hilt-android:2.52")
kapt("com.google.dagger:hilt-compiler:2.52")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("androidx.datastore:datastore-preferences-core:1.1.1")
testImplementation("androidx.room:room-testing:2.6.1")
testImplementation("androidx.test:core:1.6.1")
testImplementation("org.robolectric:robolectric:4.13")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6") debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6") debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6")
} }
kapt {
correctErrorTypes = true
}

View File

@@ -1,12 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".MessengerApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon" android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon" android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true" android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar"> android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@@ -3,18 +3,15 @@ package ru.daemonlord.messenger
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.fillMaxSize
import dagger.hilt.android.AndroidEntryPoint
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -30,14 +27,5 @@ class MainActivity : ComponentActivity() {
@Composable @Composable
private fun AppRoot() { private fun AppRoot() {
Column( MessengerNavHost()
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Benya Messenger Android", style = MaterialTheme.typography.headlineSmall)
Text(text = "Phase 0 skeleton is ready.", style = MaterialTheme.typography.bodyMedium)
}
} }

View File

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class MessengerApplication : Application()

View File

@@ -0,0 +1,37 @@
package ru.daemonlord.messenger.core.network
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import ru.daemonlord.messenger.core.token.TokenRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthHeaderInterceptor @Inject constructor(
private val tokenRepository: TokenRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val noAuthHeader = originalRequest.header(NO_AUTH_HEADER)
if (noAuthHeader == "true") {
val requestWithoutMarker = originalRequest.newBuilder()
.removeHeader(NO_AUTH_HEADER)
.build()
return chain.proceed(requestWithoutMarker)
}
val accessToken = runBlocking { tokenRepository.getTokens()?.accessToken }
val requestBuilder = originalRequest.newBuilder()
if (!accessToken.isNullOrBlank()) {
requestBuilder.header("Authorization", "Bearer $accessToken")
}
return chain.proceed(requestBuilder.build())
}
private companion object {
const val NO_AUTH_HEADER = "No-Auth"
}
}

View File

@@ -0,0 +1,81 @@
package ru.daemonlord.messenger.core.network
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.di.RefreshAuthApi
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenRefreshAuthenticator @Inject constructor(
private val tokenRepository: TokenRepository,
@RefreshAuthApi
private val refreshAuthApiService: AuthApiService,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= MAX_RETRIES) {
return null
}
val marker = response.request.header(NO_AUTH_HEADER)
if (marker == "true") {
return null
}
val refreshedAccessToken = synchronized(this) {
runBlocking {
val currentTokens = tokenRepository.getTokens() ?: return@runBlocking null
tryRefreshTokens(currentTokens)
}
} ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedAccessToken")
.build()
}
private suspend fun tryRefreshTokens(tokens: TokenBundle): String? {
return try {
val refreshed = refreshAuthApiService.refresh(
request = RefreshTokenRequestDto(refreshToken = tokens.refreshToken)
)
tokenRepository.saveTokens(
TokenBundle(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
refreshed.accessToken
} catch (_: IOException) {
null
} catch (_: Exception) {
tokenRepository.clearTokens()
null
}
}
private fun responseCount(response: Response): Int {
var current: Response? = response
var count = 1
while (current?.priorResponse != null) {
count++
current = current.priorResponse
}
return count
}
private companion object {
const val MAX_RETRIES = 2
const val NO_AUTH_HEADER = "No-Auth"
}
}

View File

@@ -0,0 +1,64 @@
package ru.daemonlord.messenger.core.token
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DataStoreTokenRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : TokenRepository {
override fun observeTokens(): Flow<TokenBundle?> = dataStore.data.map { preferences ->
preferences.toTokenBundleOrNull()
}
override suspend fun getTokens(): TokenBundle? {
return observeTokens().first()
}
override suspend fun saveTokens(tokens: TokenBundle) {
dataStore.edit { preferences ->
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
preferences[REFRESH_TOKEN_KEY] = tokens.refreshToken
preferences[SAVED_AT_KEY] = tokens.savedAtMillis
}
}
override suspend fun clearTokens() {
dataStore.edit { preferences ->
preferences.remove(ACCESS_TOKEN_KEY)
preferences.remove(REFRESH_TOKEN_KEY)
preferences.remove(SAVED_AT_KEY)
}
}
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
val access = this[ACCESS_TOKEN_KEY]
val refresh = this[REFRESH_TOKEN_KEY]
val savedAt = this[SAVED_AT_KEY]
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt == null) {
return null
}
return TokenBundle(
accessToken = access,
refreshToken = refresh,
savedAtMillis = savedAt,
)
}
private companion object {
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")
val SAVED_AT_KEY = longPreferencesKey("tokens_saved_at")
}
}

View File

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger.core.token
data class TokenBundle(
val accessToken: String,
val refreshToken: String,
val savedAtMillis: Long,
)

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.core.token
import kotlinx.coroutines.flow.Flow
interface TokenRepository {
fun observeTokens(): Flow<TokenBundle?>
suspend fun getTokens(): TokenBundle?
suspend fun saveTokens(tokens: TokenBundle)
suspend fun clearTokens()
}

View File

@@ -0,0 +1,23 @@
package ru.daemonlord.messenger.data.auth.api
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto
import retrofit2.http.Headers
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
interface AuthApiService {
@Headers("No-Auth: true")
@POST("/api/v1/auth/login")
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/refresh")
suspend fun refresh(@Body request: RefreshTokenRequestDto): TokenResponseDto
@GET("/api/v1/auth/me")
suspend fun me(): AuthUserDto
}

View File

@@ -0,0 +1,43 @@
package ru.daemonlord.messenger.data.auth.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequestDto(
val email: String,
val password: String,
)
@Serializable
data class RefreshTokenRequestDto(
@SerialName("refresh_token")
val refreshToken: String,
)
@Serializable
data class TokenResponseDto(
@SerialName("access_token")
val accessToken: String,
@SerialName("refresh_token")
val refreshToken: String,
@SerialName("token_type")
val tokenType: String,
)
@Serializable
data class AuthUserDto(
val id: Long,
val email: String,
val name: String,
val username: String,
@SerialName("avatar_url")
val avatarUrl: String? = null,
@SerialName("email_verified")
val emailVerified: Boolean,
)
@Serializable
data class ErrorResponseDto(
val detail: String? = null,
)

View File

@@ -0,0 +1,125 @@
package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkAuthRepository @Inject constructor(
private val authApiService: AuthApiService,
private val tokenRepository: TokenRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AuthRepository {
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val tokenResponse = authApiService.login(
request = LoginRequestDto(
email = email,
password = password,
)
)
tokenRepository.saveTokens(
TokenBundle(
accessToken = tokenResponse.accessToken,
refreshToken = tokenResponse.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
getMe()
} catch (error: Throwable) {
AppResult.Error(error.toAppError(forLogin = true))
}
}
override suspend fun refreshTokens(): AppResult<Unit> = withContext(ioDispatcher) {
val tokens = tokenRepository.getTokens()
?: return@withContext AppResult.Error(AppError.Unauthorized)
try {
val refreshed = authApiService.refresh(
request = RefreshTokenRequestDto(refreshToken = tokens.refreshToken)
)
tokenRepository.saveTokens(
TokenBundle(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
tokenRepository.clearTokens()
AppResult.Error(error.toAppError(forLogin = false))
}
}
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val user = authApiService.me().toDomain()
AppResult.Success(user)
} catch (error: Throwable) {
AppResult.Error(error.toAppError(forLogin = false))
}
}
override suspend fun restoreSession(): AppResult<AuthUser> = withContext(ioDispatcher) {
val tokens = tokenRepository.getTokens()
?: return@withContext AppResult.Error(AppError.Unauthorized)
if (tokens.accessToken.isBlank() || tokens.refreshToken.isBlank()) {
tokenRepository.clearTokens()
return@withContext AppResult.Error(AppError.Unauthorized)
}
when (val meResult = getMe()) {
is AppResult.Success -> meResult
is AppResult.Error -> {
if (meResult.reason is AppError.Unauthorized) {
tokenRepository.clearTokens()
}
meResult
}
}
}
override suspend fun logout() {
tokenRepository.clearTokens()
}
private fun AuthUserDto.toDomain(): AuthUser {
return AuthUser(
id = id,
email = email,
name = name,
username = username,
avatarUrl = avatarUrl,
emailVerified = emailVerified,
)
}
private fun Throwable.toAppError(forLogin: Boolean): AppError {
return when (this) {
is IOException -> AppError.Network
is HttpException -> when (code()) {
400 -> if (forLogin) AppError.InvalidCredentials else AppError.Server(message = message())
401, 403 -> if (forLogin) AppError.InvalidCredentials else AppError.Unauthorized
else -> AppError.Server(message = message())
}
else -> AppError.Unknown(cause = this)
}
}
}

View File

@@ -0,0 +1,18 @@
package ru.daemonlord.messenger.data.chat.api
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
interface ChatApiService {
@GET("/api/v1/chats")
suspend fun getChats(
@Query("archived") archived: Boolean = false,
): List<ChatReadDto>
@GET("/api/v1/chats/{chat_id}")
suspend fun getChatById(
@Path("chat_id") chatId: Long,
): ChatReadDto
}

View File

@@ -0,0 +1,45 @@
package ru.daemonlord.messenger.data.chat.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ChatReadDto(
val id: Long,
@SerialName("public_id")
val publicId: String,
val type: String,
val title: String? = null,
@SerialName("display_title")
val displayTitle: String,
val handle: String? = null,
@SerialName("avatar_url")
val avatarUrl: String? = null,
val archived: Boolean = false,
val pinned: Boolean = false,
val muted: Boolean = false,
@SerialName("unread_count")
val unreadCount: Int = 0,
@SerialName("unread_mentions_count")
val unreadMentionsCount: Int = 0,
@SerialName("counterpart_user_id")
val counterpartUserId: Long? = null,
@SerialName("counterpart_name")
val counterpartName: String? = null,
@SerialName("counterpart_username")
val counterpartUsername: String? = null,
@SerialName("counterpart_avatar_url")
val counterpartAvatarUrl: String? = null,
@SerialName("counterpart_is_online")
val counterpartIsOnline: Boolean? = null,
@SerialName("counterpart_last_seen_at")
val counterpartLastSeenAt: String? = null,
@SerialName("last_message_text")
val lastMessageText: String? = null,
@SerialName("last_message_type")
val lastMessageType: String? = null,
@SerialName("last_message_created_at")
val lastMessageCreatedAt: String? = null,
@SerialName("created_at")
val createdAt: String? = null,
)

View File

@@ -0,0 +1,110 @@
package ru.daemonlord.messenger.data.chat.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
@Dao
interface ChatDao {
@Query(
"""
SELECT
c.id,
c.public_id,
c.type,
c.title,
COALESCE(c.display_title, u.display_name) AS display_title,
c.handle,
COALESCE(c.avatar_url, u.avatar_url) AS avatar_url,
c.archived,
c.pinned,
c.muted,
c.unread_count,
c.unread_mentions_count,
COALESCE(c.counterpart_name, u.display_name) AS counterpart_name,
COALESCE(c.counterpart_username, u.username) AS counterpart_username,
COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url,
c.counterpart_is_online,
c.counterpart_last_seen_at,
c.last_message_text,
c.last_message_type,
c.last_message_created_at,
c.updated_sort_at
FROM chats c
LEFT JOIN users_short u ON c.counterpart_user_id = u.id
WHERE c.archived = :archived
ORDER BY c.pinned DESC, c.updated_sort_at DESC, c.id DESC
"""
)
fun observeChats(archived: Boolean): Flow<List<ChatListLocalModel>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChats(chats: List<ChatEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertUsers(users: List<UserShortEntity>)
@Query("DELETE FROM chats WHERE archived = :archived")
suspend fun deleteChatsByArchived(archived: Boolean)
@Query("DELETE FROM chats WHERE id = :chatId")
suspend fun deleteChat(chatId: Long)
@Query(
"""
UPDATE chats
SET counterpart_is_online = :isOnline,
counterpart_last_seen_at = :lastSeenAt
WHERE id = :chatId
"""
)
suspend fun updatePresence(chatId: Long, isOnline: Boolean, lastSeenAt: String?)
@Query(
"""
UPDATE chats
SET last_message_text = :lastMessageText,
last_message_type = :lastMessageType,
last_message_created_at = :lastMessageCreatedAt,
updated_sort_at = :updatedSortAt
WHERE id = :chatId
"""
)
suspend fun updateLastMessage(
chatId: Long,
lastMessageText: String?,
lastMessageType: String?,
lastMessageCreatedAt: String?,
updatedSortAt: String?,
)
@Query(
"""
UPDATE chats
SET unread_count = CASE
WHEN :incrementBy > 0 THEN unread_count + :incrementBy
ELSE unread_count
END
WHERE id = :chatId
"""
)
suspend fun incrementUnread(chatId: Long, incrementBy: Int = 1)
@Transaction
suspend fun clearAndReplaceChats(
archived: Boolean,
chats: List<ChatEntity>,
users: List<UserShortEntity>,
) {
upsertUsers(users)
deleteChatsByArchived(archived = archived)
upsertChats(chats)
}
}

View File

@@ -0,0 +1,19 @@
package ru.daemonlord.messenger.data.chat.local.db
import androidx.room.Database
import androidx.room.RoomDatabase
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
@Database(
entities = [
ChatEntity::class,
UserShortEntity::class,
],
version = 1,
exportSchema = false,
)
abstract class MessengerDatabase : RoomDatabase() {
abstract fun chatDao(): ChatDao
}

View File

@@ -0,0 +1,61 @@
package ru.daemonlord.messenger.data.chat.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "chats",
indices = [
Index(value = ["archived", "pinned", "updated_sort_at"]),
Index(value = ["archived", "last_message_created_at"]),
],
)
data class ChatEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "public_id")
val publicId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "title")
val title: String?,
@ColumnInfo(name = "display_title")
val displayTitle: String,
@ColumnInfo(name = "handle")
val handle: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
@ColumnInfo(name = "archived")
val archived: Boolean,
@ColumnInfo(name = "pinned")
val pinned: Boolean,
@ColumnInfo(name = "muted")
val muted: Boolean,
@ColumnInfo(name = "unread_count")
val unreadCount: Int,
@ColumnInfo(name = "unread_mentions_count")
val unreadMentionsCount: Int,
@ColumnInfo(name = "counterpart_user_id")
val counterpartUserId: Long?,
@ColumnInfo(name = "counterpart_name")
val counterpartName: String?,
@ColumnInfo(name = "counterpart_username")
val counterpartUsername: String?,
@ColumnInfo(name = "counterpart_avatar_url")
val counterpartAvatarUrl: String?,
@ColumnInfo(name = "counterpart_is_online")
val counterpartIsOnline: Boolean?,
@ColumnInfo(name = "counterpart_last_seen_at")
val counterpartLastSeenAt: String?,
@ColumnInfo(name = "last_message_text")
val lastMessageText: String?,
@ColumnInfo(name = "last_message_type")
val lastMessageType: String?,
@ColumnInfo(name = "last_message_created_at")
val lastMessageCreatedAt: String?,
@ColumnInfo(name = "updated_sort_at")
val updatedSortAt: String?,
)

View File

@@ -0,0 +1,20 @@
package ru.daemonlord.messenger.data.chat.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(
tableName = "users_short",
)
data class UserShortEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "display_name")
val displayName: String,
@ColumnInfo(name = "username")
val username: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
)

View File

@@ -0,0 +1,48 @@
package ru.daemonlord.messenger.data.chat.local.model
import androidx.room.ColumnInfo
data class ChatListLocalModel(
@ColumnInfo(name = "id")
val id: Long,
@ColumnInfo(name = "public_id")
val publicId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "title")
val title: String?,
@ColumnInfo(name = "display_title")
val displayTitle: String,
@ColumnInfo(name = "handle")
val handle: String?,
@ColumnInfo(name = "avatar_url")
val avatarUrl: String?,
@ColumnInfo(name = "archived")
val archived: Boolean,
@ColumnInfo(name = "pinned")
val pinned: Boolean,
@ColumnInfo(name = "muted")
val muted: Boolean,
@ColumnInfo(name = "unread_count")
val unreadCount: Int,
@ColumnInfo(name = "unread_mentions_count")
val unreadMentionsCount: Int,
@ColumnInfo(name = "counterpart_name")
val counterpartName: String?,
@ColumnInfo(name = "counterpart_username")
val counterpartUsername: String?,
@ColumnInfo(name = "counterpart_avatar_url")
val counterpartAvatarUrl: String?,
@ColumnInfo(name = "counterpart_is_online")
val counterpartIsOnline: Boolean?,
@ColumnInfo(name = "counterpart_last_seen_at")
val counterpartLastSeenAt: String?,
@ColumnInfo(name = "last_message_text")
val lastMessageText: String?,
@ColumnInfo(name = "last_message_type")
val lastMessageType: String?,
@ColumnInfo(name = "last_message_created_at")
val lastMessageCreatedAt: String?,
@ColumnInfo(name = "updated_sort_at")
val updatedSortAt: String?,
)

View File

@@ -0,0 +1,30 @@
package ru.daemonlord.messenger.data.chat.mapper
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
import ru.daemonlord.messenger.domain.chat.model.ChatItem
fun ChatListLocalModel.toDomain(): ChatItem {
return ChatItem(
id = id,
publicId = publicId,
type = type,
title = title,
displayTitle = displayTitle,
handle = handle,
avatarUrl = avatarUrl,
archived = archived,
pinned = pinned,
muted = muted,
unreadCount = unreadCount,
unreadMentionsCount = unreadMentionsCount,
counterpartUsername = counterpartUsername,
counterpartName = counterpartName,
counterpartAvatarUrl = counterpartAvatarUrl,
counterpartIsOnline = counterpartIsOnline,
counterpartLastSeenAt = counterpartLastSeenAt,
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
updatedSortAt = updatedSortAt,
)
}

View File

@@ -0,0 +1,43 @@
package ru.daemonlord.messenger.data.chat.mapper
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
fun ChatReadDto.toChatEntity(): ChatEntity {
return ChatEntity(
id = id,
publicId = publicId,
type = type,
title = title,
displayTitle = displayTitle,
handle = handle,
avatarUrl = avatarUrl,
archived = archived,
pinned = pinned,
muted = muted,
unreadCount = unreadCount,
unreadMentionsCount = unreadMentionsCount,
counterpartUserId = counterpartUserId,
counterpartName = counterpartName,
counterpartUsername = counterpartUsername,
counterpartAvatarUrl = counterpartAvatarUrl,
counterpartIsOnline = counterpartIsOnline,
counterpartLastSeenAt = counterpartLastSeenAt,
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
updatedSortAt = lastMessageCreatedAt ?: createdAt,
)
}
fun ChatReadDto.toUserShortEntityOrNull(): UserShortEntity? {
val userId = counterpartUserId ?: return null
val displayName = counterpartName ?: counterpartUsername ?: return null
return UserShortEntity(
id = userId,
displayName = displayName,
username = counterpartUsername,
avatarUrl = counterpartAvatarUrl,
)
}

View File

@@ -0,0 +1,90 @@
package ru.daemonlord.messenger.data.chat.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.channels.awaitClose
import retrofit2.HttpException
import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.mapper.toChatEntity
import ru.daemonlord.messenger.data.chat.mapper.toDomain
import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkChatRepository @Inject constructor(
private val chatApiService: ChatApiService,
private val chatDao: ChatDao,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ChatRepository {
override fun observeChats(archived: Boolean): Flow<List<ChatItem>> {
return channelFlow {
val dbCollection = launch {
chatDao.observeChats(archived = archived).collect { rows ->
send(rows.map { it.toDomain() })
}
}
launch(ioDispatcher) {
refreshChats(archived = archived)
}
awaitClose { dbCollection.cancel() }
}
}
override suspend fun refreshChats(archived: Boolean): AppResult<Unit> = withContext(ioDispatcher) {
try {
val chats = chatApiService.getChats(archived = archived)
val chatEntities = chats.map { it.toChatEntity() }
val userEntities = chats.mapNotNull { it.toUserShortEntityOrNull() }
chatDao.clearAndReplaceChats(
archived = archived,
chats = chatEntities,
users = userEntities,
)
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun refreshChat(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try {
val chat = chatApiService.getChatById(chatId = chatId)
chatDao.upsertUsers(chat.toUserShortEntityOrNull()?.let(::listOf).orEmpty())
chatDao.upsertChats(listOf(chat.toChatEntity()))
AppResult.Success(Unit)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun deleteChat(chatId: Long) {
withContext(ioDispatcher) {
chatDao.deleteChat(chatId = chatId)
}
}
private fun Throwable.toAppError(): AppError {
return when (this) {
is IOException -> AppError.Network
is HttpException -> if (code() == 401 || code() == 403) {
AppError.Unauthorized
} else {
AppError.Server(message = message())
}
else -> AppError.Unknown(cause = this)
}
}
}

View File

@@ -0,0 +1,89 @@
package ru.daemonlord.messenger.data.realtime
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RealtimeEventParser @Inject constructor(
private val json: Json,
) {
fun parse(raw: String): RealtimeEvent {
val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull()
?: return RealtimeEvent.Ignored
val event = root["event"].stringOrNull() ?: return RealtimeEvent.Ignored
val payload = root["payload"]?.jsonObject ?: JsonObject(emptyMap())
return when (event) {
"receive_message" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
val messageObject = payload["message"]?.jsonObject
RealtimeEvent.ReceiveMessage(
chatId = chatId,
text = messageObject?.get("text").stringOrNull(),
type = messageObject?.get("type").stringOrNull(),
createdAt = messageObject?.get("created_at").stringOrNull(),
)
}
"message_updated" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
val messageObject = payload["message"]?.jsonObject
RealtimeEvent.MessageUpdated(
chatId = chatId,
text = messageObject?.get("text").stringOrNull(),
type = messageObject?.get("type").stringOrNull(),
updatedAt = messageObject?.get("updated_at").stringOrNull(),
)
}
"message_deleted" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.MessageDeleted(
chatId = chatId,
messageId = payload["message_id"].longOrNull(),
)
}
"chat_updated" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.ChatUpdated(chatId = chatId)
}
"chat_deleted" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.ChatDeleted(chatId = chatId)
}
"user_online" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.UserOnline(chatId = chatId)
}
"user_offline" -> {
val chatId = payload["chat_id"].longOrNull() ?: return RealtimeEvent.Ignored
RealtimeEvent.UserOffline(
chatId = chatId,
lastSeenAt = payload["last_seen_at"].stringOrNull(),
)
}
else -> RealtimeEvent.Ignored
}
}
private fun JsonElement?.stringOrNull(): String? {
return this?.jsonPrimitive?.contentOrNull
}
private fun JsonElement?.longOrNull(): Long? {
return this?.jsonPrimitive?.contentOrNull?.toLongOrNull()
}
}

View File

@@ -0,0 +1,112 @@
package ru.daemonlord.messenger.data.realtime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.di.RefreshClient
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class WsRealtimeManager @Inject constructor(
@RefreshClient private val okHttpClient: OkHttpClient,
private val tokenRepository: TokenRepository,
private val parser: RealtimeEventParser,
) : RealtimeManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val eventFlow = MutableSharedFlow<RealtimeEvent>(extraBufferCapacity = 64)
private var socket: WebSocket? = null
private val isConnected = AtomicBoolean(false)
private val manualDisconnect = AtomicBoolean(false)
private var reconnectDelayMs: Long = INITIAL_RECONNECT_MS
override val events: Flow<RealtimeEvent> = eventFlow.asSharedFlow()
override fun connect() {
if (isConnected.get()) return
manualDisconnect.set(false)
scope.launch { openSocket() }
}
override fun disconnect() {
manualDisconnect.set(true)
isConnected.set(false)
socket?.close(1000, "Client disconnect")
socket = null
}
private suspend fun openSocket() {
val accessToken = tokenRepository.getTokens()?.accessToken ?: return
val wsUrl = BuildConfig.API_BASE_URL
.replace("http://", "ws://")
.replace("https://", "wss://")
.trimEnd('/') + "/api/v1/realtime/ws?token=$accessToken"
val request = Request.Builder()
.url(wsUrl)
.build()
socket = okHttpClient.newWebSocket(request, listener)
}
private fun scheduleReconnect() {
if (manualDisconnect.get()) return
scope.launch {
delay(reconnectDelayMs)
reconnectDelayMs = (reconnectDelayMs * 2).coerceAtMost(MAX_RECONNECT_MS)
openSocket()
}
}
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
isConnected.set(true)
reconnectDelayMs = INITIAL_RECONNECT_MS
}
override fun onMessage(webSocket: WebSocket, text: String) {
eventFlow.tryEmit(parser.parse(text))
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
isConnected.set(false)
webSocket.close(code, reason)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
isConnected.set(false)
scheduleReconnect()
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
isConnected.set(false)
scheduleReconnect()
}
}
@Suppress("unused")
fun shutdown() {
disconnect()
scope.cancel()
}
private companion object {
const val INITIAL_RECONNECT_MS = 1_000L
const val MAX_RECONNECT_MS = 30_000L
}
}

View File

@@ -0,0 +1,34 @@
package ru.daemonlord.messenger.di
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
): MessengerDatabase {
return Room.databaseBuilder(
context,
MessengerDatabase::class.java,
"messenger.db",
).fallbackToDestructiveMigration()
.build()
}
@Provides
@Singleton
fun provideChatDao(database: MessengerDatabase): ChatDao = database.chatDao()
}

View File

@@ -0,0 +1,126 @@
package ru.daemonlord.messenger.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json {
return Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}
}
@Provides
@Singleton
fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
@Provides
@Singleton
@RefreshClient
fun provideRefreshClient(
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
@RefreshAuthApi
fun provideRefreshApiService(
@RefreshClient refreshClient: OkHttpClient,
json: Json,
): AuthApiService {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(json.asConverterFactory(contentType))
.client(refreshClient)
.build()
.create(AuthApiService::class.java)
}
@Provides
@Singleton
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
fun provideApiClient(
loggingInterceptor: HttpLoggingInterceptor,
authHeaderInterceptor: AuthHeaderInterceptor,
tokenRefreshAuthenticator: TokenRefreshAuthenticator,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(authHeaderInterceptor)
.authenticator(tokenRefreshAuthenticator)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(
client: OkHttpClient,
json: Json,
): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(json.asConverterFactory(contentType))
.client(client)
.build()
}
@Provides
@Singleton
fun provideAuthApiService(retrofit: Retrofit): AuthApiService {
return retrofit.create(AuthApiService::class.java)
}
@Provides
@Singleton
fun provideChatApiService(retrofit: Retrofit): ChatApiService {
return retrofit.create(ChatApiService::class.java)
}
}

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RefreshClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RefreshAuthApi
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

View File

@@ -0,0 +1,20 @@
package ru.daemonlord.messenger.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.realtime.WsRealtimeManager
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RealtimeModule {
@Binds
@Singleton
abstract fun bindRealtimeManager(
manager: WsRealtimeManager,
): RealtimeManager
}

View File

@@ -0,0 +1,28 @@
package ru.daemonlord.messenger.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(
repository: NetworkAuthRepository,
): AuthRepository
@Binds
@Singleton
abstract fun bindChatRepository(
repository: NetworkChatRepository,
): ChatRepository
}

View File

@@ -0,0 +1,36 @@
package ru.daemonlord.messenger.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.core.token.DataStoreTokenRepository
import ru.daemonlord.messenger.core.token.TokenRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
@Provides
@Singleton
fun providePreferenceDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("messenger_tokens.preferences_pb") }
)
}
@Provides
@Singleton
fun provideTokenRepository(
repository: DataStoreTokenRepository,
): TokenRepository = repository
}

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.domain.auth.model
data class AuthUser(
val id: Long,
val email: String,
val name: String,
val username: String,
val avatarUrl: String?,
val emailVerified: Boolean,
)

View File

@@ -0,0 +1,12 @@
package ru.daemonlord.messenger.domain.auth.repository
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppResult
interface AuthRepository {
suspend fun login(email: String, password: String): AppResult<AuthUser>
suspend fun refreshTokens(): AppResult<Unit>
suspend fun getMe(): AppResult<AuthUser>
suspend fun restoreSession(): AppResult<AuthUser>
suspend fun logout()
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> {
return authRepository.login(email = email, password = password)
}
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class RestoreSessionUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(): AppResult<AuthUser> {
return authRepository.restoreSession()
}
}

View File

@@ -0,0 +1,25 @@
package ru.daemonlord.messenger.domain.chat.model
data class ChatItem(
val id: Long,
val publicId: String,
val type: String,
val title: String?,
val displayTitle: String,
val handle: String?,
val avatarUrl: String?,
val archived: Boolean,
val pinned: Boolean,
val muted: Boolean,
val unreadCount: Int,
val unreadMentionsCount: Int,
val counterpartUsername: String?,
val counterpartName: String?,
val counterpartAvatarUrl: String?,
val counterpartIsOnline: Boolean?,
val counterpartLastSeenAt: String?,
val lastMessageText: String?,
val lastMessageType: String?,
val lastMessageCreatedAt: String?,
val updatedSortAt: String?,
)

View File

@@ -0,0 +1,12 @@
package ru.daemonlord.messenger.domain.chat.repository
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.common.AppResult
interface ChatRepository {
fun observeChats(archived: Boolean): Flow<List<ChatItem>>
suspend fun refreshChats(archived: Boolean): AppResult<Unit>
suspend fun refreshChat(chatId: Long): AppResult<Unit>
suspend fun deleteChat(chatId: Long)
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.chat.usecase
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import javax.inject.Inject
class ObserveChatsUseCase @Inject constructor(
private val chatRepository: ChatRepository,
) {
operator fun invoke(archived: Boolean): Flow<List<ChatItem>> {
return chatRepository.observeChats(archived = archived)
}
}

View File

@@ -0,0 +1,13 @@
package ru.daemonlord.messenger.domain.chat.usecase
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class RefreshChatsUseCase @Inject constructor(
private val chatRepository: ChatRepository,
) {
suspend operator fun invoke(archived: Boolean): AppResult<Unit> {
return chatRepository.refreshChats(archived = archived)
}
}

View File

@@ -0,0 +1,9 @@
package ru.daemonlord.messenger.domain.common
sealed interface AppError {
data object InvalidCredentials : AppError
data object Unauthorized : AppError
data object Network : AppError
data class Server(val message: String?) : AppError
data class Unknown(val cause: Throwable?) : AppError
}

View File

@@ -0,0 +1,6 @@
package ru.daemonlord.messenger.domain.common
sealed interface AppResult<out T> {
data class Success<T>(val data: T) : AppResult<T>
data class Error(val reason: AppError) : AppResult<Nothing>
}

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.domain.realtime
import kotlinx.coroutines.flow.Flow
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
interface RealtimeManager {
val events: Flow<RealtimeEvent>
fun connect()
fun disconnect()
}

View File

@@ -0,0 +1,41 @@
package ru.daemonlord.messenger.domain.realtime.model
sealed interface RealtimeEvent {
data class ReceiveMessage(
val chatId: Long,
val text: String?,
val type: String?,
val createdAt: String?,
) : RealtimeEvent
data class MessageUpdated(
val chatId: Long,
val text: String?,
val type: String?,
val updatedAt: String?,
) : RealtimeEvent
data class MessageDeleted(
val chatId: Long,
val messageId: Long?,
) : RealtimeEvent
data class ChatUpdated(
val chatId: Long,
) : RealtimeEvent
data class ChatDeleted(
val chatId: Long,
) : RealtimeEvent
data class UserOnline(
val chatId: Long,
) : RealtimeEvent
data class UserOffline(
val chatId: Long,
val lastSeenAt: String?,
) : RealtimeEvent
data object Ignored : RealtimeEvent
}

View File

@@ -0,0 +1,93 @@
package ru.daemonlord.messenger.domain.realtime.usecase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HandleRealtimeEventsUseCase @Inject constructor(
private val realtimeManager: RealtimeManager,
private val chatRepository: ChatRepository,
private val chatDao: ChatDao,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var collectionJob: Job? = null
fun start() {
if (collectionJob?.isActive == true) return
realtimeManager.connect()
collectionJob = scope.launch {
realtimeManager.events.collectLatest { event ->
when (event) {
is RealtimeEvent.ReceiveMessage -> {
chatDao.updateLastMessage(
chatId = event.chatId,
lastMessageText = event.text,
lastMessageType = event.type,
lastMessageCreatedAt = event.createdAt,
updatedSortAt = event.createdAt,
)
chatDao.incrementUnread(chatId = event.chatId)
}
is RealtimeEvent.MessageUpdated -> {
chatDao.updateLastMessage(
chatId = event.chatId,
lastMessageText = event.text,
lastMessageType = event.type,
lastMessageCreatedAt = event.updatedAt,
updatedSortAt = event.updatedAt,
)
}
is RealtimeEvent.MessageDeleted -> {
chatRepository.refreshChats(archived = false)
chatRepository.refreshChats(archived = true)
}
is RealtimeEvent.ChatUpdated -> {
chatRepository.refreshChat(chatId = event.chatId)
}
is RealtimeEvent.ChatDeleted -> {
chatRepository.deleteChat(chatId = event.chatId)
}
is RealtimeEvent.UserOnline -> {
chatDao.updatePresence(
chatId = event.chatId,
isOnline = true,
lastSeenAt = null,
)
}
is RealtimeEvent.UserOffline -> {
chatDao.updatePresence(
chatId = event.chatId,
isOnline = false,
lastSeenAt = event.lastSeenAt,
)
}
RealtimeEvent.Ignored -> Unit
}
}
}
}
fun stop() {
collectionJob?.cancel()
collectionJob = null
realtimeManager.disconnect()
}
}

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.ui.auth
data class AuthUiState(
val email: String = "",
val password: String = "",
val isCheckingSession: Boolean = true,
val isLoading: Boolean = false,
val isAuthenticated: Boolean = false,
val errorMessage: String? = null,
)

View File

@@ -0,0 +1,107 @@
package ru.daemonlord.messenger.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
@HiltViewModel
class AuthViewModel @Inject constructor(
private val restoreSessionUseCase: RestoreSessionUseCase,
private val loginUseCase: LoginUseCase,
) : ViewModel() {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
init {
restoreSession()
}
fun onEmailChanged(value: String) {
_uiState.update { it.copy(email = value, errorMessage = null) }
}
fun onPasswordChanged(value: String) {
_uiState.update { it.copy(password = value, errorMessage = null) }
}
fun login() {
val state = uiState.value
if (state.email.isBlank() || state.password.isBlank()) {
_uiState.update { it.copy(errorMessage = "Email and password are required.") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
when (val result = loginUseCase(state.email.trim(), state.password)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = true,
errorMessage = null,
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isLoading = false,
isAuthenticated = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
}
private fun restoreSession() {
viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) }
when (val result = restoreSessionUseCase()) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isCheckingSession = false,
isAuthenticated = true,
errorMessage = null,
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isCheckingSession = false,
isAuthenticated = false,
errorMessage = null,
)
}
}
}
}
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.InvalidCredentials -> "Invalid email or password."
AppError.Network -> "Network error. Check your connection."
AppError.Unauthorized -> "Session expired. Please sign in again."
is AppError.Server -> "Server error. Please try again."
is AppError.Unknown -> "Unknown error. Please try again."
}
}
}

View File

@@ -0,0 +1,95 @@
package ru.daemonlord.messenger.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(
state: AuthUiState,
onEmailChanged: (String) -> Unit,
onPasswordChanged: (String) -> Unit,
onLoginClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Messenger Login",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 24.dp),
)
OutlinedTextField(
value = state.email,
onValueChange = onEmailChanged,
label = { Text(text = "Email") },
singleLine = true,
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChanged,
label = { Text(text = "Password") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
enabled = !state.isLoading,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
Button(
onClick = onLoginClick,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.padding(2.dp),
)
} else {
Text(text = "Login")
}
}
if (!state.errorMessage.isNullOrBlank()) {
Text(
text = state.errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
} else {
TextButton(
onClick = {},
enabled = false,
modifier = Modifier.padding(top = 8.dp),
) {
Text(text = "Use your existing backend account")
}
}
}
}

View File

@@ -0,0 +1,30 @@
package ru.daemonlord.messenger.ui.chat
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun ChatScreenPlaceholder(
chatId: Long,
) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Chat Screen",
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = "chatId=$chatId",
style = MaterialTheme.typography.bodyMedium,
)
}
}

View File

@@ -0,0 +1,243 @@
package ru.daemonlord.messenger.ui.chats
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ru.daemonlord.messenger.domain.chat.model.ChatItem
@Composable
fun ChatListRoute(
onOpenChat: (Long) -> Unit,
viewModel: ChatListViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
ChatListScreen(
state = state,
onTabSelected = viewModel::onTabSelected,
onSearchChanged = viewModel::onSearchChanged,
onRefresh = viewModel::onPullToRefresh,
onOpenChat = onOpenChat,
)
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun ChatListScreen(
state: ChatListUiState,
onTabSelected: (ChatTab) -> Unit,
onSearchChanged: (String) -> Unit,
onRefresh: () -> Unit,
onOpenChat: (Long) -> Unit,
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
TabRow(
selectedTabIndex = if (state.selectedTab == ChatTab.ALL) 0 else 1,
) {
Tab(
selected = state.selectedTab == ChatTab.ALL,
onClick = { onTabSelected(ChatTab.ALL) },
text = { Text(text = "All") },
)
Tab(
selected = state.selectedTab == ChatTab.ARCHIVED,
onClick = { onTabSelected(ChatTab.ARCHIVED) },
text = { Text(text = "Archived") },
)
}
OutlinedTextField(
value = state.searchQuery,
onValueChange = onSearchChanged,
label = { Text(text = "Search title / username / handle") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize(),
) {
when {
state.isLoading -> {
CenterState(text = "Loading chats...", loading = true)
}
!state.errorMessage.isNullOrBlank() -> {
CenterState(text = state.errorMessage, loading = false)
}
state.chats.isEmpty() -> {
CenterState(text = "No chats found", loading = false)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(
items = state.chats,
key = { it.id },
) { chat ->
ChatRow(
chat = chat,
onClick = { onOpenChat(chat.id) },
)
}
}
}
}
}
}
}
@Composable
private fun ChatRow(
chat: ChatItem,
onClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = chat.displayTitle,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (chat.unreadCount > 0) FontWeight.SemiBold else FontWeight.Normal,
)
if (chat.pinned) {
Text(
text = " [PIN]",
style = MaterialTheme.typography.bodySmall,
)
}
if (chat.muted) {
Text(
text = " [MUTE]",
style = MaterialTheme.typography.bodySmall,
)
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (chat.unreadMentionsCount > 0) {
BadgeChip(label = "@")
}
if (chat.unreadCount > 0) {
BadgeChip(label = chat.unreadCount.toString())
}
}
}
val preview = chat.previewText()
if (preview.isNotBlank()) {
Text(
text = preview,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
if (chat.type == "private") {
val presence = if (chat.counterpartIsOnline == true) {
"online"
} else {
chat.counterpartLastSeenAt?.let { "last seen recently" } ?: "offline"
}
Text(
text = presence,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
@Composable
private fun BadgeChip(label: String) {
AssistChip(
onClick = {},
enabled = false,
label = { Text(text = label) },
colors = AssistChipDefaults.assistChipColors(
disabledContainerColor = MaterialTheme.colorScheme.primaryContainer,
disabledLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
}
@Composable
private fun CenterState(
text: String?,
loading: Boolean,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (loading) {
CircularProgressIndicator()
Spacer(modifier = Modifier.padding(8.dp))
}
if (!text.isNullOrBlank()) {
Text(text = text)
}
}
}
private fun ChatItem.previewText(): String {
val raw = lastMessageText.orEmpty().trim()
if (raw.isNotEmpty()) return raw
return when (lastMessageType) {
"image" -> "Photo"
"video" -> "Video"
"audio" -> "Audio"
"voice" -> "Voice message"
"file" -> "File"
"circle_video" -> "Video message"
null, "text" -> ""
else -> "Media"
}
}

View File

@@ -0,0 +1,12 @@
package ru.daemonlord.messenger.ui.chats
import ru.daemonlord.messenger.domain.chat.model.ChatItem
data class ChatListUiState(
val selectedTab: ChatTab = ChatTab.ALL,
val searchQuery: String = "",
val isLoading: Boolean = true,
val isRefreshing: Boolean = false,
val errorMessage: String? = null,
val chats: List<ChatItem> = emptyList(),
)

View File

@@ -0,0 +1,122 @@
package ru.daemonlord.messenger.ui.chats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.domain.chat.model.ChatItem
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import javax.inject.Inject
@HiltViewModel
class ChatListViewModel @Inject constructor(
private val observeChatsUseCase: ObserveChatsUseCase,
private val refreshChatsUseCase: RefreshChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL)
private val searchQuery = MutableStateFlow("")
private val _uiState = MutableStateFlow(ChatListUiState())
val uiState: StateFlow<ChatListUiState> = _uiState.asStateFlow()
init {
handleRealtimeEventsUseCase.start()
observeChatStream()
}
fun onTabSelected(tab: ChatTab) {
if (selectedTab.value == tab) return
selectedTab.value = tab
_uiState.update { it.copy(selectedTab = tab, isLoading = true, errorMessage = null) }
refreshCurrentTab()
}
fun onSearchChanged(value: String) {
searchQuery.value = value
_uiState.update { it.copy(searchQuery = value) }
}
fun onPullToRefresh() {
refreshCurrentTab(forceRefresh = true)
}
private fun observeChatStream() {
viewModelScope.launch {
selectedTab
.flatMapLatest { tab ->
observeChatsUseCase(archived = tab == ChatTab.ARCHIVED)
}
.combine(searchQuery) { chats, query ->
chats.filterByQuery(query)
}
.collectLatest { filtered ->
_uiState.update {
it.copy(
isLoading = false,
isRefreshing = false,
errorMessage = null,
chats = filtered,
)
}
}
}
}
private fun refreshCurrentTab(forceRefresh: Boolean = false) {
viewModelScope.launch {
_uiState.update {
it.copy(
isRefreshing = forceRefresh,
errorMessage = null,
)
}
val result = refreshChatsUseCase(archived = selectedTab.value == ChatTab.ARCHIVED)
if (result is AppResult.Error) {
_uiState.update {
it.copy(
isRefreshing = false,
isLoading = false,
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}
private fun List<ChatItem>.filterByQuery(query: String): List<ChatItem> {
val normalized = query.trim().lowercase()
if (normalized.isBlank()) return this
return filter { chat ->
chat.displayTitle.lowercase().contains(normalized) ||
(chat.counterpartUsername?.lowercase()?.contains(normalized) == true) ||
(chat.handle?.lowercase()?.contains(normalized) == true)
}
}
private fun AppError.toUiMessage(): String {
return when (this) {
AppError.Network -> "Network error while syncing chats."
AppError.Unauthorized -> "Session expired. Please log in again."
AppError.InvalidCredentials -> "Authorization failed."
is AppError.Server -> "Server error while loading chats."
is AppError.Unknown -> "Unknown error while loading chats."
}
}
override fun onCleared() {
handleRealtimeEventsUseCase.stop()
super.onCleared()
}
}

View File

@@ -0,0 +1,6 @@
package ru.daemonlord.messenger.ui.chats
enum class ChatTab {
ALL,
ARCHIVED,
}

View File

@@ -0,0 +1,113 @@
package ru.daemonlord.messenger.ui.navigation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavType
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.navArgument
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.navigation
import androidx.navigation.compose.rememberNavController
import ru.daemonlord.messenger.ui.auth.AuthViewModel
import ru.daemonlord.messenger.ui.auth.LoginScreen
import ru.daemonlord.messenger.ui.chat.ChatScreenPlaceholder
import ru.daemonlord.messenger.ui.chats.ChatListRoute
private object Routes {
const val AuthGraph = "auth_graph"
const val Login = "login"
const val Chats = "chats"
const val Chat = "chat"
}
@Composable
fun MessengerNavHost(
navController: NavHostController = rememberNavController(),
viewModel: AuthViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(uiState.isCheckingSession, uiState.isAuthenticated) {
if (uiState.isCheckingSession) {
return@LaunchedEffect
}
if (uiState.isAuthenticated) {
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
} else {
navController.navigate(Routes.Login) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
}
}
NavHost(
navController = navController,
startDestination = Routes.AuthGraph,
) {
navigation(
route = Routes.AuthGraph,
startDestination = Routes.Login,
) {
composable(route = Routes.Login) {
if (uiState.isCheckingSession) {
SessionCheckingScreen()
} else {
LoginScreen(
state = uiState,
onEmailChanged = viewModel::onEmailChanged,
onPasswordChanged = viewModel::onPasswordChanged,
onLoginClick = viewModel::login,
)
}
}
}
composable(route = Routes.Chats) {
ChatListRoute(
onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId")
}
)
}
composable(
route = "${Routes.Chat}/{chatId}",
arguments = listOf(
navArgument("chatId") { type = NavType.LongType }
),
) { backStackEntry ->
val chatId = backStackEntry.arguments?.getLong("chatId") ?: 0L
ChatScreenPlaceholder(chatId = chatId)
}
}
}
@Composable
private fun SessionCheckingScreen() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
}
}

View File

@@ -0,0 +1,55 @@
package ru.daemonlord.messenger.core.token
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okio.Path.Companion.toPath
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class DataStoreTokenRepositoryTest {
@Test
fun saveThenReadTokens_returnsSameBundle() = runTest {
val repository = DataStoreTokenRepository(createTestDataStore())
val expected = TokenBundle(
accessToken = "access-1",
refreshToken = "refresh-1",
savedAtMillis = 1_726_000_000_000L,
)
repository.saveTokens(expected)
val actual = repository.getTokens()
assertEquals(expected, actual)
}
@Test
fun clearTokens_removesSavedTokens() = runTest {
val repository = DataStoreTokenRepository(createTestDataStore())
repository.saveTokens(
TokenBundle(
accessToken = "access-2",
refreshToken = "refresh-2",
savedAtMillis = 1_726_000_000_001L,
)
)
repository.clearTokens()
assertNull(repository.getTokens())
}
private fun createTestDataStore(): DataStore<Preferences> {
val file = File.createTempFile("tokens", ".preferences_pb")
file.deleteOnExit()
return PreferenceDataStoreFactory.createWithPath(
produceFile = { file.absolutePath.toPath() }
)
}
}

View File

@@ -0,0 +1,119 @@
package ru.daemonlord.messenger.data.auth.repository
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
@OptIn(ExperimentalCoroutinesApi::class)
class NetworkAuthRepositoryTest {
private lateinit var server: MockWebServer
private lateinit var authApiService: AuthApiService
private lateinit var tokenRepository: InMemoryTokenRepository
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
server = MockWebServer()
server.start()
val retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(
Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType())
)
.build()
authApiService = retrofit.create(AuthApiService::class.java)
tokenRepository = InMemoryTokenRepository()
}
@After
fun tearDown() {
server.shutdown()
}
@Test
fun loginSuccess_savesTokensAndReturnsUser() = runTest(dispatcher) {
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""
{"access_token":"new-access","refresh_token":"new-refresh","token_type":"bearer"}
""".trimIndent()
)
)
server.enqueue(
MockResponse().setResponseCode(200).setBody(
"""
{"id":1,"email":"user@example.com","name":"User","username":"user","avatar_url":null,"email_verified":true}
""".trimIndent()
)
)
val repository = NetworkAuthRepository(
authApiService = authApiService,
tokenRepository = tokenRepository,
ioDispatcher = dispatcher,
)
val result = repository.login(email = "user@example.com", password = "secret")
assertTrue(result is AppResult.Success)
val success = result as AppResult.Success
assertEquals("user@example.com", success.data.email)
assertEquals("new-access", tokenRepository.getTokens()?.accessToken)
assertEquals("new-refresh", tokenRepository.getTokens()?.refreshToken)
}
@Test
fun login401_returnsInvalidCredentials() = runTest(dispatcher) {
server.enqueue(
MockResponse().setResponseCode(401).setBody("""{"detail":"Invalid credentials"}""")
)
val repository = NetworkAuthRepository(
authApiService = authApiService,
tokenRepository = tokenRepository,
ioDispatcher = dispatcher,
)
val result = repository.login(email = "user@example.com", password = "wrong")
assertTrue(result is AppResult.Error)
val error = result as AppResult.Error
assertEquals(AppError.InvalidCredentials, error.reason)
}
private class InMemoryTokenRepository : TokenRepository {
private val state = MutableStateFlow<TokenBundle?>(null)
override fun observeTokens(): Flow<TokenBundle?> = state.asStateFlow()
override suspend fun getTokens(): TokenBundle? = state.value
override suspend fun saveTokens(tokens: TokenBundle) {
state.value = tokens
}
override suspend fun clearTokens() {
state.value = null
}
}
}

View File

@@ -0,0 +1,94 @@
package ru.daemonlord.messenger.data.chat.local.dao
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.robolectric.RobolectricTestRunner
import org.junit.runner.RunWith
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
@RunWith(RobolectricTestRunner::class)
@OptIn(ExperimentalCoroutinesApi::class)
class ChatDaoTest {
private lateinit var db: MessengerDatabase
private lateinit var dao: ChatDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
MessengerDatabase::class.java,
).allowMainThreadQueries()
.build()
dao = db.chatDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun clearAndReplaceChats_replacesOnlySelectedArchiveScope() = runTest {
dao.upsertChats(
listOf(
chatEntity(id = 1, archived = false, pinned = false, title = "All chat"),
chatEntity(id = 2, archived = true, pinned = true, title = "Archived chat"),
)
)
dao.clearAndReplaceChats(
archived = false,
chats = listOf(chatEntity(id = 3, archived = false, pinned = false, title = "New all chat")),
users = listOf(UserShortEntity(id = 99, displayName = "User", username = "user", avatarUrl = null)),
)
val allChats = dao.observeChats(archived = false).first()
val archivedChats = dao.observeChats(archived = true).first()
assertEquals(1, allChats.size)
assertEquals(3L, allChats.first().id)
assertEquals(1, archivedChats.size)
assertEquals(2L, archivedChats.first().id)
}
private fun chatEntity(
id: Long,
archived: Boolean,
pinned: Boolean,
title: String,
): ChatEntity {
return ChatEntity(
id = id,
publicId = "public-$id",
type = "private",
title = title,
displayTitle = title,
handle = null,
avatarUrl = null,
archived = archived,
pinned = pinned,
muted = false,
unreadCount = 0,
unreadMentionsCount = 0,
counterpartUserId = null,
counterpartName = null,
counterpartUsername = null,
counterpartAvatarUrl = null,
counterpartIsOnline = false,
counterpartLastSeenAt = null,
lastMessageText = "hi",
lastMessageType = "text",
lastMessageCreatedAt = "2026-03-08T10:00:00Z",
updatedSortAt = "2026-03-08T10:00:00Z",
)
}
}

View File

@@ -0,0 +1,187 @@
package ru.daemonlord.messenger.data.chat.repository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.chat.dto.ChatReadDto
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.chat.local.entity.ChatEntity
import ru.daemonlord.messenger.data.chat.local.entity.UserShortEntity
import ru.daemonlord.messenger.data.chat.local.model.ChatListLocalModel
@OptIn(ExperimentalCoroutinesApi::class)
class NetworkChatRepositoryTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun observeChats_cacheFirstThenNetworkSync() = runTest(dispatcher) {
val cached = ChatEntity(
id = 1,
publicId = "cached-public",
type = "private",
title = null,
displayTitle = "Cached chat",
handle = null,
avatarUrl = null,
archived = false,
pinned = false,
muted = false,
unreadCount = 0,
unreadMentionsCount = 0,
counterpartUserId = null,
counterpartName = null,
counterpartUsername = null,
counterpartAvatarUrl = null,
counterpartIsOnline = false,
counterpartLastSeenAt = null,
lastMessageText = "Cached message",
lastMessageType = "text",
lastMessageCreatedAt = "2026-03-08T10:00:00Z",
updatedSortAt = "2026-03-08T10:00:00Z",
)
val fakeDao = FakeChatDao(initialChats = listOf(cached))
val fakeApi = FakeChatApiService(
chats = listOf(
ChatReadDto(
id = 2,
publicId = "remote-public",
type = "private",
displayTitle = "Remote chat",
archived = false,
pinned = true,
muted = false,
unreadCount = 5,
unreadMentionsCount = 1,
lastMessageText = "Remote message",
lastMessageType = "text",
lastMessageCreatedAt = "2026-03-08T11:00:00Z",
createdAt = "2026-03-08T09:00:00Z",
)
)
)
val repository = NetworkChatRepository(
chatApiService = fakeApi,
chatDao = fakeDao,
ioDispatcher = dispatcher,
)
val emissions = mutableListOf<List<String>>()
val collectJob = backgroundScope.launch(dispatcher) {
repository.observeChats(archived = false)
.take(2)
.toList()
.forEach { list -> emissions.add(list.map { it.displayTitle }) }
}
advanceUntilIdle()
collectJob.join()
assertEquals(listOf("Cached chat"), emissions[0])
assertEquals(listOf("Remote chat"), emissions[1])
assertTrue(fakeApi.requestedArchivedParams.contains(false))
}
private class FakeChatApiService(
private val chats: List<ChatReadDto>,
) : ChatApiService {
val requestedArchivedParams = mutableListOf<Boolean>()
override suspend fun getChats(archived: Boolean): List<ChatReadDto> {
requestedArchivedParams += archived
return chats
}
override suspend fun getChatById(chatId: Long): ChatReadDto {
return chats.first()
}
}
private class FakeChatDao(
initialChats: List<ChatEntity>,
) : ChatDao {
private val chats = MutableStateFlow(initialChats)
override fun observeChats(archived: Boolean): Flow<List<ChatListLocalModel>> {
return chats.map { entities ->
entities.filter { it.archived == archived }.map { it.toLocalModel() }
}
}
override suspend fun upsertChats(chats: List<ChatEntity>) {
val merged = this.chats.value.associateBy { it.id }.toMutableMap()
chats.forEach { merged[it.id] = it }
this.chats.value = merged.values.toList()
}
override suspend fun upsertUsers(users: List<UserShortEntity>) = Unit
override suspend fun deleteChatsByArchived(archived: Boolean) {
chats.value = chats.value.filterNot { it.archived == archived }
}
override suspend fun deleteChat(chatId: Long) {
chats.value = chats.value.filterNot { it.id == chatId }
}
override suspend fun updatePresence(chatId: Long, isOnline: Boolean, lastSeenAt: String?) = Unit
override suspend fun updateLastMessage(
chatId: Long,
lastMessageText: String?,
lastMessageType: String?,
lastMessageCreatedAt: String?,
updatedSortAt: String?,
) = Unit
override suspend fun incrementUnread(chatId: Long, incrementBy: Int) = Unit
override suspend fun clearAndReplaceChats(
archived: Boolean,
chats: List<ChatEntity>,
users: List<UserShortEntity>,
) {
deleteChatsByArchived(archived = archived)
upsertChats(chats)
}
private fun ChatEntity.toLocalModel(): ChatListLocalModel {
return ChatListLocalModel(
id = id,
publicId = publicId,
type = type,
title = title,
displayTitle = displayTitle,
handle = handle,
avatarUrl = avatarUrl,
archived = archived,
pinned = pinned,
muted = muted,
unreadCount = unreadCount,
unreadMentionsCount = unreadMentionsCount,
counterpartName = counterpartName,
counterpartUsername = counterpartUsername,
counterpartAvatarUrl = counterpartAvatarUrl,
counterpartIsOnline = counterpartIsOnline,
counterpartLastSeenAt = counterpartLastSeenAt,
lastMessageText = lastMessageText,
lastMessageType = lastMessageType,
lastMessageCreatedAt = lastMessageCreatedAt,
updatedSortAt = updatedSortAt,
)
}
}
}

View File

@@ -0,0 +1,44 @@
package ru.daemonlord.messenger.data.realtime
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
class RealtimeEventParserTest {
private val parser = RealtimeEventParser(
json = Json { ignoreUnknownKeys = true }
)
@Test
fun parseChatUpdated_returnsMappedEvent() {
val payload = """
{
"event": "chat_updated",
"payload": { "chat_id": 42 },
"timestamp": "2026-03-08T12:00:00Z"
}
""".trimIndent()
val event = parser.parse(payload)
assertTrue(event is RealtimeEvent.ChatUpdated)
assertEquals(42L, (event as RealtimeEvent.ChatUpdated).chatId)
}
@Test
fun parseChatDeleted_returnsMappedEvent() {
val payload = """
{
"event": "chat_deleted",
"payload": { "chat_id": 77 },
"timestamp": "2026-03-08T12:00:00Z"
}
""".trimIndent()
val event = parser.parse(payload)
assertTrue(event is RealtimeEvent.ChatDeleted)
assertEquals(77L, (event as RealtimeEvent.ChatDeleted).chatId)
}
}

View File

@@ -1,4 +1,8 @@
plugins { plugins {
id("com.android.application") version "8.7.2" apply false id("com.android.application") version "8.7.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("com.google.dagger.hilt.android") version "2.52" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
id("org.jetbrains.kotlin.kapt") version "2.0.21" apply false
} }

View File

@@ -14,5 +14,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "BenyaMessengerAndroid" rootProject.name = MessengerAndroid
include(":app") include(:app)

View File

@@ -1,32 +1,32 @@
# Android Checklist (Telegram-подобный клиент) # Android Checklist (Telegram-подобный клиент)
## 1. Базовая архитектура ## 1. Базовая архитектура
- [ ] Kotlin + Jetpack Compose - [x] Kotlin + Jetpack Compose
- [ ] Модульность: `core`, `data`, `feature-*`, `app` - [ ] Модульность: `core`, `data`, `feature-*`, `app`
- [ ] DI (Hilt/Koin) - [x] DI (Hilt/Koin)
- [ ] MVI/MVVM + единый state/presenter слой - [x] MVI/MVVM + единый state/presenter слой
- [ ] Coroutines + Flow + structured concurrency - [x] Coroutines + Flow + structured concurrency
- [ ] Логирование (Timber/Logcat policy) - [ ] Логирование (Timber/Logcat policy)
- [ ] Crash reporting (Firebase Crashlytics/Sentry) - [ ] Crash reporting (Firebase Crashlytics/Sentry)
## 2. Сеть и API ## 2. Сеть и API
- [ ] Retrofit/OkHttp + auth interceptor - [x] Retrofit/OkHttp + auth interceptor
- [ ] Авто-refresh JWT - [x] Авто-refresh JWT
- [ ] Единая обработка ошибок API - [ ] Единая обработка ошибок API
- [ ] Realtime WebSocket слой (reconnect/backoff) - [x] Realtime WebSocket слой (reconnect/backoff)
- [ ] Маппинг DTO -> Domain -> UI models - [x] Маппинг DTO -> Domain -> UI models
- [ ] Версионирование API и feature flags - [ ] Версионирование API и feature flags
## 3. Локальное хранение и sync ## 3. Локальное хранение и sync
- [ ] Room для чатов/сообщений/пользователей - [ ] Room для чатов/сообщений/пользователей
- [ ] DataStore для настроек - [x] DataStore для настроек
- [ ] Кэш медиа (Coil/Exo cache) - [ ] Кэш медиа (Coil/Exo cache)
- [ ] Offline-first чтение истории - [ ] Offline-first чтение истории
- [ ] Очередь отложенных действий (send/edit/delete) - [ ] Очередь отложенных действий (send/edit/delete)
- [ ] Конфликт-резолв и reconcile после reconnect - [ ] Конфликт-резолв и reconcile после reconnect
## 4. Авторизация и аккаунт ## 4. Авторизация и аккаунт
- [ ] Login/Register flow (email-first) - [x] Login/Register flow (email-first)
- [ ] Verify email экран/обработка deep link - [ ] Verify email экран/обработка deep link
- [ ] Reset password flow - [ ] Reset password flow
- [ ] Sessions list + revoke one/all - [ ] Sessions list + revoke one/all
@@ -42,11 +42,11 @@
## 6. Список чатов ## 6. Список чатов
- [ ] Tabs/фильтры (all/private/group/channel/archive) - [ ] Tabs/фильтры (all/private/group/channel/archive)
- [ ] Pinned chats - [x] Pinned chats
- [ ] Unread badge + mention badge `@` - [x] Unread badge + mention badge `@`
- [ ] Muted badge - [x] Muted badge
- [ ] Last message preview по типам медиа - [x] Last message preview по типам медиа
- [ ] Online indicator в private чатах - [x] Online indicator в private чатах
## 7. Сообщения ## 7. Сообщения
- [ ] Отправка текста - [ ] Отправка текста
@@ -108,7 +108,7 @@
- [ ] Privacy-safe logging (без токенов/PII) - [ ] Privacy-safe logging (без токенов/PII)
## 15. Качество ## 15. Качество
- [ ] Unit tests (domain/data) - [x] Unit tests (domain/data)
- [ ] UI tests (Compose test) - [ ] UI tests (Compose test)
- [ ] Integration tests для auth/chat/realtime - [ ] Integration tests для auth/chat/realtime
- [ ] Performance baseline (startup, scroll, media) - [ ] Performance baseline (startup, scroll, media)