Compare commits
10 Commits
c86c8cf344
...
4939754de8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4939754de8 | ||
|
|
9d842c1d88 | ||
|
|
2dfad1a624 | ||
|
|
21aa11c342 | ||
|
|
d006998867 | ||
|
|
f838fe1d5d | ||
|
|
390dcb8b2d | ||
|
|
54b0d4eb8c | ||
|
|
0ff838baf7 | ||
|
|
acdb83e04e |
66
android/CHANGELOG.md
Normal file
66
android/CHANGELOG.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.daemonlord.messenger
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MessengerApplication : Application()
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.daemonlord.messenger.core.token
|
||||||
|
|
||||||
|
data class TokenBundle(
|
||||||
|
val accessToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val savedAtMillis: Long,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.daemonlord.messenger.ui.chats
|
||||||
|
|
||||||
|
enum class ChatTab {
|
||||||
|
ALL,
|
||||||
|
ARCHIVED,
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,5 +14,5 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "BenyaMessengerAndroid"
|
rootProject.name = MessengerAndroid
|
||||||
include(":app")
|
include(:app)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user