android: add auth network core, token store, and DI wiring

This commit is contained in:
Codex
2026-03-08 22:21:24 +03:00
parent acdb83e04e
commit 0ff838baf7
19 changed files with 663 additions and 43 deletions

View File

@@ -1,23 +1,25 @@
plugins {
id(com.android.application)
id(org.jetbrains.kotlin.android)
id(org.jetbrains.kotlin.kapt)
id(org.jetbrains.kotlin.plugin.serialization)
id(com.google.dagger.hilt.android)
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.kapt")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.dagger.hilt.android")
}
android {
namespace = ru.daemonlord.messenger
namespace = "ru.daemonlord.messenger"
compileSdk = 35
defaultConfig {
applicationId = ru.daemonlord.messenger
applicationId = "ru.daemonlord.messenger"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = 0.1.0
versionName = "0.1.0"
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:8000/\"")
testInstrumentationRunner = androidx.test.runner.AndroidJUnitRunner
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
@@ -27,8 +29,8 @@ android {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile(proguard-android-optimize.txt),
proguard-rules.pro
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
@@ -39,7 +41,7 @@ android {
}
kotlinOptions {
jvmTarget = 17
jvmTarget = "17"
}
buildFeatures {
@@ -48,48 +50,48 @@ android {
}
composeOptions {
kotlinCompilerExtensionVersion = 1.5.15
kotlinCompilerExtensionVersion = "1.5.15"
}
packaging {
resources {
excludes += /META-INF/AL2.0
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(androidx.core:core-ktx:1.15.0)
implementation(androidx.lifecycle:lifecycle-runtime-ktx:2.8.7)
implementation(androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7)
implementation(androidx.activity:activity-compose:1.10.1)
implementation(androidx.navigation:navigation-compose:2.8.5)
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation(androidx.compose.ui:ui:1.7.6)
implementation(androidx.compose.ui:ui-tooling-preview:1.7.6)
implementation(androidx.compose.material3:material3:1.3.1)
implementation("androidx.compose.ui:ui:1.7.6")
implementation("androidx.compose.ui:ui-tooling-preview:1.7.6")
implementation("androidx.compose.material3:material3:1.3.1")
implementation(org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0)
implementation(org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3)
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation(com.squareup.retrofit2:retrofit:2.11.0)
implementation(com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0)
implementation(com.squareup.okhttp3:okhttp:4.12.0)
implementation(com.squareup.okhttp3:logging-interceptor:4.12.0)
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
implementation(androidx.datastore:datastore-preferences:1.1.1)
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation(com.google.dagger:hilt-android:2.52)
kapt(com.google.dagger:hilt-compiler:2.52)
implementation(androidx.hilt:hilt-navigation-compose:1.2.0)
implementation("com.google.dagger:hilt-android:2.52")
kapt("com.google.dagger:hilt-compiler:2.52")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
testImplementation(junit:junit:4.13.2)
testImplementation(org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0)
testImplementation(io.mockk:mockk:1.13.13)
testImplementation(com.squareup.okhttp3:mockwebserver:4.12.0)
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("androidx.datastore:datastore-preferences-core:1.1.1")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
debugImplementation(androidx.compose.ui:ui-tooling:1.7.6)
debugImplementation(androidx.compose.ui:ui-test-manifest:1.7.6)
debugImplementation("androidx.compose.ui:ui-tooling:1.7.6")
debugImplementation("androidx.compose.ui:ui-test-manifest:1.7.6")
}
kapt {

View File

@@ -0,0 +1,37 @@
package ru.daemonlord.messenger.core.network
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.Response
import ru.daemonlord.messenger.core.token.TokenRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthHeaderInterceptor @Inject constructor(
private val tokenRepository: TokenRepository,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val noAuthHeader = originalRequest.header(NO_AUTH_HEADER)
if (noAuthHeader == "true") {
val requestWithoutMarker = originalRequest.newBuilder()
.removeHeader(NO_AUTH_HEADER)
.build()
return chain.proceed(requestWithoutMarker)
}
val accessToken = runBlocking { tokenRepository.getTokens()?.accessToken }
val requestBuilder = originalRequest.newBuilder()
if (!accessToken.isNullOrBlank()) {
requestBuilder.header("Authorization", "Bearer $accessToken")
}
return chain.proceed(requestBuilder.build())
}
private companion object {
const val NO_AUTH_HEADER = "No-Auth"
}
}

View File

@@ -0,0 +1,79 @@
package ru.daemonlord.messenger.core.network
import kotlinx.coroutines.runBlocking
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TokenRefreshAuthenticator @Inject constructor(
private val tokenRepository: TokenRepository,
private val refreshAuthApiService: AuthApiService,
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= MAX_RETRIES) {
return null
}
val marker = response.request.header(NO_AUTH_HEADER)
if (marker == "true") {
return null
}
val refreshedAccessToken = synchronized(this) {
runBlocking {
val currentTokens = tokenRepository.getTokens() ?: return@runBlocking null
tryRefreshTokens(currentTokens)
}
} ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $refreshedAccessToken")
.build()
}
private suspend fun tryRefreshTokens(tokens: TokenBundle): String? {
return try {
val refreshed = refreshAuthApiService.refresh(
request = RefreshTokenRequestDto(refreshToken = tokens.refreshToken)
)
tokenRepository.saveTokens(
TokenBundle(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
refreshed.accessToken
} catch (_: IOException) {
null
} catch (_: Exception) {
tokenRepository.clearTokens()
null
}
}
private fun responseCount(response: Response): Int {
var current: Response? = response
var count = 1
while (current?.priorResponse != null) {
count++
current = current.priorResponse
}
return count
}
private companion object {
const val MAX_RETRIES = 2
const val NO_AUTH_HEADER = "No-Auth"
}
}

View File

@@ -0,0 +1,64 @@
package ru.daemonlord.messenger.core.token
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DataStoreTokenRepository @Inject constructor(
private val dataStore: DataStore<Preferences>,
) : TokenRepository {
override fun observeTokens(): Flow<TokenBundle?> = dataStore.data.map { preferences ->
preferences.toTokenBundleOrNull()
}
override suspend fun getTokens(): TokenBundle? {
return observeTokens().first()
}
override suspend fun saveTokens(tokens: TokenBundle) {
dataStore.edit { preferences ->
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken
preferences[REFRESH_TOKEN_KEY] = tokens.refreshToken
preferences[SAVED_AT_KEY] = tokens.savedAtMillis
}
}
override suspend fun clearTokens() {
dataStore.edit { preferences ->
preferences.remove(ACCESS_TOKEN_KEY)
preferences.remove(REFRESH_TOKEN_KEY)
preferences.remove(SAVED_AT_KEY)
}
}
private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
val access = this[ACCESS_TOKEN_KEY]
val refresh = this[REFRESH_TOKEN_KEY]
val savedAt = this[SAVED_AT_KEY]
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt == null) {
return null
}
return TokenBundle(
accessToken = access,
refreshToken = refresh,
savedAtMillis = savedAt,
)
}
private companion object {
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")
val SAVED_AT_KEY = longPreferencesKey("tokens_saved_at")
}
}

View File

@@ -0,0 +1,23 @@
package ru.daemonlord.messenger.data.auth.api
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.data.auth.dto.TokenResponseDto
import retrofit2.http.Headers
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
interface AuthApiService {
@Headers("No-Auth: true")
@POST("/api/v1/auth/login")
suspend fun login(@Body request: LoginRequestDto): TokenResponseDto
@Headers("No-Auth: true")
@POST("/api/v1/auth/refresh")
suspend fun refresh(@Body request: RefreshTokenRequestDto): TokenResponseDto
@GET("/api/v1/auth/me")
suspend fun me(): AuthUserDto
}

View File

@@ -0,0 +1,43 @@
package ru.daemonlord.messenger.data.auth.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequestDto(
val email: String,
val password: String,
)
@Serializable
data class RefreshTokenRequestDto(
@SerialName("refresh_token")
val refreshToken: String,
)
@Serializable
data class TokenResponseDto(
@SerialName("access_token")
val accessToken: String,
@SerialName("refresh_token")
val refreshToken: String,
@SerialName("token_type")
val tokenType: String,
)
@Serializable
data class AuthUserDto(
val id: Long,
val email: String,
val name: String,
val username: String,
@SerialName("avatar_url")
val avatarUrl: String? = null,
@SerialName("email_verified")
val emailVerified: Boolean,
)
@Serializable
data class ErrorResponseDto(
val detail: String? = null,
)

View File

@@ -0,0 +1,125 @@
package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.HttpException
import ru.daemonlord.messenger.core.token.TokenBundle
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkAuthRepository @Inject constructor(
private val authApiService: AuthApiService,
private val tokenRepository: TokenRepository,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : AuthRepository {
override suspend fun login(email: String, password: String): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val tokenResponse = authApiService.login(
request = LoginRequestDto(
email = email,
password = password,
)
)
tokenRepository.saveTokens(
TokenBundle(
accessToken = tokenResponse.accessToken,
refreshToken = tokenResponse.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
getMe()
} catch (error: Throwable) {
AppResult.Error(error.toAppError(forLogin = true))
}
}
override suspend fun refreshTokens(): AppResult<Unit> = withContext(ioDispatcher) {
val tokens = tokenRepository.getTokens()
?: return@withContext AppResult.Error(AppError.Unauthorized)
try {
val refreshed = authApiService.refresh(
request = RefreshTokenRequestDto(refreshToken = tokens.refreshToken)
)
tokenRepository.saveTokens(
TokenBundle(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
savedAtMillis = System.currentTimeMillis(),
)
)
AppResult.Success(Unit)
} catch (error: Throwable) {
tokenRepository.clearTokens()
AppResult.Error(error.toAppError(forLogin = false))
}
}
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try {
val user = authApiService.me().toDomain()
AppResult.Success(user)
} catch (error: Throwable) {
AppResult.Error(error.toAppError(forLogin = false))
}
}
override suspend fun restoreSession(): AppResult<AuthUser> = withContext(ioDispatcher) {
val tokens = tokenRepository.getTokens()
?: return@withContext AppResult.Error(AppError.Unauthorized)
if (tokens.accessToken.isBlank() || tokens.refreshToken.isBlank()) {
tokenRepository.clearTokens()
return@withContext AppResult.Error(AppError.Unauthorized)
}
when (val meResult = getMe()) {
is AppResult.Success -> meResult
is AppResult.Error -> {
if (meResult.reason is AppError.Unauthorized) {
tokenRepository.clearTokens()
}
meResult
}
}
}
override suspend fun logout() {
tokenRepository.clearTokens()
}
private fun AuthUserDto.toDomain(): AuthUser {
return AuthUser(
id = id,
email = email,
name = name,
username = username,
avatarUrl = avatarUrl,
emailVerified = emailVerified,
)
}
private fun Throwable.toAppError(forLogin: Boolean): AppError {
return when (this) {
is IOException -> AppError.Network
is HttpException -> when (code()) {
400 -> if (forLogin) AppError.InvalidCredentials else AppError.Server(message = message())
401, 403 -> if (forLogin) AppError.InvalidCredentials else AppError.Unauthorized
else -> AppError.Server(message = message())
}
else -> AppError.Unknown(cause = this)
}
}
}

View File

@@ -0,0 +1,111 @@
package ru.daemonlord.messenger.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.network.AuthHeaderInterceptor
import ru.daemonlord.messenger.core.network.TokenRefreshAuthenticator
import ru.daemonlord.messenger.data.auth.api.AuthApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json {
return Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}
}
@Provides
@Singleton
fun provideHttpLoggingInterceptor(): HttpLoggingInterceptor {
return HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
@Provides
@Singleton
@RefreshClient
fun provideRefreshClient(
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRefreshApiService(
@RefreshClient refreshClient: OkHttpClient,
json: Json,
): AuthApiService {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(json.asConverterFactory(contentType))
.client(refreshClient)
.build()
.create(AuthApiService::class.java)
}
@Provides
@Singleton
fun provideApiClient(
loggingInterceptor: HttpLoggingInterceptor,
authHeaderInterceptor: AuthHeaderInterceptor,
tokenRefreshAuthenticator: TokenRefreshAuthenticator,
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(authHeaderInterceptor)
.authenticator(tokenRefreshAuthenticator)
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(
client: OkHttpClient,
json: Json,
): Retrofit {
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.addConverterFactory(json.asConverterFactory(contentType))
.client(client)
.build()
}
@Provides
@Singleton
fun provideAuthApiService(retrofit: Retrofit): AuthApiService {
return retrofit.create(AuthApiService::class.java)
}
}

View File

@@ -0,0 +1,7 @@
package ru.daemonlord.messenger.di
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RefreshClient

View File

@@ -0,0 +1,20 @@
package ru.daemonlord.messenger.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(
repository: NetworkAuthRepository,
): AuthRepository
}

View File

@@ -0,0 +1,36 @@
package ru.daemonlord.messenger.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import ru.daemonlord.messenger.core.token.DataStoreTokenRepository
import ru.daemonlord.messenger.core.token.TokenRepository
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object StorageModule {
@Provides
@Singleton
fun providePreferenceDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("messenger_tokens.preferences_pb") }
)
}
@Provides
@Singleton
fun provideTokenRepository(
repository: DataStoreTokenRepository,
): TokenRepository = repository
}

