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

@@ -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]
// [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]
// [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]
// [END_FILE_StorageModule.kt]

View File

@@ -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<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) {
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<String?> = flow {
emit(encryptedPrefs.getString(KEY_AUTH_TOKEN, null))
}.flowOn(Dispatchers.IO)
}
// [END_FILE_AuthRepositoryImpl.kt]
// [END_FILE_AuthRepositoryImpl.kt]

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