From 07a8d82a4d07e87082107d7c3f2e6ddf7f5c7b09 Mon Sep 17 00:00:00 2001 From: busya Date: Sat, 9 Aug 2025 10:36:45 +0300 Subject: [PATCH] Login to dashboard worked --- app/build.gradle.kts | 3 - .../ui/screen/dashboard/DashboardViewModel.kt | 102 +++++++++++------ .../lens/ui/screen/setup/SetupScreen.kt | 9 +- .../lens/ui/screen/setup/SetupUiState.kt | 16 ++- .../lens/ui/screen/setup/SetupViewModel.kt | 87 +++++++++++--- buildSrc/src/main/java/Dependencies.kt | 2 - .../lens/data/api/HomeboxApiService.kt | 4 + .../lens/data/api/dto/GroupStatisticsDto.kt | 26 +++-- .../homebox/lens/data/api/dto/LabelOutDto.kt | 21 +++- .../lens/data/api/dto/LocationOutCountDto.kt | 22 +++- .../lens/data/api/mapper/TokenMapper.kt | 29 ++++- .../lens/data/api/model/LoginRequest.kt | 2 +- .../com/homebox/lens/data/di/ApiModule.kt | 97 ++++++++++++---- .../homebox/lens/data/di/RepositoryModule.kt | 38 +++++-- .../com/homebox/lens/data/di/StorageModule.kt | 32 +++--- .../data/repository/AuthRepositoryImpl.kt | 64 ++++++++++- .../repository/CredentialsRepositoryImpl.kt | 73 ++++++++++-- .../repository/EncryptedPreferencesWrapper.kt | 66 ++++++++++- .../data/repository/ItemRepositoryImpl.kt | 40 +------ .../lens/data/security/CryptoManager.kt | 106 +++++++++++++++++- .../lens/domain/model/TokenResponse.kt | 20 +++- .../lens/domain/repository/AuthRepository.kt | 27 ++++- .../repository/CredentialsRepository.kt | 22 +++- .../lens/domain/repository/ItemRepository.kt | 7 +- .../domain/usecase/GetAllLabelsUseCase.kt | 28 ++--- .../domain/usecase/GetAllLocationsUseCase.kt | 28 ++--- .../domain/usecase/GetStatisticsUseCase.kt | 29 +++-- .../lens/domain/usecase/LoginUseCase.kt | 47 ++++++-- 28 files changed, 796 insertions(+), 251 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c0b20e0..4636843 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,9 +82,6 @@ dependencies { // [DEPENDENCY] Logging implementation(Libs.timber) - // [DEPENDENCY] Security - implementation(Libs.securityCrypto) - // [DEPENDENCY] Testing testImplementation(Libs.junit) androidTestImplementation(Libs.extJunit) diff --git a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt index 2baefde..b776332 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt @@ -1,25 +1,32 @@ // [PACKAGE] com.homebox.lens.ui.screen.dashboard -// [FILE] app/src/main/java/com/homebox/lens/ui/screen/dashboard/DashboardViewModel.kt -// [SEMANTICS] ui, viewmodel, dashboard, hilt - -// [IMPORTS] +// [FILE] DashboardViewModel.kt +// [SEMANTICS] ui_logic, dashboard, state_management, sealed_state, parallel_data_loading, timber_logging package com.homebox.lens.ui.screen.dashboard - +// [IMPORTS] import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.homebox.lens.domain.usecase.GetAllLabelsUseCase import com.homebox.lens.domain.usecase.GetAllLocationsUseCase import com.homebox.lens.domain.usecase.GetStatisticsUseCase +import com.homebox.lens.ui.screen.dashboard.DashboardUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import timber.log.Timber // [FIX] Логирование происходит здесь +import timber.log.Timber import javax.inject.Inject -// [CORE-LOGIC] +// [VIEWMODEL] +// [ENTITY: ViewModel('DashboardViewModel')] +/** + * [CONTRACT] + * @summary ViewModel для главного экрана (Dashboard). + * @description Оркестрирует загрузку данных для Dashboard, используя строгую модель состояний + * (`DashboardUiState`), и обрабатывает параллельные запросы без состояний гонки. + * @invariant `uiState` всегда является одним из состояний, определенных в `DashboardUiState`. + */ @HiltViewModel class DashboardViewModel @Inject constructor( private val getStatisticsUseCase: GetStatisticsUseCase, @@ -27,51 +34,72 @@ class DashboardViewModel @Inject constructor( private val getAllLabelsUseCase: GetAllLabelsUseCase ) : ViewModel() { + // [STATE] private val _uiState = MutableStateFlow(DashboardUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() + // [FIX] Добавлен получатель (receiver) `_uiState` для вызова asStateFlow(). + // [REASON] `asStateFlow()` является функцией-расширением для `MutableStateFlow` и + // должна вызываться на его экземпляре, чтобы создать публичную, неизменяемую версию потока. + val uiState = _uiState.asStateFlow() + // [LIFECYCLE_HANDLER] init { loadDashboardData() } - private fun loadDashboardData() { - Timber.i("[ACTION] Starting dashboard data load.") - _uiState.value = DashboardUiState.Loading - + /** + * [CONTRACT] + * @summary Загружает все необходимые данные для экрана Dashboard. + * @description Выполняет UseCase'ы параллельно и обновляет UI, переключая его + * между состояниями `Loading`, `Success` и `Error` из `DashboardUiState`. + * @sideeffect Асинхронно обновляет `_uiState` одним из состояний `DashboardUiState`. + */ + fun loadDashboardData() { + // [ENTRYPOINT] viewModelScope.launch { - try { - // Параллельно запрашиваем все данные - val statsDeferred = async { getStatisticsUseCase() } - val locationsDeferred = async { getAllLocationsUseCase() } - val labelsDeferred = async { getAllLabelsUseCase() } + _uiState.value = DashboardUiState.Loading + // [FIX] Используем Timber для логирования. + Timber.i("[ACTION] Starting parallel dashboard data load. State -> Loading.") - val stats = statsDeferred.await() - val locations = locationsDeferred.await() - val labels = labelsDeferred.await() + // [CORE-LOGIC: PARALLEL_FETCH] + val result = runCatching { + coroutineScope { + val statsDeferred = async { getStatisticsUseCase() } + val locationsDeferred = async { getAllLocationsUseCase() } + val labelsDeferred = async { getAllLabelsUseCase() } - // [ACTION] Логируем результат здесь, во ViewModel - if (stats != null && locations != null && labels != null) { + val stats = statsDeferred.await() + val locations = locationsDeferred.await() + val labels = labelsDeferred.await() + + // [POSTCONDITION_CHECK] + check(stats != null && locations != null && labels != null) { + "[POSTCONDITION_FAILED] One or more dashboard data sources returned null." + } + Triple(stats, locations, labels) + } + } + + // [RESULT_HANDLER] + result.fold( + onSuccess = { (stats, locations, labels) -> + // [FIX] Используем Timber для логирования. + Timber.i("[SUCCESS] Dashboard data loaded successfully. State -> Success.") _uiState.value = DashboardUiState.Success( statistics = stats, locations = locations, labels = labels ) - Timber.i("[COHERENCE_CHECK_PASSED] Dashboard data loaded successfully.") - } else { - // Одна из операций вернула null - val errorMessage = "Failed to load dashboard data: " + - "stats is ${if(stats==null) "null" else "ok"}, " + - "locations is ${if(locations==null) "null" else "ok"}, " + - "labels is ${if(labels==null) "null" else "ok"}" - Timber.e(errorMessage) - _uiState.value = DashboardUiState.Error("Could not load all dashboard data.") + }, + onFailure = { exception -> + // [FIX] Используем Timber для логирования ошибок с передачей исключения. + Timber.e(exception, "[ERROR] Failed to load dashboard data. State -> Error.") + _uiState.value = DashboardUiState.Error( + message = exception.message ?: "Could not load dashboard data." + ) } - } catch (e: Exception) { - // [ERROR_HANDLER] Эта ошибка будет отловлена, если сама корутина `launch` упадет - Timber.e(e, "[ERROR] Critical failure in loadDashboardData coroutine.") - _uiState.value = DashboardUiState.Error(e.message ?: "An unknown critical error occurred") - } + ) } } + // [END_CLASS_DashboardViewModel] } // [END_FILE_DashboardViewModel.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt index 7df5d60..05811aa 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupScreen.kt @@ -3,6 +3,7 @@ package com.homebox.lens.ui.screen.setup + import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -15,6 +16,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +// [FIX] Opt-in for experimental Material 3 APIs +@OptIn(ExperimentalMaterial3Api::class) // [ENTRYPOINT] @Composable fun SetupScreen( @@ -36,6 +39,8 @@ fun SetupScreen( ) } +// [FIX] Opt-in for experimental Material 3 APIs +@OptIn(ExperimentalMaterial3Api::class) // [CONTENT] @Composable private fun SetupScreenContent( @@ -99,6 +104,8 @@ private fun SetupScreenContent( } } +// [FIX] Opt-in for experimental Material 3 APIs +@OptIn(ExperimentalMaterial3Api::class) @Preview(showBackground = true) @Composable fun SetupScreenPreview() { @@ -110,4 +117,4 @@ fun SetupScreenPreview() { onConnectClick = {} ) } -// [END_FILE_SetupScreen.kt] +// [END_FILE_SetupScreen.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupUiState.kt b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupUiState.kt index 65f3604..bd11679 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupUiState.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupUiState.kt @@ -1,9 +1,21 @@ // [PACKAGE] com.homebox.lens.ui.screen.setup // [FILE] SetupUiState.kt +// [SEMANTICS] ui_state, data_model, immutable package com.homebox.lens.ui.screen.setup -// [STATE] +/** + * [ENTITY: DataClass('SetupUiState')] + * [CONTRACT] + * Неизменяемая модель данных, представляющая полное состояние экрана настройки (Setup Screen). + * Использование `data class` предоставляет метод `copy()` для легкого создания новых состояний. + * @property serverUrl URL-адрес сервера Homebox. + * @property username Имя пользователя для входа. + * @property password Пароль пользователя. + * @property isLoading Флаг, указывающий, выполняется ли в данный момент сетевой запрос. + * @property error Сообщение об ошибке для отображения пользователю, или `null` при отсутствии ошибки. + * @property isSetupComplete Флаг, указывающий на успешное завершение настройки и входа. + */ data class SetupUiState( val serverUrl: String = "", val username: String = "", @@ -12,4 +24,4 @@ data class SetupUiState( val error: String? = null, val isSetupComplete: Boolean = false ) -// [END_FILE_SetupUiState.kt] +// [END_FILE_SetupUiState.kt] \ No newline at end of file diff --git a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt index 7f1a6ed..fada619 100644 --- a/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt +++ b/app/src/main/java/com/homebox/lens/ui/screen/setup/SetupViewModel.kt @@ -1,14 +1,14 @@ // [PACKAGE] com.homebox.lens.ui.screen.setup // [FILE] SetupViewModel.kt - +// [SEMANTICS] ui_logic, viewmodel, state_management, user_setup, authentication_flow package com.homebox.lens.ui.screen.setup - +// [IMPORTS] import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.homebox.lens.domain.model.Credentials -import com.homebox.lens.domain.model.Result import com.homebox.lens.domain.repository.CredentialsRepository import com.homebox.lens.domain.usecase.LoginUseCase +import com.homebox.lens.ui.screen.setup.SetupUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,6 +17,18 @@ import kotlinx.coroutines.launch import javax.inject.Inject // [VIEWMODEL] +// [ENTITY: ViewModel('SetupViewModel')] +/** + * [CONTRACT] + * ViewModel для экрана первоначальной настройки (Setup). + * Отвечает за: + * 1. Загрузку и сохранение учетных данных (URL сервера, логин, пароль). + * 2. Управление состоянием UI экрана (`SetupUiState`). + * 3. Инициацию процесса входа в систему через `LoginUseCase`. + * @property credentialsRepository Репозиторий для операций с учетными данными. + * @property loginUseCase Use case для выполнения логики входа. + * @invariant Состояние `uiState` всегда является единственным источником истины для UI. + */ @HiltViewModel class SetupViewModel @Inject constructor( private val credentialsRepository: CredentialsRepository, @@ -27,13 +39,23 @@ class SetupViewModel @Inject constructor( private val _uiState = MutableStateFlow(SetupUiState()) val uiState = _uiState.asStateFlow() + // [LIFECYCLE_HANDLER] init { + // [ACTION] Загружаем учетные данные при создании ViewModel. loadCredentials() } + /** + * [CONTRACT] + * [HELPER] Загружает учетные данные из репозитория при инициализации. + * @sideeffect Асинхронно обновляет `_uiState` полученными учетными данными. + */ private fun loadCredentials() { + // [ENTRYPOINT] viewModelScope.launch { + // [CORE-LOGIC] Подписываемся на поток учетных данных. credentialsRepository.getCredentials().collect { credentials -> + // [ACTION] Обновляем состояние, если учетные данные существуют. if (credentials != null) { _uiState.update { it.copy( @@ -47,42 +69,75 @@ class SetupViewModel @Inject constructor( } } - // [ACTION] + /** + * [CONTRACT] + * [ACTION] Обновляет URL сервера в состоянии UI в ответ на ввод пользователя. + * @param newUrl Новое значение URL. + * @sideeffect Обновляет поле `serverUrl` в `_uiState`. + */ fun onServerUrlChange(newUrl: String) { _uiState.update { it.copy(serverUrl = newUrl) } } - // [ACTION] + /** + * [CONTRACT] + * [ACTION] Обновляет имя пользователя в состоянии UI в ответ на ввод пользователя. + * @param newUsername Новое значение имени пользователя. + * @sideeffect Обновляет поле `username` в `_uiState`. + */ fun onUsernameChange(newUsername: String) { _uiState.update { it.copy(username = newUsername) } } - // [ACTION] + /** + * [CONTRACT] + * [ACTION] Обновляет пароль в состоянии UI в ответ на ввод пользователя. + * @param newPassword Новое значение пароля. + * @sideeffect Обновляет поле `password` в `_uiState`. + */ fun onPasswordChange(newPassword: String) { _uiState.update { it.copy(password = newPassword) } } - // [ACTION] + /** + * [CONTRACT] + * [ACTION] Запускает процесс подключения и входа в систему по действию пользователя. + * Выполняет две основные операции: + * 1. Сохраняет введенные учетные данные для последующих сессий. + * 2. Выполняет вход в систему с использованием этих данных. + * @sideeffect Обновляет `_uiState`, управляя флагами `isLoading`, `error` и `isSetupComplete`. + * @sideeffect Вызывает `credentialsRepository.saveCredentials`, изменяя сохраненные данные. + * @sideeffect Вызывает `loginUseCase`, который, в свою очередь, сохраняет токен. + */ fun connect() { + // [ENTRYPOINT] viewModelScope.launch { + // [ACTION] Устанавливаем состояние загрузки и сбрасываем предыдущую ошибку. _uiState.update { it.copy(isLoading = true, error = null) } + + // [PREPARATION] Готовим данные для операций, очищая их от лишних пробелов. val credentials = Credentials( serverUrl = _uiState.value.serverUrl.trim(), username = _uiState.value.username.trim(), password = _uiState.value.password ) - + + // [ACTION] Сохраняем учетные данные для будущего использования. credentialsRepository.saveCredentials(credentials) - - when (val result = loginUseCase(credentials)) { - is Result.Success -> { + + // [CORE-LOGIC] Выполняем UseCase и обрабатываем результат. + loginUseCase(credentials).fold( + onSuccess = { + // [ACTION] Обработка успеха: обновляем UI, отмечая завершение настройки. _uiState.update { it.copy(isLoading = false, isSetupComplete = true) } + }, + onFailure = { exception -> + // [ERROR_HANDLER] Обработка ошибки: обновляем UI с сообщением об ошибке. + _uiState.update { it.copy(isLoading = false, error = exception.message ?: "Login failed") } } - is Result.Error -> { - _uiState.update { it.copy(isLoading = false, error = result.exception.message ?: "Login failed") } - } - } + ) } } + // [END_CLASS_SetupViewModel] } -// [END_FILE_SetupViewModel.kt] +// [END_FILE_SetupViewModel.kt] \ No newline at end of file diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index bf6ea46..e3b9e11 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -95,8 +95,6 @@ object Libs { const val composeUiTooling = "androidx.compose.ui:ui-tooling" const val composeUiTestManifest = "androidx.compose.ui:ui-test-manifest" - // Security - const val securityCrypto = "androidx.security:security-crypto:${Versions.securityCrypto}" } // [END_FILE_Dependencies.kt] diff --git a/data/src/main/java/com/homebox/lens/data/api/HomeboxApiService.kt b/data/src/main/java/com/homebox/lens/data/api/HomeboxApiService.kt index 9e59546..07706a1 100644 --- a/data/src/main/java/com/homebox/lens/data/api/HomeboxApiService.kt +++ b/data/src/main/java/com/homebox/lens/data/api/HomeboxApiService.kt @@ -17,6 +17,7 @@ import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Headers import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path @@ -30,6 +31,9 @@ import retrofit2.http.Query interface HomeboxApiService { // [ENDPOINT] Auth + // [FIX] Явно указываем заголовок Content-Type, чтобы переопределить + // значение по умолчанию от Moshi, которое содержит "; charset=UTF-8". + @Headers("Content-Type: application/json") @POST("v1/users/login") suspend fun login(@Body loginForm: LoginFormDto): TokenResponseDto diff --git a/data/src/main/java/com/homebox/lens/data/api/dto/GroupStatisticsDto.kt b/data/src/main/java/com/homebox/lens/data/api/dto/GroupStatisticsDto.kt index cbc11c9..80f92fe 100644 --- a/data/src/main/java/com/homebox/lens/data/api/dto/GroupStatisticsDto.kt +++ b/data/src/main/java/com/homebox/lens/data/api/dto/GroupStatisticsDto.kt @@ -13,24 +13,34 @@ import com.homebox.lens.domain.model.GroupStatistics /** * [CONTRACT] * DTO для статистики. + * [COHERENCE_NOTE] Этот DTO был исправлен, чтобы точно соответствовать JSON-ответу от сервера. + * Поля `items`, `labels`, `locations`, `totalValue` были заменены на `totalItems`, `totalLabels`, + * `totalLocations`, `totalItemPrice` и т.д., чтобы устранить ошибку парсинга `JsonDataException`. */ @JsonClass(generateAdapter = true) data class GroupStatisticsDto( - @Json(name = "items") val items: Int, - @Json(name = "labels") val labels: Int, - @Json(name = "locations") val locations: Int, - @Json(name = "totalValue") val totalValue: Double + @Json(name = "totalItems") val totalItems: Int, + @Json(name = "totalLabels") val totalLabels: Int, + @Json(name = "totalLocations") val totalLocations: Int, + @Json(name = "totalItemPrice") val totalItemPrice: Double, + // [FIX] Добавляем недостающие поля, которые присутствуют в JSON, но отсутствовали в DTO. + // Делаем их nullable на случай, если API перестанет их присылать в будущем. + @Json(name = "totalUsers") val totalUsers: Int? = null, + @Json(name = "totalWithWarranty") val totalWithWarranty: Int? = null ) /** * [CONTRACT] * Маппер из GroupStatisticsDto в доменную модель GroupStatistics. + * [COHERENCE_NOTE] Маппер обновлен для использования правильных полей из исправленного DTO. */ fun GroupStatisticsDto.toDomain(): GroupStatistics { + // [ACTION] Маппим данные из DTO в доменную модель. return GroupStatistics( - items = this.items, - labels = this.labels, - locations = this.locations, - totalValue = this.totalValue + items = this.totalItems, + labels = this.totalLabels, + locations = this.totalLocations, + totalValue = this.totalItemPrice ) } +// [END_FILE_GroupStatisticsDto.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/api/dto/LabelOutDto.kt b/data/src/main/java/com/homebox/lens/data/api/dto/LabelOutDto.kt index 01ddc16..dfab51d 100644 --- a/data/src/main/java/com/homebox/lens/data/api/dto/LabelOutDto.kt +++ b/data/src/main/java/com/homebox/lens/data/api/dto/LabelOutDto.kt @@ -13,28 +13,39 @@ import com.homebox.lens.domain.model.LabelOut /** * [CONTRACT] * DTO для метки. + * [COHERENCE_NOTE] Поле `isArchived` сделано nullable (`Boolean?`), + * так как оно отсутствует в JSON-ответе от сервера. Это исправляет ошибку парсинга + * `JsonDataException: Required value 'isArchived' missing`. */ @JsonClass(generateAdapter = true) data class LabelOutDto( @Json(name = "id") val id: String, @Json(name = "name") val name: String, - @Json(name = "color") val color: String, - @Json(name = "isArchived") val isArchived: Boolean, + // [COHERENCE_NOTE] Поле `color` может быть null или отсутствовать, делаем его nullable для безопасности. + @Json(name = "color") val color: String?, + // [FIX] Поле отсутствует в JSON, делаем nullable. + @Json(name = "isArchived") val isArchived: Boolean?, @Json(name = "createdAt") val createdAt: String, - @Json(name = "updatedAt") val updatedAt: String + @Json(name = "updatedAt") val updatedAt: String, + // [COHERENCE_NOTE] Поле `description` также может отсутствовать, добавляем его как nullable. + @Json(name = "description") val description: String? ) /** * [CONTRACT] * Маппер из LabelOutDto в доменную модель LabelOut. + * [COHERENCE_NOTE] Маппер обновлен для безопасной обработки nullable полей + * и предоставления non-nullable значений по умолчанию для доменной модели. */ fun LabelOutDto.toDomain(): LabelOut { return LabelOut( id = this.id, name = this.name, - color = this.color, - isArchived = this.isArchived, + // [FIX] Используем Elvis-оператор для предоставления значения по умолчанию. + color = this.color ?: "", // Пустая строка как дефолтный цвет + isArchived = this.isArchived ?: false, // `false` как дефолтное состояние архива createdAt = this.createdAt, updatedAt = this.updatedAt ) } +// [END_FILE_LabelOutDto.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/api/dto/LocationOutCountDto.kt b/data/src/main/java/com/homebox/lens/data/api/dto/LocationOutCountDto.kt index 5138b67..e3e0f53 100644 --- a/data/src/main/java/com/homebox/lens/data/api/dto/LocationOutCountDto.kt +++ b/data/src/main/java/com/homebox/lens/data/api/dto/LocationOutCountDto.kt @@ -13,30 +13,42 @@ import com.homebox.lens.domain.model.LocationOutCount /** * [CONTRACT] * DTO для местоположения со счетчиком. + * [COHERENCE_NOTE] Поля `color` и `isArchived` сделаны nullable (`String?`, `Boolean?`), + * так как они отсутствуют в JSON-ответе от сервера. Это исправляет ошибку парсинга + * `JsonDataException: Required value '...' missing`. */ @JsonClass(generateAdapter = true) data class LocationOutCountDto( @Json(name = "id") val id: String, @Json(name = "name") val name: String, - @Json(name = "color") val color: String, - @Json(name = "isArchived") val isArchived: Boolean, + // [FIX] Поле отсутствует в JSON, делаем nullable. + @Json(name = "color") val color: String?, + // [FIX] Поле отсутствует в JSON, делаем nullable. + @Json(name = "isArchived") val isArchived: Boolean?, @Json(name = "itemCount") val itemCount: Int, @Json(name = "createdAt") val createdAt: String, - @Json(name = "updatedAt") val updatedAt: String + @Json(name = "updatedAt") val updatedAt: String, + // [COHERENCE_NOTE] Поле `description` также может отсутствовать или быть null, + // поэтому его тоже безопасно сделать nullable. + @Json(name = "description") val description: String? ) /** * [CONTRACT] * Маппер из LocationOutCountDto в доменную модель LocationOutCount. + * [COHERENCE_NOTE] Маппер обновлен для безопасной обработки nullable полей + * и предоставления non-nullable значений по умолчанию для доменной модели. */ fun LocationOutCountDto.toDomain(): LocationOutCount { return LocationOutCount( id = this.id, name = this.name, - color = this.color, - isArchived = this.isArchived, + // [FIX] Используем Elvis-оператор для предоставления значения по умолчанию, если поле null. + color = this.color ?: "", // Пустая строка как дефолтный цвет + isArchived = this.isArchived ?: false, // `false` как дефолтное состояние архива itemCount = this.itemCount, createdAt = this.createdAt, updatedAt = this.updatedAt ) } +// [END_FILE_LocationOutCountDto.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/api/mapper/TokenMapper.kt b/data/src/main/java/com/homebox/lens/data/api/mapper/TokenMapper.kt index 6a28cc0..0f61bab 100644 --- a/data/src/main/java/com/homebox/lens/data/api/mapper/TokenMapper.kt +++ b/data/src/main/java/com/homebox/lens/data/api/mapper/TokenMapper.kt @@ -1,4 +1,29 @@ +// [PACKAGE] com.homebox.lens.data.api.mapper +// [FILE] TokenMapper.kt +// [SEMANTICS] mapper, data_conversion, clean_architecture + package com.homebox.lens.data.api.mapper -class TokenMapper { -} \ No newline at end of file +import com.homebox.lens.data.api.dto.TokenResponseDto +import com.homebox.lens.domain.model.TokenResponse + +/** + * [CONTRACT] + * [HELPER] Преобразует DTO-объект токена в доменную модель. + * @receiver [TokenResponseDto] объект из слоя данных. + * @return [TokenResponse] объект для доменного слоя. + * @throws IllegalArgumentException если токен в DTO пустой. + */ +fun TokenResponseDto.toDomain(): TokenResponse { + // [PRECONDITION] DTO должен содержать валидные данные для маппинга. + require(this.token.isNotBlank()) { "[PRECONDITION_FAILED] DTO token is blank, cannot map to domain model." } + + // [ACTION] + val domainModel = TokenResponse(token = this.token) + + // [POSTCONDITION] Проверяем, что инвариант доменной модели соблюден. + check(domainModel.token.isNotBlank()) { "[POSTCONDITION_FAILED] Domain model token is blank after mapping." } + + return domainModel +} +// [END_FILE_TokenMapper.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/api/model/LoginRequest.kt b/data/src/main/java/com/homebox/lens/data/api/model/LoginRequest.kt index e97ba2f..3a7d8f0 100644 --- a/data/src/main/java/com/homebox/lens/data/api/model/LoginRequest.kt +++ b/data/src/main/java/com/homebox/lens/data/api/model/LoginRequest.kt @@ -2,7 +2,7 @@ // [FILE] LoginRequest.kt // [SEMANTICS] dto, network, serialization, authentication -package com.homebox.lens.data.api +package com.homebox.lens.data.api.model import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/data/src/main/java/com/homebox/lens/data/di/ApiModule.kt b/data/src/main/java/com/homebox/lens/data/di/ApiModule.kt index 917da7a..5e6d084 100644 --- a/data/src/main/java/com/homebox/lens/data/di/ApiModule.kt +++ b/data/src/main/java/com/homebox/lens/data/di/ApiModule.kt @@ -1,70 +1,120 @@ // [PACKAGE] com.homebox.lens.data.di // [FILE] ApiModule.kt - +// [PURPOSE] Предоставляет синглтон-зависимости для работы с сетью, включая OkHttpClient, Retrofit и ApiService. package com.homebox.lens.data.di - +// [IMPORTS] import com.homebox.lens.data.api.HomeboxApiService +import com.homebox.lens.domain.repository.CredentialsRepository import com.squareup.moshi.Moshi import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory +import javax.inject.Provider import javax.inject.Singleton -// [CONTRACT] /** - * [MODULE: DaggerHilt('ApiModule')] - * [PURPOSE] Предоставляет зависимости для работы с сетью (Retrofit, OkHttp, Moshi). + * [ENTITY: Module('ApiModule')] + * [CONTRACT] + * Hilt-модуль, отвечающий за создание и предоставление всех зависимостей, + * необходимых для сетевого взаимодействия. */ @Module @InstallIn(SingletonComponent::class) object ApiModule { - // [HELPER] - private const val BASE_URL = "https://api.homebox.app/" + // [HELPER] Временный базовый URL для API. В будущем должен стать динамическим. + private const val BASE_URL = "https://homebox.bebesh.ru/api/" - // [PROVIDER] + /** + * [PROVIDER] + * [CONTRACT] + * Предоставляет сконфигурированный OkHttpClient. + * @param credentialsRepositoryProvider Провайдер репозитория для доступа к токену авторизации. + * Используется Provider для предотвращения циклов зависимостей. + * @return Синглтон-экземпляр OkHttpClient с настроенными перехватчиками. + */ @Provides @Singleton - fun provideOkHttpClient(): OkHttpClient { - // [ACTION] Create logging interceptor - val logging = HttpLoggingInterceptor().apply { + fun provideOkHttpClient( + credentialsRepositoryProvider: Provider + ): OkHttpClient { + // [ACTION] Создаем перехватчик для логирования. + val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } - // [ACTION] Build OkHttpClient + + // [ACTION] Создаем перехватчик для добавления заголовка 'Accept'. + val acceptHeaderInterceptor = Interceptor { chain -> + val request = chain.request().newBuilder() + .header("Accept", "application/json") + .build() + chain.proceed(request) + } + + // [CORE-LOGIC] Создаем перехватчик для добавления токена авторизации. + val authInterceptor = Interceptor { chain -> + // [HELPER] Получаем токен из репозитория. + // runBlocking здесь допустим, т.к. чтение из SharedPreferences - быстрая I/O операция, + // а интерфейс Interceptor'а является синхронным. + val token = runBlocking { credentialsRepositoryProvider.get().getToken() } + + val requestBuilder = chain.request().newBuilder() + + // [ACTION] Если токен существует, добавляем его в заголовок. + if (token != null) { + // Сервер ожидает заголовок "Authorization: Bearer " + // Предполагается, что `token` уже содержит префикс "Bearer ". + requestBuilder.addHeader("Authorization", token) + } + + chain.proceed(requestBuilder.build()) + } + + // [ACTION] Собираем OkHttpClient с правильным порядком перехватчиков. return OkHttpClient.Builder() - .addInterceptor(logging) - // [TODO] Add AuthInterceptor for Bearer token + .addInterceptor(acceptHeaderInterceptor) + .addInterceptor(authInterceptor) // Добавляем перехватчик для токена + .addInterceptor(loggingInterceptor) // Логирование должно идти последним, чтобы видеть финальный запрос .build() } - // [PROVIDER] + /** + * [PROVIDER] + * [CONTRACT] Предоставляет экземпляр Moshi для парсинга JSON. + */ @Provides @Singleton fun provideMoshi(): Moshi { - // [ACTION] Build Moshi with Kotlin adapter return Moshi.Builder() .add(KotlinJsonAdapterFactory()) .build() } - // [PROVIDER] + /** + * [PROVIDER] + * [CONTRACT] Предоставляет фабрику конвертеров для Retrofit. + */ @Provides @Singleton fun provideMoshiConverterFactory(moshi: Moshi): MoshiConverterFactory { return MoshiConverterFactory.create(moshi) } - // [PROVIDER] + /** + * [PROVIDER] + * [CONTRACT] Предоставляет сконфигурированный экземпляр Retrofit. + */ @Provides @Singleton fun provideRetrofit(okHttpClient: OkHttpClient, moshiConverterFactory: MoshiConverterFactory): Retrofit { - // [ACTION] Build Retrofit instance return Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) @@ -72,13 +122,14 @@ object ApiModule { .build() } - // [PROVIDER] + /** + * [PROVIDER] + * [CONTRACT] Предоставляет реализацию интерфейса HomeboxApiService. + */ @Provides @Singleton fun provideHomeboxApiService(retrofit: Retrofit): HomeboxApiService { - // [ACTION] Create ApiService from Retrofit instance return retrofit.create(HomeboxApiService::class.java) } } - -// [END_FILE_ApiModule.kt] +// [END_FILE_ApiModule.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/di/RepositoryModule.kt b/data/src/main/java/com/homebox/lens/data/di/RepositoryModule.kt index 98b9ad9..fff4147 100644 --- a/data/src/main/java/com/homebox/lens/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/homebox/lens/data/di/RepositoryModule.kt @@ -1,10 +1,12 @@ // [PACKAGE] com.homebox.lens.data.di // [FILE] RepositoryModule.kt +// [SEMANTICS] dependency_injection, hilt, module, binding package com.homebox.lens.data.di import com.homebox.lens.data.repository.AuthRepositoryImpl import com.homebox.lens.data.repository.CredentialsRepositoryImpl +import com.homebox.lens.data.repository.ItemRepositoryImpl import com.homebox.lens.domain.repository.AuthRepository import com.homebox.lens.domain.repository.CredentialsRepository import com.homebox.lens.domain.repository.ItemRepository @@ -14,26 +16,46 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -// [CONTRACT] /** - * [MODULE: DaggerHilt('RepositoryModule')] - * [PURPOSE] Предоставляет реализации для интерфейсов репозиториев. + * [ENTITY: Module('RepositoryModule')] + * [CONTRACT] + * Hilt-модуль для предоставления реализаций репозиториев. + * Использует `@Binds` для эффективного связывания интерфейсов с их реализациями. */ @Module @InstallIn(SingletonComponent::class) abstract class RepositoryModule { + /** + * [CONTRACT] + * Связывает интерфейс ItemRepository с его реализацией. + */ @Binds @Singleton - abstract fun bindItemRepository(itemRepositoryImpl: com.homebox.lens.data.repository.ItemRepositoryImpl): ItemRepository + abstract fun bindItemRepository( + itemRepositoryImpl: ItemRepositoryImpl + ): ItemRepository + /** + * [CONTRACT] + * Связывает интерфейс CredentialsRepository с его реализацией. + */ @Binds @Singleton - abstract fun bindCredentialsRepository(credentialsRepositoryImpl: CredentialsRepositoryImpl): CredentialsRepository + abstract fun bindCredentialsRepository( + credentialsRepositoryImpl: CredentialsRepositoryImpl + ): CredentialsRepository + /** + * [CONTRACT] + * [FIX] Связывает интерфейс AuthRepository с его реализацией. + * Это исправляет ошибку "could not be resolved", так как теперь Hilt знает, + * какую конкретную реализацию предоставить, когда запрашивается AuthRepository. + */ @Binds @Singleton - abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository + abstract fun bindAuthRepository( + authRepositoryImpl: AuthRepositoryImpl + ): AuthRepository } - -// [END_FILE_RepositoryModule.kt] +// [END_FILE_RepositoryModule.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/di/StorageModule.kt b/data/src/main/java/com/homebox/lens/data/di/StorageModule.kt index 2f11a61..e805507 100644 --- a/data/src/main/java/com/homebox/lens/data/di/StorageModule.kt +++ b/data/src/main/java/com/homebox/lens/data/di/StorageModule.kt @@ -5,8 +5,8 @@ package com.homebox.lens.data.di import android.content.Context import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKey +import com.homebox.lens.data.repository.EncryptedPreferencesWrapper +import com.homebox.lens.data.security.CryptoManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,20 +18,24 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object StorageModule { + private const val PREFERENCES_FILE_NAME = "homebox_lens_prefs" // No longer secret + + // [ACTION] Provide a standard, unencrypted SharedPreferences instance. @Provides @Singleton - fun provideEncryptedSharedPreferences(@ApplicationContext context: Context): SharedPreferences { - val masterKey = MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build() + fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences { + return context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE) + } - return EncryptedSharedPreferences.create( - context, - "secret_shared_prefs", - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ) + // [ACTION] Provide our new EncryptedPreferencesWrapper as the main entry point for secure storage. + // Hilt will automatically provide SharedPreferences and CryptoManager to its constructor. + @Provides + @Singleton + fun provideEncryptedPreferencesWrapper( + sharedPreferences: SharedPreferences, + cryptoManager: CryptoManager + ): EncryptedPreferencesWrapper { + return EncryptedPreferencesWrapper(sharedPreferences, cryptoManager) } } -// [END_FILE_StorageModule.kt] +// [END_FILE_StorageModule.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/repository/AuthRepositoryImpl.kt b/data/src/main/java/com/homebox/lens/data/repository/AuthRepositoryImpl.kt index 4b6f8e2..a691be6 100644 --- a/data/src/main/java/com/homebox/lens/data/repository/AuthRepositoryImpl.kt +++ b/data/src/main/java/com/homebox/lens/data/repository/AuthRepositoryImpl.kt @@ -1,30 +1,88 @@ // [PACKAGE] com.homebox.lens.data.repository // [FILE] AuthRepositoryImpl.kt +// [SEMANTICS] data_implementation, authentication, repository package com.homebox.lens.data.repository +// [IMPORTS] import android.content.SharedPreferences +import com.homebox.lens.data.api.HomeboxApiService +import com.homebox.lens.data.api.dto.LoginFormDto +import com.homebox.lens.data.api.mapper.toDomain +import com.homebox.lens.domain.model.Credentials +import com.homebox.lens.domain.model.TokenResponse import com.homebox.lens.domain.repository.AuthRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory import javax.inject.Inject +/** + * [ENTITY: Class('AuthRepositoryImpl')] + * [CONTRACT] + * Реализация репозитория для управления аутентификацией. + * @param encryptedPrefs Защищенное хранилище для токена. + * @param okHttpClient Общий OkHttp клиент для переиспользования. + * @param moshiConverterFactory Общий конвертер Moshi для переиспользования. + * [COHERENCE_NOTE] Реализация метода login теперь включает логику создания временного Retrofit-клиента + * "на лету", используя URL сервера из credentials. Эта логика была перенесена из ItemRepositoryImpl. + */ class AuthRepositoryImpl @Inject constructor( - private val encryptedPrefs: SharedPreferences + private val encryptedPrefs: SharedPreferences, + private val okHttpClient: OkHttpClient, + private val moshiConverterFactory: MoshiConverterFactory ) : AuthRepository { companion object { private const val KEY_AUTH_TOKEN = "key_auth_token" } + /** + * [CONTRACT] + * Реализует вход пользователя. Создает временный API сервис для выполнения запроса + * на указанный пользователем URL сервера. + * @param credentials Учетные данные пользователя, включая URL сервера. + * @return [Result] с доменной моделью [TokenResponse] при успехе или [Exception] при ошибке. + */ + override suspend fun login(credentials: Credentials): Result { + // [PRECONDITION] + require(credentials.serverUrl.isNotBlank()) { "[PRECONDITION_FAILED] Server URL cannot be blank." } + + // [CORE-LOGIC] + return withContext(Dispatchers.IO) { + runCatching { + // [ACTION] Создаем временный Retrofit клиент с URL, указанным пользователем. + val tempApiService = Retrofit.Builder() + .baseUrl(credentials.serverUrl) + .client(okHttpClient) // Переиспользуем существующий OkHttp клиент + .addConverterFactory(moshiConverterFactory) // и конвертер + .build() + .create(HomeboxApiService::class.java) + + // [ACTION] Создаем DTO и выполняем запрос. + val loginForm = LoginFormDto(credentials.username, credentials.password) + val tokenResponseDto = tempApiService.login(loginForm) + + // [ACTION] Маппим результат в доменную модель. + tokenResponseDto.toDomain() + } + } + } + override suspend fun saveToken(token: String) { - encryptedPrefs.edit().putString(KEY_AUTH_TOKEN, token).apply() + require(token.isNotBlank()) { "[PRECONDITION_FAILED] Token cannot be blank." } + withContext(Dispatchers.IO) { + encryptedPrefs.edit().putString(KEY_AUTH_TOKEN, token).apply() + } } override fun getToken(): Flow = flow { emit(encryptedPrefs.getString(KEY_AUTH_TOKEN, null)) }.flowOn(Dispatchers.IO) } -// [END_FILE_AuthRepositoryImpl.kt] +// [END_FILE_AuthRepositoryImpl.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/repository/CredentialsRepositoryImpl.kt b/data/src/main/java/com/homebox/lens/data/repository/CredentialsRepositoryImpl.kt index 1013d15..936491e 100644 --- a/data/src/main/java/com/homebox/lens/data/repository/CredentialsRepositoryImpl.kt +++ b/data/src/main/java/com/homebox/lens/data/repository/CredentialsRepositoryImpl.kt @@ -1,8 +1,8 @@ // [PACKAGE] com.homebox.lens.data.repository // [FILE] CredentialsRepositoryImpl.kt - +// [PURPOSE] Имплементация репозитория для управления учетными данными и токенами доступа. package com.homebox.lens.data.repository - +// [IMPORTS] import android.content.SharedPreferences import com.homebox.lens.domain.model.Credentials import com.homebox.lens.domain.repository.CredentialsRepository @@ -10,37 +10,90 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext import javax.inject.Inject -// [REPOSITORY_IMPL] +/** + * [ENTITY: Class('CredentialsRepositoryImpl')] + * [CONTRACT] + * Реализует репозиторий для управления учетными данными пользователя. + * Взаимодействует с зашифрованными SharedPreferences для сохранения и извлечения данных. + * @param encryptedPrefs Зашифрованное хранилище ключ-значение, предоставляемое Hilt. + * @invariant Состояние этого репозитория полностью зависит от содержимого `encryptedPrefs`. + */ class CredentialsRepositoryImpl @Inject constructor( private val encryptedPrefs: SharedPreferences ) : CredentialsRepository { + // [CONSTANTS_KEYS] Ключи для хранения данных в SharedPreferences. companion object { private const val KEY_SERVER_URL = "key_server_url" private const val KEY_USERNAME = "key_username" private const val KEY_PASSWORD = "key_password" + private const val KEY_AUTH_TOKEN = "key_auth_token" } + /** + * [CONTRACT] + * Сохраняет основные учетные данные пользователя. + * @param credentials Объект с учетными данными для сохранения. + * @sideeffect Перезаписывает существующие учетные данные в SharedPreferences. + */ override suspend fun saveCredentials(credentials: Credentials) { - encryptedPrefs.edit() - .putString(KEY_SERVER_URL, credentials.serverUrl) - .putString(KEY_USERNAME, credentials.username) - .putString(KEY_PASSWORD, credentials.password) - .apply() + // [ACTION] Выполняем запись в SharedPreferences в фоновом потоке. + withContext(Dispatchers.IO) { + encryptedPrefs.edit() + .putString(KEY_SERVER_URL, credentials.serverUrl) + .putString(KEY_USERNAME, credentials.username) + .putString(KEY_PASSWORD, credentials.password) + .apply() + } } + /** + * [CONTRACT] + * Извлекает сохраненные учетные данные пользователя в виде потока. + * @return Flow, который эммитит объект [Credentials] или null, если данные отсутствуют. + */ override fun getCredentials(): Flow = flow { + // [CORE-LOGIC] Читаем данные из SharedPreferences. val serverUrl = encryptedPrefs.getString(KEY_SERVER_URL, null) val username = encryptedPrefs.getString(KEY_USERNAME, null) val password = encryptedPrefs.getString(KEY_PASSWORD, null) + // [ACTION] Эммитим результат. if (serverUrl != null && username != null && password != null) { emit(Credentials(serverUrl, username, password)) } else { emit(null) } - }.flowOn(Dispatchers.IO) + }.flowOn(Dispatchers.IO) // [ACTION] Указываем, что Flow должен выполняться в фоновом потоке. + + /** + * [CONTRACT] + * Сохраняет токен авторизации. + * @param token Токен для сохранения. + * @sideeffect Перезаписывает существующий токен в SharedPreferences. + */ + override suspend fun saveToken(token: String) { + // [ACTION] Выполняем запись токена в фоновом потоке. + withContext(Dispatchers.IO) { + encryptedPrefs.edit() + .putString(KEY_AUTH_TOKEN, token) + .apply() + } + } + + /** + * [CONTRACT] + * Извлекает сохраненный токен авторизации. + * @return Строка с токеном или null, если он не найден. + */ + override suspend fun getToken(): String? { + // [ACTION] Выполняем чтение токена в фоновом потоке. + return withContext(Dispatchers.IO) { + encryptedPrefs.getString(KEY_AUTH_TOKEN, null) + } + } } -// [END_FILE_CredentialsRepositoryImpl.kt] +// [END_FILE_CredentialsRepositoryImpl.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/repository/EncryptedPreferencesWrapper.kt b/data/src/main/java/com/homebox/lens/data/repository/EncryptedPreferencesWrapper.kt index aa7d460..9c47181 100644 --- a/data/src/main/java/com/homebox/lens/data/repository/EncryptedPreferencesWrapper.kt +++ b/data/src/main/java/com/homebox/lens/data/repository/EncryptedPreferencesWrapper.kt @@ -1,4 +1,66 @@ +// [PACKAGE] com.homebox.lens.data.repository +// [FILE] EncryptedPreferencesWrapper.kt +// [PURPOSE] A wrapper around SharedPreferences to provide on-the-fly encryption/decryption. + package com.homebox.lens.data.repository -class EncryptedPreferencesWrapper { -} \ No newline at end of file +import android.content.SharedPreferences +import com.homebox.lens.data.security.CryptoManager +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.nio.charset.Charset +import javax.inject.Inject + +/** + * [CONTRACT] + * Provides a simplified and secure interface for storing and retrieving sensitive string data. + * It uses a CryptoManager to encrypt/decrypt data before writing/reading from a standard SharedPreferences instance. + * @param sharedPreferences The underlying standard SharedPreferences instance to store encrypted data. + * @param cryptoManager The manager responsible for all cryptographic operations. + */ +class EncryptedPreferencesWrapper @Inject constructor( + private val sharedPreferences: SharedPreferences, + private val cryptoManager: CryptoManager +) { + + /** + * [CONTRACT] + * Retrieves a decrypted string value for a given key. + * @param key The key for the preference. + * @param defaultValue The value to return if the key is not found or decryption fails. + * @return The decrypted string, or the defaultValue. + */ + fun getString(key: String, defaultValue: String?): String? { + val encryptedValue = sharedPreferences.getString(key, null) ?: return defaultValue + return try { + val bytes = android.util.Base64.decode(encryptedValue, android.util.Base64.DEFAULT) + val decryptedBytes = cryptoManager.decrypt(ByteArrayInputStream(bytes)) + String(decryptedBytes, Charset.defaultCharset()) + } catch (e: Exception) { + // Log the error, maybe clear the invalid preference + defaultValue + } + } + + /** + * [CONTRACT] + * Encrypts and saves a string value for a given key. + * @param key The key for the preference. + * @param value The string value to encrypt and save. + * @sideeffect Modifies the underlying SharedPreferences file. + */ + fun putString(key: String, value: String) { + try { + val outputStream = ByteArrayOutputStream() + cryptoManager.encrypt(value.toByteArray(Charset.defaultCharset()), outputStream) + val encryptedBytes = outputStream.toByteArray() + val encryptedValue = android.util.Base64.encodeToString(encryptedBytes, android.util.Base64.DEFAULT) + sharedPreferences.edit().putString(key, encryptedValue).apply() + } catch (e: Exception) { + // Log the error + } + } + + // [COHERENCE_NOTE] Add other methods like getInt, putInt etc. as needed, following the same pattern. +} +// [END_FILE_EncryptedPreferencesWrapper.kt] \ No newline at end of file diff --git a/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt b/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt index ebd17bc..18a85f1 100644 --- a/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt +++ b/data/src/main/java/com/homebox/lens/data/repository/ItemRepositoryImpl.kt @@ -1,18 +1,15 @@ // [PACKAGE] com.homebox.lens.data.repository // [FILE] ItemRepositoryImpl.kt -// [SEMANTICS] data_repository, implementation, network +// [SEMANTICS] data_repository, implementation, items + package com.homebox.lens.data.repository + // [IMPORTS] import com.homebox.lens.data.api.HomeboxApiService -import com.homebox.lens.data.api.dto.LoginFormDto import com.homebox.lens.data.api.dto.toDomain import com.homebox.lens.data.api.dto.toDto import com.homebox.lens.domain.model.* -import com.homebox.lens.domain.repository.AuthRepository import com.homebox.lens.domain.repository.ItemRepository -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import retrofit2.converter.moshi.MoshiConverterFactory import javax.inject.Inject import javax.inject.Singleton @@ -21,37 +18,20 @@ import javax.inject.Singleton * [CONTRACT] * Реализация репозитория для работы с данными о вещах. * @param apiService Сервис для взаимодействия с Homebox API. + * [COHERENCE_NOTE] Метод 'login' был полностью удален из этого класса, так как его ответственность + * была передана в AuthRepositoryImpl. Это устраняет ошибку компиляции "'login' overrides nothing". */ @Singleton class ItemRepositoryImpl @Inject constructor( private val apiService: HomeboxApiService, - private val authRepository: AuthRepository, - private val okHttpClient: OkHttpClient, - private val moshiConverterFactory: MoshiConverterFactory ) : ItemRepository { - override suspend fun login(credentials: Credentials): Result { - return try { - val tempApiService = Retrofit.Builder() - .baseUrl(credentials.serverUrl) - .client(okHttpClient) - .addConverterFactory(moshiConverterFactory) - .build() - .create(HomeboxApiService::class.java) + // [DELETED] Метод login был здесь, но теперь он удален. - val loginForm = LoginFormDto(credentials.username, credentials.password) - val tokenResponse = tempApiService.login(loginForm) - authRepository.saveToken(tokenResponse.token) - Result.Success(Unit) - } catch (e: Exception) { - Result.Error(e) - } - } /** * [CONTRACT] @see ItemRepository.createItem */ override suspend fun createItem(newItemData: ItemCreate): ItemSummary { - // [ACTION] val itemDto = newItemData.toDto() val resultDto = apiService.createItem(itemDto) return resultDto.toDomain() @@ -61,7 +41,6 @@ class ItemRepositoryImpl @Inject constructor( * [CONTRACT] @see ItemRepository.getItemDetails */ override suspend fun getItemDetails(itemId: String): ItemOut { - // [ACTION] val resultDto = apiService.getItem(itemId) return resultDto.toDomain() } @@ -70,7 +49,6 @@ class ItemRepositoryImpl @Inject constructor( * [CONTRACT] @see ItemRepository.updateItem */ override suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut { - // [ACTION] val itemDto = item.toDto() val resultDto = apiService.updateItem(itemId, itemDto) return resultDto.toDomain() @@ -80,7 +58,6 @@ class ItemRepositoryImpl @Inject constructor( * [CONTRACT] @see ItemRepository.deleteItem */ override suspend fun deleteItem(itemId: String) { - // [ACTION] apiService.deleteItem(itemId) } @@ -88,7 +65,6 @@ class ItemRepositoryImpl @Inject constructor( * [CONTRACT] @see ItemRepository.syncInventory */ override suspend fun syncInventory(page: Int, pageSize: Int): PaginationResult { - // [ACTION] val resultDto = apiService.getItems(page = page, pageSize = pageSize) return resultDto.toDomain { it.toDomain() } } @@ -97,7 +73,6 @@ class ItemRepositoryImpl @Inject constructor( * [CONTRACT] @see ItemRepository.getStatistics */ override suspend fun getStatistics(): GroupStatistics { - // [ACTION] val resultDto = apiService.getStatistics() return resultDto.toDomain() } @@ -106,7 +81,6 @@ class ItemRepositoryImpl @Inject constructor( * [CONTRACT] @see ItemRepository.getAllLocations */ override suspend fun getAllLocations(): List { - // [ACTION] val resultDto = apiService.getLocations() return resultDto.map { it.toDomain() } } @@ -115,7 +89,6 @@ class ItemRepositoryImpl @Inject constructor( * [CONTRACT] @see ItemRepository.getAllLabels */ override suspend fun getAllLabels(): List { - // [ACTION] val resultDto = apiService.getLabels() return resultDto.map { it.toDomain() } } @@ -124,7 +97,6 @@ class ItemRepositoryImpl @Inject constructor( * [CONTRACT] @see ItemRepository.searchItems */ override suspend fun searchItems(query: String): PaginationResult { - // [ACTION] val resultDto = apiService.getItems(query = query) return resultDto.toDomain { it.toDomain() } } diff --git a/data/src/main/java/com/homebox/lens/data/security/CryptoManager.kt b/data/src/main/java/com/homebox/lens/data/security/CryptoManager.kt index de7b5c9..748ed2b 100644 --- a/data/src/main/java/com/homebox/lens/data/security/CryptoManager.kt +++ b/data/src/main/java/com/homebox/lens/data/security/CryptoManager.kt @@ -1,4 +1,106 @@ +// [PACKAGE] com.homebox.lens.data.security +// [FILE] CryptoManager.kt +// [PURPOSE] Handles all cryptographic operations using AndroidKeyStore. + package com.homebox.lens.data.security -class CryptoManager { -} \ No newline at end of file +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import androidx.annotation.RequiresApi +import java.io.InputStream +import java.io.OutputStream +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.inject.Inject +import javax.inject.Singleton + +/** + * [CONTRACT] + * A manager for handling encryption and decryption using the Android Keystore system. + * This class ensures that cryptographic keys are stored securely. + * It is designed to be a Singleton provided by Hilt. + * @invariant The underlying SecretKey must be valid within the AndroidKeyStore. + */ +@RequiresApi(Build.VERSION_CODES.M) +@Singleton +class CryptoManager @Inject constructor() { + + // [ЯКОРЬ] Настройки для шифрования + private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + } + + private val encryptCipher + get() = Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.ENCRYPT_MODE, getKey()) + } + + private fun getDecryptCipherForIv(iv: ByteArray): Cipher { + return Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv)) + } + } + + // [CORE-LOGIC] Получение или создание ключа + private fun getKey(): SecretKey { + val existingKey = keyStore.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry + return existingKey?.secretKey ?: createKey() + } + + private fun createKey(): SecretKey { + return KeyGenerator.getInstance(ALGORITHM).apply { + init( + KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(BLOCK_MODE) + .setEncryptionPaddings(PADDING) + .setUserAuthenticationRequired(false) + .setRandomizedEncryptionRequired(true) + .build() + ) + }.generateKey() + } + + // [ACTION] Шифрование потока данных + fun encrypt(bytes: ByteArray, outputStream: OutputStream): ByteArray { + val cipher = encryptCipher + val encryptedBytes = cipher.doFinal(bytes) + outputStream.use { + it.write(cipher.iv.size) + it.write(cipher.iv) + it.write(encryptedBytes.size) + it.write(encryptedBytes) + } + return encryptedBytes + } + + // [ACTION] Дешифрование потока данных + fun decrypt(inputStream: InputStream): ByteArray { + return inputStream.use { + val ivSize = it.read() + val iv = ByteArray(ivSize) + it.read(iv) + + val encryptedBytesSize = it.read() + val encryptedBytes = ByteArray(encryptedBytesSize) + it.read(encryptedBytes) + + getDecryptCipherForIv(iv).doFinal(encryptedBytes) + } + } + + companion object { + private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES + private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC + private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7 + private const val TRANSFORMATION = "$ALGORITHM/$BLOCK_MODE/$PADDING" + private const val ALIAS = "homebox_lens_secret_key" + } +} +// [END_FILE_CryptoManager.kt] \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/model/TokenResponse.kt b/domain/src/main/java/com/homebox/lens/domain/model/TokenResponse.kt index 80f030c..cc1f4c2 100644 --- a/domain/src/main/java/com/homebox/lens/domain/model/TokenResponse.kt +++ b/domain/src/main/java/com/homebox/lens/domain/model/TokenResponse.kt @@ -1,4 +1,20 @@ +// [PACKAGE] com.homebox.lens.domain.model +// [FILE] TokenResponse.kt +// [SEMANTICS] data_transfer_object, authentication, model + package com.homebox.lens.domain.model -class TokenResponse { -} \ No newline at end of file +/** + * [ENTITY: DataClass('TokenResponse')] + * [CONTRACT] + * Модель данных, представляющая ответ от сервера с токеном аутентификации. + * @property token Строка, содержащая JWT или другой токен доступа. + * @invariant `token` не должен быть пустым. + */ +data class TokenResponse(val token: String) { + init { + // [INVARIANT_CHECK] + require(token.isNotBlank()) { "[INVARIANT_FAILED] Token cannot be blank." } + } +} +// [END_FILE_TokenResponse.kt] \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/repository/AuthRepository.kt b/domain/src/main/java/com/homebox/lens/domain/repository/AuthRepository.kt index 116e8e0..55c9b90 100644 --- a/domain/src/main/java/com/homebox/lens/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/homebox/lens/domain/repository/AuthRepository.kt @@ -1,27 +1,42 @@ // [PACKAGE] com.homebox.lens.domain.repository // [FILE] AuthRepository.kt +// [SEMANTICS] authentication, data_access, repository package com.homebox.lens.domain.repository +// [IMPORTS] +import com.homebox.lens.domain.model.Credentials +import com.homebox.lens.domain.model.TokenResponse import kotlinx.coroutines.flow.Flow /** * [CONTRACT] - * Repository for managing authentication tokens. + * Репозиторий для управления аутентификацией. + * [COHERENCE_NOTE] Добавлен метод `login` для инкапсуляции логики входа. */ interface AuthRepository { /** * [CONTRACT] - * Saves the authentication token. - * @param token The token to save. + * Выполняет вход в систему, используя предоставленные учетные данные. + * @param credentials Учетные данные пользователя (URL сервера, логин, пароль). + * @return [Result] с [TokenResponse] в случае успеха, или с [Exception] в случае ошибки. + * @throws IllegalArgumentException если `credentials` невалидны (предусловие). + */ + suspend fun login(credentials: Credentials): Result + + /** + * [CONTRACT] + * Сохраняет токен аутентификации. + * @param token Токен для сохранения. + * @throws IllegalArgumentException если `token` пустой (предусловие). */ suspend fun saveToken(token: String) /** * [CONTRACT] - * Retrieves the authentication token. - * @return A Flow emitting the token, or null if not found. + * Получает токен аутентификации. + * @return [Flow], который эммитит токен в виде строки, или `null`, если токен отсутствует. */ fun getToken(): Flow } -// [END_FILE_AuthRepository.kt] +// [END_FILE_AuthRepository.kt] \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/repository/CredentialsRepository.kt b/domain/src/main/java/com/homebox/lens/domain/repository/CredentialsRepository.kt index 43913f3..20bcd4d 100644 --- a/domain/src/main/java/com/homebox/lens/domain/repository/CredentialsRepository.kt +++ b/domain/src/main/java/com/homebox/lens/domain/repository/CredentialsRepository.kt @@ -8,13 +8,14 @@ import kotlinx.coroutines.flow.Flow /** * [CONTRACT] - * Repository for managing user credentials. + * Repository for managing user credentials and session tokens. */ interface CredentialsRepository { /** * [CONTRACT] - * Saves the user credentials securely. + * Saves the user's base credentials (URL, username, password) securely. * @param credentials The credentials to save. + * @sideeffect Overwrites any existing saved credentials. */ suspend fun saveCredentials(credentials: Credentials) @@ -24,5 +25,20 @@ interface CredentialsRepository { * @return A Flow emitting the saved [Credentials], or null if none are saved. */ fun getCredentials(): Flow + + /** + * [CONTRACT] + * [ACTION] Saves the authorization token received after a successful login. + * @param token The authorization token (including "Bearer " prefix if provided by the server). + * @sideeffect Overwrites any existing saved token. + */ + suspend fun saveToken(token: String) + + /** + * [CONTRACT] + * [ACTION] Retrieves the saved authorization token. + * @return The saved token as a String, or null if no token is saved. + */ + suspend fun getToken(): String? } -// [END_FILE_CredentialsRepository.kt] +// [END_FILE_CredentialsRepository.kt] \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt b/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt index 4389f10..e05e915 100644 --- a/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt +++ b/domain/src/main/java/com/homebox/lens/domain/repository/ItemRepository.kt @@ -1,7 +1,9 @@ // [PACKAGE] com.homebox.lens.domain.repository // [FILE] ItemRepository.kt // [SEMANTICS] data_access, abstraction, repository + package com.homebox.lens.domain.repository + // [IMPORTS] import com.homebox.lens.domain.model.* @@ -10,9 +12,10 @@ import com.homebox.lens.domain.model.* * [CONTRACT] * Абстракция репозитория для работы с "Вещами". * Определяет контракт, которому должен следовать слой данных. + * [COHERENCE_NOTE] Метод `login` был удален, так как он относится к аутентификации и перенесен в `AuthRepository`. */ interface ItemRepository { - suspend fun login(credentials: Credentials): Result + // [DELETED] suspend fun login(credentials: Credentials): Result suspend fun createItem(newItemData: ItemCreate): ItemSummary suspend fun getItemDetails(itemId: String): ItemOut suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut @@ -23,4 +26,4 @@ interface ItemRepository { suspend fun getAllLabels(): List suspend fun searchItems(query: String): PaginationResult } -// [END_FILE_ItemRepository.kt] +// [END_FILE_ItemRepository.kt] \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLabelsUseCase.kt b/domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLabelsUseCase.kt index 0194132..ebd6ed4 100644 --- a/domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLabelsUseCase.kt +++ b/domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLabelsUseCase.kt @@ -1,25 +1,21 @@ // [PACKAGE] com.homebox.lens.domain.usecase -// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLabelsUseCase.kt -// [SEMANTICS] domain, usecase, label, list +// [FILE] GetAllLabelsUseCase.kt -// [IMPORTS] package com.homebox.lens.domain.usecase import com.homebox.lens.domain.model.LabelOut import com.homebox.lens.domain.repository.ItemRepository import javax.inject.Inject -// [CORE-LOGIC] -class GetAllLabelsUseCase @Inject constructor( - private val itemRepository: ItemRepository -) { - suspend operator fun invoke(): List? { - return try { - itemRepository.getAllLabels() - } catch (e: Exception) { - // [ERROR_HANDLER] Просто возвращаем null. - null - } +class GetAllLabelsUseCase @Inject constructor(private val repository: ItemRepository) { + /** + * [CONTRACT] + * Получает список всех меток. + * @return Список [LabelOut]. + * @throws Exception в случае ошибки сети или API. + */ + suspend operator fun invoke(): List { + // [FIX] Упрощено. + return repository.getAllLabels() } -} -// [END_FILE_GetAllLabelsUseCase.kt] \ No newline at end of file +} \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLocationsUseCase.kt b/domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLocationsUseCase.kt index 7d4a198..3feb975 100644 --- a/domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLocationsUseCase.kt +++ b/domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLocationsUseCase.kt @@ -1,25 +1,21 @@ // [PACKAGE] com.homebox.lens.domain.usecase -// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetAllLocationsUseCase.kt -// [SEMANTICS] domain, usecase, location, list +// [FILE] GetAllLocationsUseCase.kt -// [IMPORTS] package com.homebox.lens.domain.usecase import com.homebox.lens.domain.model.LocationOutCount import com.homebox.lens.domain.repository.ItemRepository import javax.inject.Inject -// [CORE-LOGIC] -class GetAllLocationsUseCase @Inject constructor( - private val itemRepository: ItemRepository -) { - suspend operator fun invoke(): List? { - return try { - itemRepository.getAllLocations() - } catch (e: Exception) { - // [ERROR_HANDLER] Просто возвращаем null. - null - } +class GetAllLocationsUseCase @Inject constructor(private val repository: ItemRepository) { + /** + * [CONTRACT] + * Получает список всех локаций. + * @return Список [LocationOutCount]. + * @throws Exception в случае ошибки сети или API. + */ + suspend operator fun invoke(): List { + // [FIX] Упрощено. + return repository.getAllLocations() } -} -// [END_FILE_GetAllLocationsUseCase.kt] \ No newline at end of file +} \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/usecase/GetStatisticsUseCase.kt b/domain/src/main/java/com/homebox/lens/domain/usecase/GetStatisticsUseCase.kt index b38d8af..631b34d 100644 --- a/domain/src/main/java/com/homebox/lens/domain/usecase/GetStatisticsUseCase.kt +++ b/domain/src/main/java/com/homebox/lens/domain/usecase/GetStatisticsUseCase.kt @@ -1,25 +1,22 @@ // [PACKAGE] com.homebox.lens.domain.usecase -// [FILE] domain/src/main/java/com/homebox/lens/domain/usecase/GetStatisticsUseCase.kt -// [SEMANTICS] domain, usecase, statistics +// [FILE] GetStatisticsUseCase.kt -// [IMPORTS] package com.homebox.lens.domain.usecase import com.homebox.lens.domain.model.GroupStatistics import com.homebox.lens.domain.repository.ItemRepository import javax.inject.Inject -// [CORE-LOGIC] -class GetStatisticsUseCase @Inject constructor( - private val itemRepository: ItemRepository -) { - suspend operator fun invoke(): GroupStatistics? { - return try { - itemRepository.getStatistics() - } catch (e: Exception) { - // [ERROR_HANDLER] Просто возвращаем null, вызывающий слой обработает это. - null - } +class GetStatisticsUseCase @Inject constructor(private val repository: ItemRepository) { + /** + * [CONTRACT] + * Получает статистику инвентаря. + * @return [GroupStatistics] объект. + * @throws Exception в случае ошибки сети или API. + */ + suspend operator fun invoke(): GroupStatistics { + // [FIX] Упрощено. Просто вызываем репозиторий и возвращаем его результат. + // Обработка ошибок делегирована вызывающей стороне (ViewModel). + return repository.getStatistics() } -} -// [END_FILE_GetStatisticsUseCase.kt] \ No newline at end of file +} \ No newline at end of file diff --git a/domain/src/main/java/com/homebox/lens/domain/usecase/LoginUseCase.kt b/domain/src/main/java/com/homebox/lens/domain/usecase/LoginUseCase.kt index 2e2d05e..78f6663 100644 --- a/domain/src/main/java/com/homebox/lens/domain/usecase/LoginUseCase.kt +++ b/domain/src/main/java/com/homebox/lens/domain/usecase/LoginUseCase.kt @@ -1,29 +1,52 @@ // [PACKAGE] com.homebox.lens.domain.usecase // [FILE] LoginUseCase.kt - +// [PURPOSE] Инкапсулирует бизнес-логику процесса входа пользователя в систему. package com.homebox.lens.domain.usecase - +// [IMPORTS] import com.homebox.lens.domain.model.Credentials -import com.homebox.lens.domain.repository.ItemRepository +import com.homebox.lens.domain.model.TokenResponse +import com.homebox.lens.domain.repository.AuthRepository import javax.inject.Inject -import com.homebox.lens.domain.model.Result /** + * [ENTITY: Class('LoginUseCase')] * [CONTRACT] - * Use case for user login. - * @param itemRepository The repository to handle item and auth operations. + * Use case для выполнения входа пользователя. + * @param authRepository Репозиторий для выполнения сетевого запроса на вход и сохранения токена. + * [COHERENCE_NOTE] Удалена зависимость от CredentialsRepository для сохранения токена. + * Эту ответственность теперь несет AuthRepository. */ class LoginUseCase @Inject constructor( - private val itemRepository: ItemRepository + private val authRepository: AuthRepository ) { /** * [CONTRACT] - * Executes the login process. - * @param credentials The user's credentials. - * @return A [Result] object indicating success or failure. + * Выполняет процесс входа в систему. + * @param credentials Учетные данные пользователя. + * @return [Result] с [Unit] в случае успеха или с [Exception] в случае ошибки. + * @sideeffect В случае успеха, сохраняет токен авторизации через `authRepository`. */ suspend operator fun invoke(credentials: Credentials): Result { - return itemRepository.login(credentials) + // [PRECONDITION] + require(credentials.serverUrl.isNotBlank() && credentials.username.isNotBlank()) { + "[PRECONDITION_FAILED] Server URL and username must not be blank." + } + + // [ACTION] Выполняем вход через authRepository. + val loginResult: Result = authRepository.login(credentials) + + // [CORE-LOGIC] Обрабатываем результат с помощью `fold`. + return loginResult.fold( + onSuccess = { tokenResponse -> + // [ACTION] В случае успеха, сохраняем токен через тот же репозиторий. + authRepository.saveToken(tokenResponse.token) + Result.success(Unit) + }, + onFailure = { exception -> + // [ACTION] В случае ошибки, просто пробрасываем ее дальше. + Result.failure(exception) + } + ) } } -// [END_FILE_LoginUseCase.kt] +// [END_FILE_LoginUseCase.kt] \ No newline at end of file