View File

@@ -0,0 +1,10 @@
package ru.daemonlord.messenger.domain.auth.model
data class AuthUser(
val id: Long,
val email: String,
val name: String,
val username: String,
val avatarUrl: String?,
val emailVerified: Boolean,
)

View File

@@ -0,0 +1,12 @@
package ru.daemonlord.messenger.domain.auth.repository
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppResult
interface AuthRepository {
suspend fun login(email: String, password: String): AppResult<AuthUser>
suspend fun refreshTokens(): AppResult<Unit>
suspend fun getMe(): AppResult<AuthUser>
suspend fun restoreSession(): AppResult<AuthUser>
suspend fun logout()
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class LoginUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(email: String, password: String): AppResult<AuthUser> {
return authRepository.login(email = email, password = password)
}
}

View File

@@ -0,0 +1,14 @@
package ru.daemonlord.messenger.domain.auth.usecase
import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppResult
import javax.inject.Inject
class RestoreSessionUseCase @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend operator fun invoke(): AppResult<AuthUser> {
return authRepository.restoreSession()
}
}

View File

@@ -0,0 +1,9 @@
package ru.daemonlord.messenger.domain.common
sealed interface AppError {
data object InvalidCredentials : AppError
data object Unauthorized : AppError
data object Network : AppError
data class Server(val message: String?) : AppError
data class Unknown(val cause: Throwable?) : AppError
}

View File

@@ -0,0 +1,6 @@
package ru.daemonlord.messenger.domain.common
sealed interface AppResult<out T> {
data class Success<T>(val data: T) : AppResult<T>
data class Error(val reason: AppError) : AppResult<Nothing>
}