From 0ff838baf7ddd3dce8533d41fe99292e9341c0be Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 8 Mar 2026 22:21:24 +0300 Subject: [PATCH] android: add auth network core, token store, and DI wiring --- android/CHANGELOG.md | 7 + android/app/build.gradle.kts | 78 +++++------ .../core/network/AuthHeaderInterceptor.kt | 37 ++++++ .../core/network/TokenRefreshAuthenticator.kt | 79 +++++++++++ .../core/token/DataStoreTokenRepository.kt | 64 +++++++++ .../messenger/data/auth/api/AuthApiService.kt | 23 ++++ .../messenger/data/auth/dto/AuthDtos.kt | 43 ++++++ .../auth/repository/NetworkAuthRepository.kt | 125 ++++++++++++++++++ .../daemonlord/messenger/di/NetworkModule.kt | 111 ++++++++++++++++ .../ru/daemonlord/messenger/di/Qualifiers.kt | 7 + .../messenger/di/RepositoryModule.kt | 20 +++ .../daemonlord/messenger/di/StorageModule.kt | 36 +++++ .../messenger/domain/auth/model/AuthUser.kt | 10 ++ .../domain/auth/repository/AuthRepository.kt | 12 ++ .../domain/auth/usecase/LoginUseCase.kt | 14 ++ .../auth/usecase/RestoreSessionUseCase.kt | 14 ++ .../messenger/domain/common/AppError.kt | 9 ++ .../messenger/domain/common/AppResult.kt | 6 + android/build.gradle.kts | 11 +- 19 files changed, 663 insertions(+), 43 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/network/AuthHeaderInterceptor.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/network/TokenRefreshAuthenticator.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/di/Qualifiers.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/di/StorageModule.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthUser.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LoginUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RestoreSessionUseCase.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/common/AppError.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/common/AppResult.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 8c74605..c369e66 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -7,3 +7,10 @@ - Added app dependencies for Retrofit/OkHttp, DataStore, coroutines, Hilt, and unit testing. - Enabled INTERNET permission and registered MessengerApplication in manifest. - Added MessengerApplication with HiltAndroidApp. + +### Step 2 - Network/data core + DI +- Fixed DTO/Auth API serialization annotations and endpoint declarations for `/api/v1/auth/login`, `/api/v1/auth/refresh`, `/api/v1/auth/me`. +- Implemented DataStore-based token persistence with a corrected `getTokens()` read path. +- Added auth network stack: bearer interceptor, 401 authenticator with refresh flow and retry guard. +- Added clean-layer contracts and implementations: `domain/common`, `domain/auth`, `data/auth/repository`. +- Wired dependencies with Hilt modules for DataStore, OkHttp/Retrofit, and repository bindings. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d1edd19..6a9bcfd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/network/AuthHeaderInterceptor.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/network/AuthHeaderInterceptor.kt new file mode 100644 index 0000000..e08700f --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/network/AuthHeaderInterceptor.kt @@ -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" + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/network/TokenRefreshAuthenticator.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/network/TokenRefreshAuthenticator.kt new file mode 100644 index 0000000..e245324 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/network/TokenRefreshAuthenticator.kt @@ -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" + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepository.kt new file mode 100644 index 0000000..eef9bd7 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepository.kt @@ -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, +) : TokenRepository { + + override fun observeTokens(): Flow = 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") + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt new file mode 100644 index 0000000..2c54fbe --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt @@ -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 +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt new file mode 100644 index 0000000..804b393 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt @@ -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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt new file mode 100644 index 0000000..e19ad2b --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt @@ -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 = 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 = 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 = 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 = 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) + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt new file mode 100644 index 0000000..bb23286 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt @@ -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) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/Qualifiers.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/Qualifiers.kt new file mode 100644 index 0000000..80c848a --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/Qualifiers.kt @@ -0,0 +1,7 @@ +package ru.daemonlord.messenger.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class RefreshClient diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt new file mode 100644 index 0000000..ce33978 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -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 +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/StorageModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/StorageModule.kt new file mode 100644 index 0000000..3166247 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/StorageModule.kt @@ -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 { + return PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("messenger_tokens.preferences_pb") } + ) + } + + @Provides + @Singleton + fun provideTokenRepository( + repository: DataStoreTokenRepository, + ): TokenRepository = repository +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthUser.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthUser.kt new file mode 100644 index 0000000..7cb49b7 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/model/AuthUser.kt @@ -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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt new file mode 100644 index 0000000..715456e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/AuthRepository.kt @@ -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 + suspend fun refreshTokens(): AppResult + suspend fun getMe(): AppResult + suspend fun restoreSession(): AppResult + suspend fun logout() +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LoginUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LoginUseCase.kt new file mode 100644 index 0000000..ee00025 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LoginUseCase.kt @@ -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 { + return authRepository.login(email = email, password = password) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RestoreSessionUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RestoreSessionUseCase.kt new file mode 100644 index 0000000..956f5e2 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/RestoreSessionUseCase.kt @@ -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 { + return authRepository.restoreSession() + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/common/AppError.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/common/AppError.kt new file mode 100644 index 0000000..30988ac --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/common/AppError.kt @@ -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 +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/common/AppResult.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/common/AppResult.kt new file mode 100644 index 0000000..365beed --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/common/AppResult.kt @@ -0,0 +1,6 @@ +package ru.daemonlord.messenger.domain.common + +sealed interface AppResult { + data class Success(val data: T) : AppResult + data class Error(val reason: AppError) : AppResult +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 4f39563..f8870a7 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -1,7 +1,8 @@ plugins { - id(com.android.application) version 8.7.2 apply false - id(org.jetbrains.kotlin.android) version 2.0.21 apply false - id(com.google.dagger.hilt.android) version 2.52 apply false - id(org.jetbrains.kotlin.plugin.serialization) version 2.0.21 apply false - id(org.jetbrains.kotlin.kapt) version 2.0.21 apply false + id("com.android.application") version "8.7.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false + id("com.google.dagger.hilt.android") version "2.52" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false + id("org.jetbrains.kotlin.kapt") version "2.0.21" apply false }