Login to dashboard worked
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user