Login to dashboard worked

This commit is contained in:
2025-08-09 10:36:45 +03:00
parent d9fc689185
commit 07a8d82a4d
28 changed files with 796 additions and 251 deletions

View File

@@ -82,9 +82,6 @@ dependencies {
// [DEPENDENCY] Logging
implementation(Libs.timber)
// [DEPENDENCY] Security
implementation(Libs.securityCrypto)
// [DEPENDENCY] Testing
testImplementation(Libs.junit)
androidTestImplementation(Libs.extJunit)

View File

@@ -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,20 +34,35 @@ class DashboardViewModel @Inject constructor(
private val getAllLabelsUseCase: GetAllLabelsUseCase
) : ViewModel() {
// [STATE]
private val _uiState = MutableStateFlow<DashboardUiState>(DashboardUiState.Loading)
val uiState: StateFlow<DashboardUiState> = _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 {
// Параллельно запрашиваем все данные
_uiState.value = DashboardUiState.Loading
// [FIX] Используем Timber для логирования.
Timber.i("[ACTION] Starting parallel dashboard data load. State -> Loading.")
// [CORE-LOGIC: PARALLEL_FETCH]
val result = runCatching {
coroutineScope {
val statsDeferred = async { getStatisticsUseCase() }
val locationsDeferred = async { getAllLocationsUseCase() }
val labelsDeferred = async { getAllLabelsUseCase() }
@@ -49,29 +71,35 @@ class DashboardViewModel @Inject constructor(
val locations = locationsDeferred.await()
val labels = labelsDeferred.await()
// [ACTION] Логируем результат здесь, во ViewModel
if (stats != null && locations != null && labels != null) {
// [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.")
}
} 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")
},
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."
)
}
)
}
}
// [END_CLASS_DashboardViewModel]
}
// [END_FILE_DashboardViewModel.kt]

View File

@@ -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() {

View File

@@ -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 = "",

View File

@@ -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]

View File

@@ -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]

View File

@@ -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

View File

@@ -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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -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 {
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]

View File

@@ -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

View File

@@ -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<T> для предотвращения циклов зависимостей.
* @return Синглтон-экземпляр OkHttpClient с настроенными перехватчиками.
*/
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
// [ACTION] Create logging interceptor
val logging = HttpLoggingInterceptor().apply {
fun provideOkHttpClient(
credentialsRepositoryProvider: Provider<CredentialsRepository>
): 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>"
// Предполагается, что `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]

View File

@@ -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]

View File

@@ -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]

View File

@@ -1,27 +1,85 @@
// [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<TokenResponse> {
// [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) {
require(token.isNotBlank()) { "[PRECONDITION_FAILED] Token cannot be blank." }
withContext(Dispatchers.IO) {
encryptedPrefs.edit().putString(KEY_AUTH_TOKEN, token).apply()
}
}
override fun getToken(): Flow<String?> = flow {
emit(encryptedPrefs.getString(KEY_AUTH_TOKEN, null))

View File

@@ -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) {
// [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<Credentials?> = 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]

View File

@@ -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 {
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]

View File

@@ -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<Unit> {
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<ItemSummary> {
// [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<LocationOutCount> {
// [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<LabelOut> {
// [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<ItemSummary> {
// [ACTION]
val resultDto = apiService.getItems(query = query)
return resultDto.toDomain { it.toDomain() }
}

View File

@@ -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 {
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]

View File

@@ -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 {
/**
* [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]

View File

@@ -1,26 +1,41 @@
// [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<TokenResponse>
/**
* [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<String?>
}

View File

@@ -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<Credentials?>
/**
* [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]

View File

@@ -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<Unit>
// [DELETED] suspend fun login(credentials: Credentials): Result<Unit>
suspend fun createItem(newItemData: ItemCreate): ItemSummary
suspend fun getItemDetails(itemId: String): ItemOut
suspend fun updateItem(itemId: String, item: ItemUpdate): ItemOut

View File

@@ -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<LabelOut>? {
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<LabelOut> {
// [FIX] Упрощено.
return repository.getAllLabels()
}
}
// [END_FILE_GetAllLabelsUseCase.kt]

View File

@@ -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<LocationOutCount>? {
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<LocationOutCount> {
// [FIX] Упрощено.
return repository.getAllLocations()
}
}
// [END_FILE_GetAllLocationsUseCase.kt]

View File

@@ -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]

View File

@@ -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<Unit> {
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<TokenResponse> = 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]