diff --git a/app/src/main/java/com/threegap/bitnagil/di/core/DataStoreModule.kt b/app/src/main/java/com/threegap/bitnagil/di/core/DataStoreModule.kt index 05e9778b..e6c1b6fb 100644 --- a/app/src/main/java/com/threegap/bitnagil/di/core/DataStoreModule.kt +++ b/app/src/main/java/com/threegap/bitnagil/di/core/DataStoreModule.kt @@ -3,6 +3,7 @@ package com.threegap.bitnagil.di.core import android.content.Context import com.threegap.bitnagil.datastore.auth.crypto.TokenCrypto import com.threegap.bitnagil.datastore.auth.serializer.AuthTokenSerializer +import com.threegap.bitnagil.datastore.auth.serializer.AuthTokenSerializerImpl import com.threegap.bitnagil.datastore.auth.storage.AuthTokenDataStore import com.threegap.bitnagil.datastore.auth.storage.AuthTokenStorageFactory import com.threegap.bitnagil.security.crypto.Crypto @@ -26,6 +27,11 @@ object DataStoreModule { override fun decrypt(bytes: ByteArray): ByteArray = crypto.decrypt(bytes) } + @Provides + @Singleton + fun provideAuthTokenSerializer(tokenCrypto: TokenCrypto): AuthTokenSerializer = + AuthTokenSerializerImpl(tokenCrypto) + @Provides @Singleton fun provideAuthTokenStorage( diff --git a/app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt b/app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt index 6c6d24f3..9e029076 100644 --- a/app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt +++ b/app/src/main/java/com/threegap/bitnagil/di/core/NetworkModule.kt @@ -8,7 +8,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -54,17 +54,25 @@ object NetworkModule { } } + @Provides + @Singleton + fun provideTokenStore(dataStore: AuthTokenDataStore): TokenProvider = + object : TokenProvider { + override suspend fun getAccessToken(): String? = dataStore.tokenFlow.firstOrNull()?.accessToken + } + @Provides @Singleton @Auth - fun provideAuthInterceptor(authInterceptor: AuthInterceptor): Interceptor = authInterceptor + fun provideAuthInterceptor(tokenProvider: TokenProvider): Interceptor = + AuthInterceptor(tokenProvider) @Provides @Singleton @Auth fun provideAuthOkHttpClient( httpLoggingInterceptor: HttpLoggingInterceptor, - authInterceptor: Interceptor, + @Auth authInterceptor: Interceptor, ): OkHttpClient = OkHttpClient.Builder() .addInterceptor(authInterceptor) .addInterceptor(httpLoggingInterceptor) @@ -110,11 +118,4 @@ object NetworkModule { .addConverterFactory(converterFactory) .client(okHttpClient) .build() - - @Provides - @Singleton - fun provideTokenStore(dataStore: AuthTokenDataStore): TokenProvider = - object : TokenProvider { - override suspend fun getToken(): String? = dataStore.tokenFlow.first().accessToken - } } diff --git a/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt b/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt new file mode 100644 index 00000000..be8a381a --- /dev/null +++ b/app/src/main/java/com/threegap/bitnagil/di/data/DataSourceModule.kt @@ -0,0 +1,24 @@ +package com.threegap.bitnagil.di.data + +import com.threegap.bitnagil.data.auth.datasource.AuthLocalDataSource +import com.threegap.bitnagil.data.auth.datasource.AuthRemoteDataSource +import com.threegap.bitnagil.data.auth.datasourceimpl.AuthLocalDataSourceImpl +import com.threegap.bitnagil.data.auth.datasourceimpl.AuthRemoteDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataSourceModule { + + @Binds + @Singleton + abstract fun bindAuthDataSource(authDataSourceImpl: AuthRemoteDataSourceImpl): AuthRemoteDataSource + + @Binds + @Singleton + abstract fun bindAuthLocalDataSource(authLocalDataSourceImpl: AuthLocalDataSourceImpl): AuthLocalDataSource +} diff --git a/app/src/main/java/com/threegap/bitnagil/di/data/RepositoryModule.kt b/app/src/main/java/com/threegap/bitnagil/di/data/RepositoryModule.kt new file mode 100644 index 00000000..be0710d7 --- /dev/null +++ b/app/src/main/java/com/threegap/bitnagil/di/data/RepositoryModule.kt @@ -0,0 +1,18 @@ +package com.threegap.bitnagil.di.data + +import com.threegap.bitnagil.data.auth.repositoryimpl.AuthRepositoryImpl +import com.threegap.bitnagil.domain.auth.repository.AuthRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun bindAuthRepository(authRepositoryImpl: AuthRepositoryImpl): AuthRepository +} diff --git a/app/src/main/java/com/threegap/bitnagil/di/data/ServiceModule.kt b/app/src/main/java/com/threegap/bitnagil/di/data/ServiceModule.kt new file mode 100644 index 00000000..cc04ef84 --- /dev/null +++ b/app/src/main/java/com/threegap/bitnagil/di/data/ServiceModule.kt @@ -0,0 +1,20 @@ +package com.threegap.bitnagil.di.data + +import com.threegap.bitnagil.data.auth.service.AuthService +import com.threegap.bitnagil.di.core.Auth +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ServiceModule { + + @Provides + @Singleton + fun provideAuthService(@Auth retrofit: Retrofit): AuthService = + retrofit.create(AuthService::class.java) +} diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 11bec74c..615ae559 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -8,7 +8,7 @@ android { } dependencies { - implementation(libs.androidx.datastore.preferences) + api(libs.androidx.datastore.preferences) implementation(libs.kotlinx.serialization.json) testImplementation(libs.androidx.junit) diff --git a/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/serializer/AuthTokenSerializerImpl.kt b/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/serializer/AuthTokenSerializerImpl.kt index 8e830dcf..010aefd2 100644 --- a/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/serializer/AuthTokenSerializerImpl.kt +++ b/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/serializer/AuthTokenSerializerImpl.kt @@ -1,6 +1,5 @@ package com.threegap.bitnagil.datastore.auth.serializer -import androidx.datastore.core.Serializer import com.threegap.bitnagil.datastore.auth.crypto.TokenCrypto import com.threegap.bitnagil.datastore.auth.model.AuthToken import kotlinx.coroutines.Dispatchers @@ -12,7 +11,7 @@ import java.util.Base64 class AuthTokenSerializerImpl( private val crypto: TokenCrypto, -) : Serializer { +) : AuthTokenSerializer { override val defaultValue: AuthToken get() = AuthToken() diff --git a/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStore.kt b/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStore.kt index 1ff36276..6067bcd9 100644 --- a/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStore.kt +++ b/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStore.kt @@ -6,11 +6,11 @@ import kotlinx.coroutines.flow.Flow interface AuthTokenDataStore { val tokenFlow: Flow - suspend fun updateAuthToken(authToken: AuthToken): AuthToken + suspend fun updateAuthToken(accessToken: String, refreshToken: String) - suspend fun updateAccessToken(accessToken: String): AuthToken + suspend fun updateAccessToken(accessToken: String) - suspend fun updateRefreshToken(refreshToken: String): AuthToken + suspend fun updateRefreshToken(refreshToken: String) - suspend fun clearAuthToken(): AuthToken + suspend fun clearAuthToken() } diff --git a/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStoreImpl.kt b/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStoreImpl.kt index da43becd..0d26a905 100644 --- a/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStoreImpl.kt +++ b/core/datastore/src/main/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStoreImpl.kt @@ -1,6 +1,5 @@ package com.threegap.bitnagil.datastore.auth.storage -import android.util.Log import androidx.datastore.core.DataStore import com.threegap.bitnagil.datastore.auth.model.AuthToken import kotlinx.coroutines.flow.Flow @@ -10,53 +9,43 @@ class AuthTokenDataStoreImpl( ) : AuthTokenDataStore { override val tokenFlow: Flow = dataStore.data - override suspend fun updateAuthToken(authToken: AuthToken): AuthToken = - runCatching { - dataStore.updateData { authToken } - }.fold( - onSuccess = { it }, - onFailure = { - Log.e(TAG, "updateAuthToken failed:", it) - throw it - }, - ) + override suspend fun updateAuthToken(accessToken: String, refreshToken: String) { + try { + dataStore.updateData { + AuthToken(accessToken, refreshToken) + } + } catch (e: Exception) { + throw e + } + } - override suspend fun updateAccessToken(accessToken: String): AuthToken = - runCatching { - dataStore.updateData { authToken -> - authToken.copy(accessToken = accessToken) + override suspend fun updateAccessToken(accessToken: String) { + try { + dataStore.updateData { currentToken -> + currentToken.copy(accessToken = accessToken) } - }.fold( - onSuccess = { it }, - onFailure = { - Log.e(TAG, "updateAccessToken failed:", it) - throw it - }, - ) + } catch (e: Exception) { + throw e + } + } - override suspend fun updateRefreshToken(refreshToken: String): AuthToken = - runCatching { - dataStore.updateData { authToken -> - authToken.copy(refreshToken = refreshToken) + override suspend fun updateRefreshToken(refreshToken: String) { + try { + dataStore.updateData { currentToken -> + currentToken.copy(refreshToken = refreshToken) } - }.fold( - onSuccess = { it }, - onFailure = { - Log.e(TAG, "updateRefreshToken failed:", it) - throw it - }, - ) + } catch (e: Exception) { + throw e + } + } - override suspend fun clearAuthToken(): AuthToken = - runCatching { + override suspend fun clearAuthToken() { + try { dataStore.updateData { AuthToken() } - }.fold( - onSuccess = { it }, - onFailure = { - Log.e(TAG, "clearAuthToken failed:", it) - throw it - }, - ) + } catch (e: Exception) { + throw e + } + } companion object { private const val TAG = "AuthTokenDataStore" diff --git a/core/datastore/src/test/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStoreImplTest.kt b/core/datastore/src/test/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStoreImplTest.kt index 4e5fa052..ed70a6a2 100644 --- a/core/datastore/src/test/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStoreImplTest.kt +++ b/core/datastore/src/test/java/com/threegap/bitnagil/datastore/auth/storage/AuthTokenDataStoreImplTest.kt @@ -70,16 +70,19 @@ class AuthTokenDataStoreImplTest { fun `토큰 전체 업데이트가 성공하면 저장된 토큰을 반환해야 한다`() = runTest { // given + val accessToken = "access" + val refreshToken = "refresh" val token = AuthToken( - accessToken = "access", - refreshToken = "refresh", + accessToken = accessToken, + refreshToken = refreshToken, ) // when - val result = authTokenDataStore.updateAuthToken(token) + authTokenDataStore.updateAuthToken(accessToken, refreshToken) // then + val result = authTokenDataStore.tokenFlow.first() assertEquals(token, result) } @@ -88,18 +91,17 @@ class AuthTokenDataStoreImplTest { runTest { // given authTokenDataStore.updateAuthToken( - AuthToken( - accessToken = "oldAccess", - refreshToken = "oldRefresh", - ), + accessToken = "oldAccess", + refreshToken = "oldRefresh", ) // when - val updated = authTokenDataStore.updateAccessToken(accessToken = "newAccess") + authTokenDataStore.updateAccessToken(accessToken = "newAccess") // then - assertEquals("newAccess", updated.accessToken) - assertEquals("oldRefresh", updated.refreshToken) + val result = authTokenDataStore.tokenFlow.first() + assertEquals("newAccess", result.accessToken) + assertEquals("oldRefresh", result.refreshToken) } @Test @@ -107,18 +109,17 @@ class AuthTokenDataStoreImplTest { runTest { // given authTokenDataStore.updateAuthToken( - AuthToken( - accessToken = "oldAccess", - refreshToken = "oldRefresh", - ), + accessToken = "oldAccess", + refreshToken = "oldRefresh", ) // when - val updated = authTokenDataStore.updateRefreshToken(refreshToken = "newRefresh") + authTokenDataStore.updateRefreshToken(refreshToken = "newRefresh") // then - assertEquals("oldAccess", updated.accessToken) - assertEquals("newRefresh", updated.refreshToken) + val result = authTokenDataStore.tokenFlow.first() + assertEquals("oldAccess", result.accessToken) + assertEquals("newRefresh", result.refreshToken) } @Test @@ -126,16 +127,15 @@ class AuthTokenDataStoreImplTest { runTest { // given authTokenDataStore.updateAuthToken( - AuthToken( - accessToken = "someAccess", - refreshToken = "someRefresh", - ), + accessToken = "someAccess", + refreshToken = "someRefresh", ) // when - val cleared = authTokenDataStore.clearAuthToken() + authTokenDataStore.clearAuthToken() // then + val cleared = authTokenDataStore.tokenFlow.first() assertEquals(AuthToken(), cleared) } @@ -150,7 +150,7 @@ class AuthTokenDataStoreImplTest { ) // when - authTokenDataStore.updateAuthToken(token) + authTokenDataStore.updateAuthToken("flowAccess", "flowRefresh") // then val flowValue = authTokenDataStore.tokenFlow.first() @@ -172,7 +172,7 @@ class AuthTokenDataStoreImplTest { val failingDataStore = AuthTokenDataStoreImpl(brokenStore) // when & then - failingDataStore.updateAuthToken(AuthToken("access", "refresh")) + failingDataStore.updateAuthToken("access", "refresh") } @Test(expected = RuntimeException::class) diff --git a/core/network/src/main/java/com/threegap/bitnagil/network/auth/AuthInterceptor.kt b/core/network/src/main/java/com/threegap/bitnagil/network/auth/AuthInterceptor.kt index 3f511800..ea472e37 100644 --- a/core/network/src/main/java/com/threegap/bitnagil/network/auth/AuthInterceptor.kt +++ b/core/network/src/main/java/com/threegap/bitnagil/network/auth/AuthInterceptor.kt @@ -3,6 +3,7 @@ package com.threegap.bitnagil.network.auth import com.threegap.bitnagil.network.token.TokenProvider import kotlinx.coroutines.runBlocking import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response class AuthInterceptor( @@ -10,7 +11,13 @@ class AuthInterceptor( ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() - val token = runBlocking { tokenProvider.getToken() } + val noToken = originalRequest.header(HEADER_NO_SERVICE_TOKEN) + + if (noToken == "true") { + return chain.proceed(removeNoTokenHeader(originalRequest)) + } + + val token = runBlocking { tokenProvider.getAccessToken() } if (token.isNullOrBlank()) { return chain.proceed(originalRequest) } @@ -22,8 +29,14 @@ class AuthInterceptor( return chain.proceed(newRequest) } + private fun removeNoTokenHeader(request: Request): Request = + request.newBuilder() + .removeHeader(HEADER_NO_SERVICE_TOKEN) + .build() + companion object { + private const val HEADER_NO_SERVICE_TOKEN = "No-Service-Token" private const val HEADER_AUTHORIZATION = "Authorization" - private const val TOKEN_PREFIX = "Bearer " + private const val TOKEN_PREFIX = "Bearer" } } diff --git a/core/network/src/main/java/com/threegap/bitnagil/network/model/ErrorResponse.kt b/core/network/src/main/java/com/threegap/bitnagil/network/model/ErrorResponse.kt new file mode 100644 index 00000000..ca707dcc --- /dev/null +++ b/core/network/src/main/java/com/threegap/bitnagil/network/model/ErrorResponse.kt @@ -0,0 +1,12 @@ +package com.threegap.bitnagil.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponse( + @SerialName("code") + val code: String, + @SerialName("message") + val message: String, +) diff --git a/core/network/src/main/java/com/threegap/bitnagil/network/token/TokenProvider.kt b/core/network/src/main/java/com/threegap/bitnagil/network/token/TokenProvider.kt index 442aeae9..355ecc49 100644 --- a/core/network/src/main/java/com/threegap/bitnagil/network/token/TokenProvider.kt +++ b/core/network/src/main/java/com/threegap/bitnagil/network/token/TokenProvider.kt @@ -1,5 +1,5 @@ package com.threegap.bitnagil.network.token interface TokenProvider { - suspend fun getToken(): String? + suspend fun getAccessToken(): String? } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 8e64a81d..7f7e00f7 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -10,7 +10,10 @@ android { dependencies { implementation(projects.core.network) + implementation(projects.core.datastore) + implementation(projects.domain) + implementation(libs.kotlinx.serialization.json) implementation(platform(libs.okhttp.bom)) implementation(libs.bundles.okhttp) implementation(platform(libs.retrofit.bom)) diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/datasource/AuthLocalDataSource.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/datasource/AuthLocalDataSource.kt new file mode 100644 index 00000000..06edb560 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/datasource/AuthLocalDataSource.kt @@ -0,0 +1,5 @@ +package com.threegap.bitnagil.data.auth.datasource + +interface AuthLocalDataSource { + suspend fun updateAuthToken(accessToken: String, refreshToken: String): Result +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/datasource/AuthRemoteDataSource.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/datasource/AuthRemoteDataSource.kt new file mode 100644 index 00000000..25a41dbc --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/datasource/AuthRemoteDataSource.kt @@ -0,0 +1,8 @@ +package com.threegap.bitnagil.data.auth.datasource + +import com.threegap.bitnagil.data.auth.model.request.LoginRequestDto +import com.threegap.bitnagil.data.auth.model.response.LoginResponseDto + +interface AuthRemoteDataSource { + suspend fun login(socialAccessToken: String, loginRequestDto: LoginRequestDto): Result +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/datasourceimpl/AuthLocalDataSourceImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/datasourceimpl/AuthLocalDataSourceImpl.kt new file mode 100644 index 00000000..ebb0f58b --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/datasourceimpl/AuthLocalDataSourceImpl.kt @@ -0,0 +1,18 @@ +package com.threegap.bitnagil.data.auth.datasourceimpl + +import com.threegap.bitnagil.data.auth.datasource.AuthLocalDataSource +import com.threegap.bitnagil.datastore.auth.storage.AuthTokenDataStore +import javax.inject.Inject + +class AuthLocalDataSourceImpl @Inject constructor( + private val authTokenDataStore: AuthTokenDataStore, +) : AuthLocalDataSource { + + override suspend fun updateAuthToken(accessToken: String, refreshToken: String): Result = + runCatching { + authTokenDataStore.updateAuthToken( + accessToken = accessToken, + refreshToken = refreshToken, + ) + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/datasourceimpl/AuthRemoteDataSourceImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/datasourceimpl/AuthRemoteDataSourceImpl.kt new file mode 100644 index 00000000..ea4c61dc --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/datasourceimpl/AuthRemoteDataSourceImpl.kt @@ -0,0 +1,17 @@ +package com.threegap.bitnagil.data.auth.datasourceimpl + +import com.threegap.bitnagil.data.auth.datasource.AuthRemoteDataSource +import com.threegap.bitnagil.data.auth.model.request.LoginRequestDto +import com.threegap.bitnagil.data.auth.model.response.LoginResponseDto +import com.threegap.bitnagil.data.auth.service.AuthService +import com.threegap.bitnagil.data.common.safeApiCall +import javax.inject.Inject + +class AuthRemoteDataSourceImpl @Inject constructor( + private val authService: AuthService, +) : AuthRemoteDataSource { + override suspend fun login(socialAccessToken: String, loginRequestDto: LoginRequestDto): Result = + safeApiCall { + authService.postLogin(socialAccessToken, loginRequestDto) + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/mapper/AuthMapper.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/mapper/AuthMapper.kt new file mode 100644 index 00000000..53c89040 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/mapper/AuthMapper.kt @@ -0,0 +1,11 @@ +package com.threegap.bitnagil.data.auth.mapper + +import com.threegap.bitnagil.data.auth.model.response.LoginResponseDto +import com.threegap.bitnagil.domain.auth.model.AuthSession +import com.threegap.bitnagil.domain.auth.model.UserRole + +internal fun LoginResponseDto.toDomain() = AuthSession( + accessToken = accessToken, + refreshToken = refreshToken, + role = UserRole.from(role), +) diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/model/request/LoginRequestDto.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/model/request/LoginRequestDto.kt new file mode 100644 index 00000000..9ffbb088 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/model/request/LoginRequestDto.kt @@ -0,0 +1,10 @@ +package com.threegap.bitnagil.data.auth.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginRequestDto( + @SerialName("socialType") + val socialType: String, +) diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/model/response/LoginResponseDto.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/model/response/LoginResponseDto.kt new file mode 100644 index 00000000..570acabd --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/model/response/LoginResponseDto.kt @@ -0,0 +1,14 @@ +package com.threegap.bitnagil.data.auth.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponseDto( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String, + @SerialName("role") + val role: String, +) diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/repositoryimpl/AuthRepositoryImpl.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/repositoryimpl/AuthRepositoryImpl.kt new file mode 100644 index 00000000..0cb2ed6a --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/repositoryimpl/AuthRepositoryImpl.kt @@ -0,0 +1,21 @@ +package com.threegap.bitnagil.data.auth.repositoryimpl + +import com.threegap.bitnagil.data.auth.datasource.AuthLocalDataSource +import com.threegap.bitnagil.data.auth.datasource.AuthRemoteDataSource +import com.threegap.bitnagil.data.auth.mapper.toDomain +import com.threegap.bitnagil.data.auth.model.request.LoginRequestDto +import com.threegap.bitnagil.domain.auth.model.AuthSession +import com.threegap.bitnagil.domain.auth.repository.AuthRepository +import javax.inject.Inject + +class AuthRepositoryImpl @Inject constructor( + private val authRemoteDataSource: AuthRemoteDataSource, + private val authLocalDataSource: AuthLocalDataSource, +) : AuthRepository { + override suspend fun login(socialAccessToken: String, socialType: String): Result = + authRemoteDataSource.login(socialAccessToken, LoginRequestDto(socialType)) + .map { it.toDomain() } + + override suspend fun updateAuthToken(accessToken: String, refreshToken: String): Result = + authLocalDataSource.updateAuthToken(accessToken, refreshToken) +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/auth/service/AuthService.kt b/data/src/main/java/com/threegap/bitnagil/data/auth/service/AuthService.kt new file mode 100644 index 00000000..b78b4be5 --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/auth/service/AuthService.kt @@ -0,0 +1,18 @@ +package com.threegap.bitnagil.data.auth.service + +import com.threegap.bitnagil.data.auth.model.request.LoginRequestDto +import com.threegap.bitnagil.data.auth.model.response.LoginResponseDto +import com.threegap.bitnagil.network.model.BaseResponse +import retrofit2.http.Body +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST + +interface AuthService { + @POST("/api/v1/auth/login") + @Headers("No-Service-Token: true") + suspend fun postLogin( + @Header("SocialAccessToken") socialAccessToken: String, + @Body loginRequestDto: LoginRequestDto, + ): BaseResponse +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/common/SafeApiCall.kt b/data/src/main/java/com/threegap/bitnagil/data/common/SafeApiCall.kt new file mode 100644 index 00000000..4840ab7d --- /dev/null +++ b/data/src/main/java/com/threegap/bitnagil/data/common/SafeApiCall.kt @@ -0,0 +1,39 @@ +package com.threegap.bitnagil.data.common + +import com.threegap.bitnagil.domain.error.model.BitnagilError +import com.threegap.bitnagil.network.model.BaseResponse +import com.threegap.bitnagil.network.model.ErrorResponse +import kotlinx.serialization.json.Json +import retrofit2.HttpException +import java.io.IOException + +internal suspend inline fun safeApiCall( + crossinline apiCall: suspend () -> BaseResponse, +): Result { + return try { + val response = apiCall() + response.data?.let { data -> + Result.success(data) + } ?: Result.failure( + BitnagilError( + code = "EMPTY_DATA", + message = response.message, + ), + ) + } catch (e: HttpException) { + val errorBody = e.response()?.errorBody()?.string() + val errorResponse = errorBody?.let { + Json.decodeFromString(it) + } + Result.failure( + BitnagilError( + code = errorResponse?.code ?: "HTTP_${e.code()}", + message = errorResponse?.message ?: e.message(), + ), + ) + } catch (e: IOException) { + Result.failure(Exception(e.message ?: "Network error")) + } catch (e: Exception) { + Result.failure(Exception(e.message ?: "Unknown error")) + } +} diff --git a/data/src/main/java/com/threegap/bitnagil/data/datasource/.gitkeep b/data/src/main/java/com/threegap/bitnagil/data/datasource/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/data/src/main/java/com/threegap/bitnagil/data/di/.gitkeep b/data/src/main/java/com/threegap/bitnagil/data/di/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/data/src/main/java/com/threegap/bitnagil/data/mapper/.gitkeep b/data/src/main/java/com/threegap/bitnagil/data/mapper/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/data/src/main/java/com/threegap/bitnagil/data/repositoryimpl/.gitkeep b/data/src/main/java/com/threegap/bitnagil/data/repositoryimpl/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/data/src/main/java/com/threegap/bitnagil/data/service/.gitkeep b/data/src/main/java/com/threegap/bitnagil/data/service/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 577e127e..eaee9b99 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -4,4 +4,5 @@ plugins { dependencies { implementation(libs.kotlinx.coroutines.core) + implementation(libs.javax.inject) } diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/auth/model/AuthSession.kt b/domain/src/main/java/com/threegap/bitnagil/domain/auth/model/AuthSession.kt new file mode 100644 index 00000000..689f9c0b --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/auth/model/AuthSession.kt @@ -0,0 +1,7 @@ +package com.threegap.bitnagil.domain.auth.model + +data class AuthSession( + val accessToken: String, + val refreshToken: String, + val role: UserRole, +) diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/auth/model/UserRole.kt b/domain/src/main/java/com/threegap/bitnagil/domain/auth/model/UserRole.kt new file mode 100644 index 00000000..141ae17b --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/auth/model/UserRole.kt @@ -0,0 +1,18 @@ +package com.threegap.bitnagil.domain.auth.model + +enum class UserRole { + USER, + GUEST, + ; + + fun isGuest() = this == GUEST + + companion object { + fun from(value: String): UserRole = + when (value) { + "USER" -> USER + "GUEST" -> GUEST + else -> throw IllegalArgumentException("Unknown role: $value") + } + } +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/auth/repository/AuthRepository.kt b/domain/src/main/java/com/threegap/bitnagil/domain/auth/repository/AuthRepository.kt new file mode 100644 index 00000000..da1187a0 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/auth/repository/AuthRepository.kt @@ -0,0 +1,9 @@ +package com.threegap.bitnagil.domain.auth.repository + +import com.threegap.bitnagil.domain.auth.model.AuthSession + +interface AuthRepository { + suspend fun login(socialAccessToken: String, socialType: String): Result + + suspend fun updateAuthToken(accessToken: String, refreshToken: String): Result +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/auth/usecase/LoginUseCase.kt b/domain/src/main/java/com/threegap/bitnagil/domain/auth/usecase/LoginUseCase.kt new file mode 100644 index 00000000..b41f1984 --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/auth/usecase/LoginUseCase.kt @@ -0,0 +1,17 @@ +package com.threegap.bitnagil.domain.auth.usecase + +import com.threegap.bitnagil.domain.auth.model.AuthSession +import com.threegap.bitnagil.domain.auth.repository.AuthRepository +import javax.inject.Inject + +class LoginUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(socialAccessToken: String, socialType: String): Result = + authRepository.login(socialAccessToken, socialType) + .mapCatching { authSession -> + authRepository.updateAuthToken(authSession.accessToken, authSession.refreshToken) + .getOrThrow() + authSession + } +} diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/error/model/BitnagilError.kt b/domain/src/main/java/com/threegap/bitnagil/domain/error/model/BitnagilError.kt new file mode 100644 index 00000000..8f4d518a --- /dev/null +++ b/domain/src/main/java/com/threegap/bitnagil/domain/error/model/BitnagilError.kt @@ -0,0 +1,6 @@ +package com.threegap.bitnagil.domain.error.model + +data class BitnagilError( + val code: String, + override val message: String, +) : Exception() diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/model/.gitkeep b/domain/src/main/java/com/threegap/bitnagil/domain/model/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/repository/.gitkeep b/domain/src/main/java/com/threegap/bitnagil/domain/repository/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/domain/src/main/java/com/threegap/bitnagil/domain/usecase/.gitkeep b/domain/src/main/java/com/threegap/bitnagil/domain/usecase/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/presentation/build.gradle.kts b/presentation/build.gradle.kts index 9e2539dc..78005b07 100644 --- a/presentation/build.gradle.kts +++ b/presentation/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.bitnagil.android.library) alias(libs.plugins.bitnagil.android.compose.library) + alias(libs.plugins.bitnagil.android.hilt) alias(libs.plugins.kotlin.parcelize) } @@ -10,6 +11,7 @@ android { dependencies { implementation(projects.core.designsystem) + implementation(projects.domain) implementation(libs.bundles.androidx.core) implementation(libs.bundles.orbit) diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/LoginScreen.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/LoginScreen.kt index b7a5904a..608ce301 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/LoginScreen.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/LoginScreen.kt @@ -14,38 +14,40 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import com.kakao.sdk.user.UserApiClient import com.threegap.bitnagil.designsystem.BitnagilTheme -import com.threegap.bitnagil.presentation.login.model.LoginIntent +import com.threegap.bitnagil.presentation.login.kakao.KakaoLoginHandlerImpl import com.threegap.bitnagil.presentation.login.model.LoginSideEffect import org.orbitmvi.orbit.compose.collectSideEffect @Composable -fun LoginScreenContainer(viewModel: LoginViewModel = hiltViewModel()) { +fun LoginScreenContainer( + viewModel: LoginViewModel = hiltViewModel(), +) { val context = LocalContext.current - val client = UserApiClient.instance viewModel.collectSideEffect { sideEffect -> when (sideEffect) { - is LoginSideEffect.RequestKakaoTalkLogin -> { - client.loginWithKakaoTalk(context) { token, error -> - viewModel.sendIntent(LoginIntent.OnKakaoLoginResult(token, error)) + is LoginSideEffect.RequestKakaoAccountLogin -> { + KakaoLoginHandlerImpl.accountLogin(context) { token, error -> + viewModel.kakaoLogin(token, error) } } - is LoginSideEffect.RequestKakaoAccountLogin -> { - client.loginWithKakaoAccount(context) { token, error -> - viewModel.sendIntent(LoginIntent.OnKakaoLoginResult(token, error)) - } + is LoginSideEffect.NavigateToHome -> { + // TODO: Navigate to Home + } + + is LoginSideEffect.NavigateToTermsOfService -> { + // TODO: Navigate to Terms of Service } } } LoginScreen( onKakaoLoginClick = { - viewModel.sendIntent( - LoginIntent.OnKakaoLoginClick(client.isKakaoTalkLoginAvailable(context)), - ) + KakaoLoginHandlerImpl.login(context) { token, error -> + viewModel.kakaoLogin(token, error) + } }, ) } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/LoginViewModel.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/LoginViewModel.kt index 6964a4cb..4a8b8059 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/LoginViewModel.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/LoginViewModel.kt @@ -2,20 +2,25 @@ package com.threegap.bitnagil.presentation.login import android.util.Log import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause -import com.kakao.sdk.user.UserApiClient +import com.threegap.bitnagil.domain.auth.usecase.LoginUseCase +import com.threegap.bitnagil.domain.error.model.BitnagilError import com.threegap.bitnagil.presentation.common.mviviewmodel.MviViewModel import com.threegap.bitnagil.presentation.login.model.LoginIntent import com.threegap.bitnagil.presentation.login.model.LoginSideEffect import com.threegap.bitnagil.presentation.login.model.LoginState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch import org.orbitmvi.orbit.syntax.simple.SimpleSyntax import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, + private val loginUseCase: LoginUseCase, ) : MviViewModel( initState = LoginState(), savedStateHandle = savedStateHandle, @@ -25,42 +30,71 @@ class LoginViewModel @Inject constructor( state: LoginState, ): LoginState? = when (intent) { - is LoginIntent.OnKakaoLoginClick -> { - if (!intent.onKakaoTalkLoginAvailable) { - sendSideEffect(LoginSideEffect.RequestKakaoAccountLogin) - } else { - sendSideEffect(LoginSideEffect.RequestKakaoTalkLogin) - } - null + is LoginIntent.SetLoading -> { + state.copy(isLoading = intent.isLoading) + } + + is LoginIntent.LoginSuccess -> { + sendSideEffect( + if (intent.isGuest) { + LoginSideEffect.NavigateToTermsOfService + } else { + LoginSideEffect.NavigateToHome + }, + ) + state.copy( + isGuest = intent.isGuest, + isLoading = false, + ) + } + + is LoginIntent.KakaoTalkLoginCancel -> { + sendSideEffect(LoginSideEffect.RequestKakaoAccountLogin) + state.copy(isLoading = false) + } + + is LoginIntent.LoginFailure -> { + state.copy(isLoading = false) } + } - is LoginIntent.OnKakaoLoginResult -> { - when { - intent.token != null -> { - Log.i("KakaoLogin", "로그인 성공 ${intent.token.accessToken}") - UserApiClient.instance.me { user, error -> - if (error != null) { - Log.e("KakaoLogin", "사용자 정보 요청 실패", error) - } else if (user != null) { - Log.i( - "KakaoLogin", - "사용자 정보 요청 성공" + - "\n이메일: ${user.kakaoAccount?.email}" + - "\n닉네임: ${user.kakaoAccount?.profile?.nickname}", - ) - } - } - } + fun kakaoLogin(token: OAuthToken?, error: Throwable?) { + viewModelScope.launch { + sendIntent(LoginIntent.SetLoading(true)) + when { + token != null -> { + processKakaoLoginSuccess(token) + } - intent.error is ClientError && intent.error.reason == ClientErrorCause.Cancelled -> { - Log.e("KakaoLogin", "로그인 취소", intent.error) - } + error is ClientError && error.reason == ClientErrorCause.Cancelled -> { + Log.e("KakaoLogin", "카카오 로그인 취소", error) + sendIntent(LoginIntent.KakaoTalkLoginCancel) + } - intent.error != null -> { - Log.e("KakaoLogin", "로그인 실패", intent.error) - } + error != null -> { + Log.e("KakaoLogin", "카카오 로그인 실패", error) + sendIntent(LoginIntent.LoginFailure) } - null } } + } + + private suspend fun processKakaoLoginSuccess(token: OAuthToken) { + loginUseCase( + socialAccessToken = token.accessToken, + socialType = "KAKAO", + ).fold( + onSuccess = { + val isGuest = it.role.isGuest() + sendIntent(LoginIntent.LoginSuccess(isGuest = isGuest)) + }, + onFailure = { e -> + sendIntent(LoginIntent.LoginFailure) + if (e is BitnagilError) { + Log.e("Login", "${e.code} ${e.message}") + } + Log.e("Login", "${e.message}") + }, + ) + } } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/kakao/KakaoLoginHandler.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/kakao/KakaoLoginHandler.kt new file mode 100644 index 00000000..74c2de78 --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/kakao/KakaoLoginHandler.kt @@ -0,0 +1,16 @@ +package com.threegap.bitnagil.presentation.login.kakao + +import android.content.Context +import com.kakao.sdk.auth.model.OAuthToken + +interface KakaoLoginHandler { + fun login( + context: Context, + onResult: (OAuthToken?, Throwable?) -> Unit, + ) + + fun accountLogin( + context: Context, + onResult: (OAuthToken?, Throwable?) -> Unit, + ) +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/kakao/KakaoLoginHandlerImpl.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/kakao/KakaoLoginHandlerImpl.kt new file mode 100644 index 00000000..03a1f95d --- /dev/null +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/kakao/KakaoLoginHandlerImpl.kt @@ -0,0 +1,27 @@ +package com.threegap.bitnagil.presentation.login.kakao + +import android.content.Context +import com.kakao.sdk.auth.model.OAuthToken +import com.kakao.sdk.user.UserApiClient + +object KakaoLoginHandlerImpl : KakaoLoginHandler { + private val client = UserApiClient.instance + + override fun login( + context: Context, + onResult: (OAuthToken?, Throwable?) -> Unit, + ) { + if (client.isKakaoTalkLoginAvailable(context)) { + client.loginWithKakaoTalk(context, callback = onResult) + } else { + client.loginWithKakaoAccount(context, callback = onResult) + } + } + + override fun accountLogin( + context: Context, + onResult: (OAuthToken?, Throwable?) -> Unit, + ) { + client.loginWithKakaoAccount(context, callback = onResult) + } +} diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginIntent.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginIntent.kt index 16a39a5f..ade3f445 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginIntent.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginIntent.kt @@ -1,13 +1,10 @@ package com.threegap.bitnagil.presentation.login.model -import com.kakao.sdk.auth.model.OAuthToken import com.threegap.bitnagil.presentation.common.mviviewmodel.MviIntent sealed class LoginIntent : MviIntent { - data class OnKakaoLoginClick(val onKakaoTalkLoginAvailable: Boolean) : LoginIntent() - - data class OnKakaoLoginResult( - val token: OAuthToken?, - val error: Throwable?, - ) : LoginIntent() + data class SetLoading(val isLoading: Boolean) : LoginIntent() + data class LoginSuccess(val isGuest: Boolean) : LoginIntent() + data object KakaoTalkLoginCancel : LoginIntent() + data object LoginFailure : LoginIntent() } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginSideEffect.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginSideEffect.kt index a94c2ce9..dc12afda 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginSideEffect.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginSideEffect.kt @@ -3,7 +3,7 @@ package com.threegap.bitnagil.presentation.login.model import com.threegap.bitnagil.presentation.common.mviviewmodel.MviSideEffect sealed interface LoginSideEffect : MviSideEffect { - data object RequestKakaoTalkLogin : LoginSideEffect - data object RequestKakaoAccountLogin : LoginSideEffect + data object NavigateToHome : LoginSideEffect + data object NavigateToTermsOfService : LoginSideEffect } diff --git a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginState.kt b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginState.kt index 35dc826b..1ba7fd01 100644 --- a/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginState.kt +++ b/presentation/src/main/java/com/threegap/bitnagil/presentation/login/model/LoginState.kt @@ -5,5 +5,6 @@ import kotlinx.parcelize.Parcelize @Parcelize data class LoginState( - val isLoggedIn: Boolean = false, + val isLoading: Boolean = false, + val isGuest: Boolean = false, ) : MviState