diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b84334df1..30944de36 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,10 +41,6 @@ android { isDebuggable = true applicationIdSuffix = ".debug" - buildConfigs(rootDir) { - string(name = "BASE_URL", key = "debug.base.url") - } - manifestPlaceholders { "appName" to "@string/app_name_debug" "appIcon" to "@mipmap/ic_wss_logo_debug" @@ -61,10 +57,6 @@ android { "proguard-rules.pro", ) - buildConfigs(rootDir) { - string(name = "BASE_URL", key = "release.base.url") - } - manifestPlaceholders { "appName" to "@string/app_name" "appIcon" to "@mipmap/ic_wss_logo" @@ -84,9 +76,11 @@ dependencies { // 프로젝트 의존성 implementation(projects.core.resource) implementation(projects.core.designsystem) + implementation(projects.core.common) implementation(projects.core.auth) implementation(projects.core.authKakao) implementation(projects.core.network) + implementation(projects.core.datastore) implementation(projects.feature.signin) diff --git a/app/src/main/java/com/into/websoso/WebsosoApp.kt b/app/src/main/java/com/into/websoso/WebsosoApp.kt index 63f2a2d23..a376f4375 100644 --- a/app/src/main/java/com/into/websoso/WebsosoApp.kt +++ b/app/src/main/java/com/into/websoso/WebsosoApp.kt @@ -2,16 +2,34 @@ package com.into.websoso import android.app.Application import androidx.appcompat.app.AppCompatDelegate +import androidx.lifecycle.ProcessLifecycleOwner import com.into.websoso.BuildConfig.KAKAO_APP_KEY +import com.into.websoso.core.auth.AuthSessionManager +import com.into.websoso.core.common.navigator.NavigatorProvider +import com.into.websoso.core.common.util.collectWithLifecycle import com.kakao.sdk.common.KakaoSdk import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject @HiltAndroidApp class WebsosoApp : Application() { + @Inject + lateinit var sessionManager: AuthSessionManager + + @Inject + lateinit var navigatorProvider: NavigatorProvider + override fun onCreate() { super.onCreate() AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + subscribeSessionState() KakaoSdk.init(this, KAKAO_APP_KEY) } + + private fun subscribeSessionState() { + sessionManager.sessionExpired.collectWithLifecycle(ProcessLifecycleOwner.get()) { + navigatorProvider.navigateToLoginActivity() + } + } } diff --git a/app/src/main/java/com/into/websoso/core/common/util/navigator/WebsosoNavigator.kt b/app/src/main/java/com/into/websoso/core/common/util/navigator/WebsosoNavigator.kt new file mode 100644 index 000000000..e719ccfae --- /dev/null +++ b/app/src/main/java/com/into/websoso/core/common/util/navigator/WebsosoNavigator.kt @@ -0,0 +1,43 @@ +package com.into.websoso.core.common.util.navigator + +import android.content.Context +import com.into.websoso.core.common.navigator.NavigatorProvider +import com.into.websoso.ui.login.LoginActivity +import com.into.websoso.ui.main.MainActivity +import com.into.websoso.ui.onboarding.OnboardingActivity +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Inject +import javax.inject.Singleton + +internal class WebsosoNavigator + @Inject + constructor( + @ApplicationContext private val context: Context, + ) : NavigatorProvider { + override fun navigateToLoginActivity() { + val intent = LoginActivity.getIntent(context) + context.startActivity(intent) + } + + override fun navigateToMainActivity() { + val intent = MainActivity.getIntent(context, true) + context.startActivity(intent) + } + + override fun navigateToOnboardingActivity() { + val intent = OnboardingActivity.getIntent(context) + context.startActivity(intent) + } + } + +@Module +@InstallIn(SingletonComponent::class) +internal interface NavigatorModule { + @Binds + @Singleton + fun bindWebsosoNavigator(websosoNavigator: WebsosoNavigator): NavigatorProvider +} diff --git a/app/src/main/java/com/into/websoso/core/common/util/sessionManager/WebsosoAuthSessionManager.kt b/app/src/main/java/com/into/websoso/core/common/util/sessionManager/WebsosoAuthSessionManager.kt new file mode 100644 index 000000000..a7b598203 --- /dev/null +++ b/app/src/main/java/com/into/websoso/core/common/util/sessionManager/WebsosoAuthSessionManager.kt @@ -0,0 +1,31 @@ +package com.into.websoso.core.common.util.sessionManager + +import com.into.websoso.core.auth.AuthSessionManager +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +internal class WebsosoAuthSessionManager + @Inject + constructor() : AuthSessionManager { + private val _sessionExpired = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + override val sessionExpired: SharedFlow get() = _sessionExpired.asSharedFlow() + + override suspend fun onSessionExpired() { + _sessionExpired.emit(Unit) + } + } + +@Module +@InstallIn(SingletonComponent::class) +internal interface WebsosoAuthSessionManagerModule { + @Binds + @Singleton + fun bindWebsosoAuthSessionManager(websosoAuthSessionManager: WebsosoAuthSessionManager): AuthSessionManager +} diff --git a/app/src/main/java/com/into/websoso/data/authenticator/WebsosoAuthenticator.kt b/app/src/main/java/com/into/websoso/data/authenticator/WebsosoAuthenticator.kt deleted file mode 100644 index fe26783ab..000000000 --- a/app/src/main/java/com/into/websoso/data/authenticator/WebsosoAuthenticator.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.into.websoso.data.authenticator - -import android.content.Context -import com.into.websoso.data.repository.AuthRepository -import com.into.websoso.ui.login.LoginActivity -import com.kakao.sdk.user.UserApiClient -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.runBlocking -import okhttp3.Authenticator -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class WebsosoAuthenticator - @Inject - constructor( - private val authRepository: AuthRepository, - @ApplicationContext private val context: Context, - ) : Authenticator { - override fun authenticate( - route: Route?, - response: Response, - ): Request? { - if (response.request.header("Authorization") == null) { - return null - } - - if (response.code == 401) { - if (authRepository.refreshToken.isBlank()) { - return null - } - - val newAccessToken = runCatching { - runBlocking { - authRepository.reissueToken() - } - }.onFailure { - runBlocking { - authRepository.clearTokens() - UserApiClient.instance.logout { - context.startActivity(LoginActivity.getIntent(context)) - } - } - }.getOrThrow() - - return response.request - .newBuilder() - .header("Authorization", "Bearer $newAccessToken") - .build() - } - return null - } - } diff --git a/app/src/main/java/com/into/websoso/data/di/ApiModule.kt b/app/src/main/java/com/into/websoso/data/di/ApiModule.kt index c6f808287..8f303f6d5 100644 --- a/app/src/main/java/com/into/websoso/data/di/ApiModule.kt +++ b/app/src/main/java/com/into/websoso/data/di/ApiModule.kt @@ -1,7 +1,5 @@ package com.into.websoso.data.di -import com.into.websoso.data.qualifier.Secured -import com.into.websoso.data.qualifier.Unsecured import com.into.websoso.data.remote.api.AuthApi import com.into.websoso.data.remote.api.AvatarApi import com.into.websoso.data.remote.api.FeedApi @@ -24,61 +22,41 @@ import javax.inject.Singleton object ApiModule { @Provides @Singleton - fun provideAuthApi( - @Unsecured retrofit: Retrofit, - ): AuthApi = retrofit.create(AuthApi::class.java) + fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java) @Provides @Singleton - fun provideNovelApi( - @Secured retrofit: Retrofit, - ): NovelApi = retrofit.create(NovelApi::class.java) + fun provideNovelApi(retrofit: Retrofit): NovelApi = retrofit.create(NovelApi::class.java) @Provides @Singleton - fun provideUserNovelApi( - @Secured retrofit: Retrofit, - ): UserNovelApi = retrofit.create(UserNovelApi::class.java) + fun provideUserNovelApi(retrofit: Retrofit): UserNovelApi = retrofit.create(UserNovelApi::class.java) @Provides @Singleton - fun provideNotificationApi( - @Secured retrofit: Retrofit, - ): NotificationApi = retrofit.create(NotificationApi::class.java) + fun provideNotificationApi(retrofit: Retrofit): NotificationApi = retrofit.create(NotificationApi::class.java) @Provides @Singleton - fun provideFeedApi( - @Secured retrofit: Retrofit, - ): FeedApi = retrofit.create(FeedApi::class.java) + fun provideFeedApi(retrofit: Retrofit): FeedApi = retrofit.create(FeedApi::class.java) @Provides @Singleton - fun provideUserApi( - @Secured retrofit: Retrofit, - ): UserApi = retrofit.create(UserApi::class.java) + fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java) @Provides @Singleton - fun provideKeywordApi( - @Secured retrofit: Retrofit, - ): KeywordApi = retrofit.create(KeywordApi::class.java) + fun provideKeywordApi(retrofit: Retrofit): KeywordApi = retrofit.create(KeywordApi::class.java) @Provides @Singleton - fun provideAvatarApi( - @Secured retrofit: Retrofit, - ): AvatarApi = retrofit.create(AvatarApi::class.java) + fun provideAvatarApi(retrofit: Retrofit): AvatarApi = retrofit.create(AvatarApi::class.java) @Provides @Singleton - fun provideVersionApi( - @Secured retrofit: Retrofit, - ): VersionApi = retrofit.create(VersionApi::class.java) + fun provideVersionApi(retrofit: Retrofit): VersionApi = retrofit.create(VersionApi::class.java) @Provides @Singleton - fun providePushMessageApi( - @Secured retrofit: Retrofit, - ): PushMessageApi = retrofit.create(PushMessageApi::class.java) + fun providePushMessageApi(retrofit: Retrofit): PushMessageApi = retrofit.create(PushMessageApi::class.java) } diff --git a/app/src/main/java/com/into/websoso/data/di/NetworkModule.kt b/app/src/main/java/com/into/websoso/data/di/NetworkModule.kt deleted file mode 100644 index 1cabf301f..000000000 --- a/app/src/main/java/com/into/websoso/data/di/NetworkModule.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.into.websoso.data.di - -import com.into.websoso.BuildConfig -import com.into.websoso.data.authenticator.WebsosoAuthenticator -import com.into.websoso.data.interceptor.AuthInterceptor -import com.into.websoso.data.qualifier.Auth -import com.into.websoso.data.qualifier.Logging -import com.into.websoso.data.qualifier.Secured -import com.into.websoso.data.qualifier.Unsecured -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.json.Json -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Converter -import retrofit2.Retrofit -import java.util.concurrent.TimeUnit -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object NetworkModule { - private const val BASE_URL = BuildConfig.BASE_URL - private const val CONTENT_TYPE = "application/json" - - @Provides - @Singleton - fun provideJson(): Json = Json { ignoreUnknownKeys = true } - - @Provides - @Singleton - fun provideJsonConverterFactory(json: Json): Converter.Factory = json.asConverterFactory(CONTENT_TYPE.toMediaType()) - - @Provides - @Singleton - @Logging - fun provideLoggingInterceptor(): Interceptor = - HttpLoggingInterceptor().apply { - level = if (BuildConfig.DEBUG) { - HttpLoggingInterceptor.Level.BODY - } else { - HttpLoggingInterceptor.Level.NONE - } - } - - @Provides - @Singleton - @Auth - fun provideAuthInterceptor(interceptor: AuthInterceptor): Interceptor = interceptor - - @Provides - @Singleton - @Secured - fun provideSecuredOkHttpClient( - @Logging loggingInterceptor: Interceptor, - @Auth authInterceptor: Interceptor, - websosoAuthenticator: WebsosoAuthenticator, - ): OkHttpClient = - OkHttpClient - .Builder() - .addInterceptor(loggingInterceptor) - .addInterceptor(authInterceptor) - .authenticator(websosoAuthenticator) - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(15, TimeUnit.SECONDS) - .build() - - @Provides - @Singleton - @Unsecured - fun provideUnsecuredOkHttpClient( - @Logging loggingInterceptor: Interceptor, - ): OkHttpClient = - OkHttpClient - .Builder() - .addInterceptor(loggingInterceptor) - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(15, TimeUnit.SECONDS) - .build() - - @Provides - @Singleton - @Secured - fun provideSecuredRetrofit( - @Secured client: OkHttpClient, - converterFactory: Converter.Factory, - ): Retrofit = - Retrofit - .Builder() - .baseUrl(BASE_URL) - .client(client) - .addConverterFactory(converterFactory) - .build() - - @Provides - @Singleton - @Unsecured - fun provideUnsecuredRetrofit( - @Unsecured client: OkHttpClient, - converterFactory: Converter.Factory, - ): Retrofit = - Retrofit - .Builder() - .baseUrl(BASE_URL) - .client(client) - .addConverterFactory(converterFactory) - .build() -} diff --git a/app/src/main/java/com/into/websoso/data/di/OAuthModule.kt b/app/src/main/java/com/into/websoso/data/di/OAuthModule.kt deleted file mode 100644 index 45a0a0e04..000000000 --- a/app/src/main/java/com/into/websoso/data/di/OAuthModule.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.into.websoso.data.di - -import com.into.websoso.data.remote.api.KakaoAuthService -import com.into.websoso.data.remote.api.OAuthService -import com.kakao.sdk.user.UserApiClient -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.components.ActivityComponent -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -object OAuthModule { - @Provides - fun provideKakaoApiClient(): UserApiClient = UserApiClient.instance - - @Module - @InstallIn(ActivityComponent::class) - interface Binder { - @Binds - fun bindKakaoAuthService(service: KakaoAuthService): OAuthService - } -} diff --git a/app/src/main/java/com/into/websoso/data/interceptor/AuthInterceptor.kt b/app/src/main/java/com/into/websoso/data/interceptor/AuthInterceptor.kt deleted file mode 100644 index 243e45c53..000000000 --- a/app/src/main/java/com/into/websoso/data/interceptor/AuthInterceptor.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.into.websoso.data.interceptor - -import com.into.websoso.data.repository.AuthRepository -import okhttp3.Interceptor -import okhttp3.Response -import javax.inject.Inject - -class AuthInterceptor - @Inject - constructor( - private val authRepository: AuthRepository, - ) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain - .request() - .newBuilder() - .header("Authorization", "Bearer ${authRepository.accessToken}") - .build() - - return chain.proceed(request) - } - } diff --git a/app/src/main/java/com/into/websoso/data/mapper/KakaoTokenMapper.kt b/app/src/main/java/com/into/websoso/data/mapper/KakaoTokenMapper.kt deleted file mode 100644 index 6fcfde8a8..000000000 --- a/app/src/main/java/com/into/websoso/data/mapper/KakaoTokenMapper.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.into.websoso.data.mapper - -import com.into.websoso.data.model.KakaoOAuthToken - -typealias KakaoToken = com.kakao.sdk.auth.model.OAuthToken - -fun KakaoToken.toOAuthToken() = - KakaoOAuthToken( - accessToken = accessToken, - refreshToken = refreshToken, - ) diff --git a/app/src/main/java/com/into/websoso/data/mapper/LoginMapper.kt b/app/src/main/java/com/into/websoso/data/mapper/LoginMapper.kt deleted file mode 100644 index f6cb55e84..000000000 --- a/app/src/main/java/com/into/websoso/data/mapper/LoginMapper.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.into.websoso.data.mapper - -import com.into.websoso.data.model.LoginEntity -import com.into.websoso.data.remote.response.KakaoLoginResponseDto - -fun KakaoLoginResponseDto.toData(): LoginEntity = - LoginEntity( - authorization = this.authorization, - refreshToken = this.refreshToken, - isRegister = this.isRegister, - ) diff --git a/app/src/main/java/com/into/websoso/data/model/LoginEntity.kt b/app/src/main/java/com/into/websoso/data/model/LoginEntity.kt deleted file mode 100644 index fe1801468..000000000 --- a/app/src/main/java/com/into/websoso/data/model/LoginEntity.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.into.websoso.data.model - -data class LoginEntity( - val authorization: String, - val refreshToken: String, - val isRegister: Boolean, -) diff --git a/app/src/main/java/com/into/websoso/data/model/OAuthToken.kt b/app/src/main/java/com/into/websoso/data/model/OAuthToken.kt deleted file mode 100644 index af3201733..000000000 --- a/app/src/main/java/com/into/websoso/data/model/OAuthToken.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.into.websoso.data.model - -sealed interface OAuthToken { - val accessToken: String -} - -data class KakaoOAuthToken( - override val accessToken: String, - val refreshToken: String, -) : OAuthToken diff --git a/app/src/main/java/com/into/websoso/data/qualifier/Auth.kt b/app/src/main/java/com/into/websoso/data/qualifier/Auth.kt deleted file mode 100644 index 17c08e7d9..000000000 --- a/app/src/main/java/com/into/websoso/data/qualifier/Auth.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.into.websoso.data.qualifier - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class Secured - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class Unsecured diff --git a/app/src/main/java/com/into/websoso/data/qualifier/Interceptor.kt b/app/src/main/java/com/into/websoso/data/qualifier/Interceptor.kt deleted file mode 100644 index 020d72e6f..000000000 --- a/app/src/main/java/com/into/websoso/data/qualifier/Interceptor.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.into.websoso.data.qualifier - -import javax.inject.Qualifier - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class Logging - -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class Auth diff --git a/app/src/main/java/com/into/websoso/data/remote/api/AuthApi.kt b/app/src/main/java/com/into/websoso/data/remote/api/AuthApi.kt index a20f75d7e..34ba169e6 100644 --- a/app/src/main/java/com/into/websoso/data/remote/api/AuthApi.kt +++ b/app/src/main/java/com/into/websoso/data/remote/api/AuthApi.kt @@ -2,11 +2,8 @@ package com.into.websoso.data.remote.api import com.into.websoso.data.remote.request.FCMTokenRequestDto import com.into.websoso.data.remote.request.LogoutRequestDto -import com.into.websoso.data.remote.request.TokenReissueRequestDto import com.into.websoso.data.remote.request.UserProfileRequestDto import com.into.websoso.data.remote.request.WithdrawRequestDto -import com.into.websoso.data.remote.response.KakaoLoginResponseDto -import com.into.websoso.data.remote.response.KakaoTokenReissueResponseDto import com.into.websoso.data.remote.response.UserNicknameValidityResponseDto import retrofit2.http.Body import retrofit2.http.GET @@ -15,11 +12,6 @@ import retrofit2.http.POST import retrofit2.http.Query interface AuthApi { - @POST("auth/login/kakao") - suspend fun loginWithKakao( - @Header("Kakao-Access-Token") accessToken: String, - ): KakaoLoginResponseDto - @GET("users/nickname/check") suspend fun getNicknameValidity( @Header("Authorization") authorization: String, @@ -32,11 +24,6 @@ interface AuthApi { @Body userProfileRequestDto: UserProfileRequestDto, ) - @POST("reissue") - suspend fun reissueToken( - @Body tokenReissueRequestDto: TokenReissueRequestDto, - ): KakaoTokenReissueResponseDto - @POST("auth/logout") suspend fun logout( @Header("Authorization") authorization: String, diff --git a/app/src/main/java/com/into/websoso/data/remote/api/KakaoAuthService.kt b/app/src/main/java/com/into/websoso/data/remote/api/KakaoAuthService.kt deleted file mode 100644 index 3c92bc8e5..000000000 --- a/app/src/main/java/com/into/websoso/data/remote/api/KakaoAuthService.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.into.websoso.data.remote.api - -import android.content.Context -import com.google.firebase.Firebase -import com.google.firebase.analytics.FirebaseAnalytics -import com.google.firebase.analytics.analytics -import com.google.firebase.analytics.logEvent -import com.into.websoso.data.mapper.toOAuthToken -import com.into.websoso.data.model.OAuthToken -import com.kakao.sdk.common.model.ClientError -import com.kakao.sdk.common.model.ClientErrorCause -import com.kakao.sdk.user.UserApiClient -import dagger.hilt.android.qualifiers.ActivityContext -import kotlinx.coroutines.suspendCancellableCoroutine -import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException - -class KakaoAuthService - @Inject - constructor( - @ActivityContext private val context: Context, - private val client: UserApiClient, - ) : OAuthService { - private val firebaseAnalytics: FirebaseAnalytics = Firebase.analytics - private val isKakaoTalkLoginAvailable: Boolean - get() = client.isKakaoTalkLoginAvailable(context) - - override suspend fun login(): OAuthToken = - suspendCancellableCoroutine { - if (isKakaoTalkLoginAvailable) { - client.loginWithKakaoTalk(context) { token, error -> - if (error != null) { - // 사용자가 카카오톡 설치 후 디바이스 권한 요청 화면에서 로그인을 취소한 경우, - // 의도적인 로그인 취소로 보고 카카오계정으로 로그인 시도 없이 로그인 취소로 처리 (예: 뒤로 가기) - if (error is ClientError && error.reason == ClientErrorCause.Cancelled) { - return@loginWithKakaoTalk - } - - // Firebase Analytics에 실패 이벤트 로그 기록 - firebaseAnalytics.logEvent("kakao_login_failure") { - param("error_message", error.message ?: "Unknown Error") - param("login_method", "kakao_talk") - } - - // 카카오톡에 연결된 카카오계정이 없는 경우, 카카오계정으로 로그인 시도 - client.loginWithKakaoAccount(context) { accountToken, accountError -> - if (accountError != null) { - it.resumeWithException(accountError) - } else if (accountToken != null) { - it.resume(accountToken.toOAuthToken()) - } - } - } else if (token != null) { - it.resume(token.toOAuthToken()) - } - } - } else { - client.loginWithKakaoAccount(context) { token, error -> - if (error != null) { - it.resumeWithException(error) - } else if (token != null) { - it.resume(token.toOAuthToken()) - } - } - } - } - - override suspend fun logout() = - suspendCancellableCoroutine { - client.logout { error -> - if (error != null) { - it.resumeWithException(error) - } else { - it.resume(Unit) - } - } - } - - override suspend fun withdraw() = - suspendCancellableCoroutine { - client.unlink { error -> - if (error != null) { - it.resumeWithException(error) - } else { - it.resume(Unit) - } - } - } - } diff --git a/app/src/main/java/com/into/websoso/data/remote/api/OAuthService.kt b/app/src/main/java/com/into/websoso/data/remote/api/OAuthService.kt deleted file mode 100644 index 70ab8ecdd..000000000 --- a/app/src/main/java/com/into/websoso/data/remote/api/OAuthService.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.into.websoso.data.remote.api - -import com.into.websoso.data.model.OAuthToken - -interface OAuthService { - suspend fun login(): OAuthToken - - suspend fun logout() - - suspend fun withdraw() -} diff --git a/app/src/main/java/com/into/websoso/data/remote/response/KakaoLoginResponseDto.kt b/app/src/main/java/com/into/websoso/data/remote/response/KakaoLoginResponseDto.kt deleted file mode 100644 index 9dcbbb58e..000000000 --- a/app/src/main/java/com/into/websoso/data/remote/response/KakaoLoginResponseDto.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.into.websoso.data.remote.response - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -class KakaoLoginResponseDto( - @SerialName("Authorization") - val authorization: String, - @SerialName("refreshToken") - val refreshToken: String, - @SerialName("isRegister") - val isRegister: Boolean, -) diff --git a/app/src/main/java/com/into/websoso/data/remote/response/KakaoTokenReissueResponseDto.kt b/app/src/main/java/com/into/websoso/data/remote/response/KakaoTokenReissueResponseDto.kt deleted file mode 100644 index 0e29c603c..000000000 --- a/app/src/main/java/com/into/websoso/data/remote/response/KakaoTokenReissueResponseDto.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.into.websoso.data.remote.response - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -class KakaoTokenReissueResponseDto( - @SerialName("Authorization") - val authorization: String, - @SerialName("refreshToken") - val refreshToken: String, -) diff --git a/app/src/main/java/com/into/websoso/data/repository/AuthRepository.kt b/app/src/main/java/com/into/websoso/data/repository/AuthRepository.kt index 10c80ca73..1214ea6b4 100644 --- a/app/src/main/java/com/into/websoso/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/into/websoso/data/repository/AuthRepository.kt @@ -1,12 +1,9 @@ package com.into.websoso.data.repository import android.content.SharedPreferences -import com.into.websoso.data.mapper.toData -import com.into.websoso.data.model.LoginEntity import com.into.websoso.data.remote.api.AuthApi import com.into.websoso.data.remote.request.FCMTokenRequestDto import com.into.websoso.data.remote.request.LogoutRequestDto -import com.into.websoso.data.remote.request.TokenReissueRequestDto import com.into.websoso.data.remote.request.UserProfileRequestDto import com.into.websoso.data.remote.request.WithdrawRequestDto import javax.inject.Inject @@ -29,13 +26,6 @@ class AuthRepository get() = authStorage.getBoolean(AUTO_LOGIN_KEY, false) private set(value) = authStorage.edit().putBoolean(AUTO_LOGIN_KEY, value).apply() - suspend fun loginWithKakao(accessToken: String): LoginEntity { - val response = authApi.loginWithKakao(accessToken) - this.accessToken = response.authorization - this.refreshToken = response.refreshToken - return response.toData() - } - suspend fun fetchNicknameValidity( authorization: String, nickname: String, @@ -89,17 +79,6 @@ class AuthRepository } } - suspend fun reissueToken(): String? = - runCatching { - val response = authApi.reissueToken(TokenReissueRequestDto(refreshToken)) - accessToken = response.authorization - refreshToken = response.refreshToken - response.authorization - }.getOrElse { - it.printStackTrace() - null - } - fun updateAccessToken(accessToken: String) { this.accessToken = accessToken } diff --git a/app/src/main/java/com/into/websoso/ui/login/LoginActivity.kt b/app/src/main/java/com/into/websoso/ui/login/LoginActivity.kt index 1d13428d6..c0646328c 100644 --- a/app/src/main/java/com/into/websoso/ui/login/LoginActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/login/LoginActivity.kt @@ -13,6 +13,7 @@ import com.google.firebase.analytics.analytics import com.into.websoso.R.layout.activity_login import com.into.websoso.core.auth.AuthClient import com.into.websoso.core.auth.AuthPlatform +import com.into.websoso.core.common.navigator.NavigatorProvider import com.into.websoso.core.common.ui.base.BaseActivity import com.into.websoso.core.designsystem.theme.WebsosoTheme import com.into.websoso.databinding.ActivityLoginBinding @@ -28,6 +29,9 @@ class LoginActivity : BaseActivity(activity_login) { @Inject lateinit var authClient: Map + @Inject + lateinit var websosoNavigator: NavigatorProvider + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -38,6 +42,7 @@ class LoginActivity : BaseActivity(activity_login) { authClient = { platform -> authClient[platform] ?: throw IllegalStateException() }, + websosoNavigator = websosoNavigator, ) } } diff --git a/app/src/main/java/com/into/websoso/ui/login/LoginViewModel.kt b/app/src/main/java/com/into/websoso/ui/login/LoginViewModel.kt deleted file mode 100644 index dabae48b4..000000000 --- a/app/src/main/java/com/into/websoso/ui/login/LoginViewModel.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.into.websoso.ui.login - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.into.websoso.core.resource.R.drawable.img_login_1 -import com.into.websoso.core.resource.R.drawable.img_login_2 -import com.into.websoso.core.resource.R.drawable.img_login_3 -import com.into.websoso.core.resource.R.drawable.img_login_4 -import com.into.websoso.data.repository.AuthRepository -import com.into.websoso.ui.login.model.LoginUiState -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class LoginViewModel - @Inject - constructor( - private val authRepository: AuthRepository, - ) : ViewModel() { - private val _loginUiState = MutableLiveData() - val loginUiState: LiveData get() = _loginUiState - - private val _loginImages = MutableLiveData>() - val loginImages: LiveData> = _loginImages - - lateinit var accessToken: String - private set - lateinit var refreshToken: String - private set - - init { - _loginImages.value = listOf( - img_login_1, - img_login_2, - img_login_3, - img_login_4, - ) - } - - fun loginWithKakao(kakaoAccessToken: String) { - viewModelScope.launch { - _loginUiState.value = LoginUiState.Loading - runCatching { - authRepository.loginWithKakao(kakaoAccessToken) - }.onSuccess { loginEntity -> - accessToken = loginEntity.authorization - refreshToken = loginEntity.refreshToken - - if (loginEntity.isRegister) { - authRepository.updateAccessToken(loginEntity.authorization) - authRepository.updateRefreshToken(loginEntity.refreshToken) - authRepository.updateIsAutoLogin(true) - } - - _loginUiState.value = LoginUiState.Success( - isRegistered = loginEntity.isRegister, - ) - }.onFailure { error -> - _loginUiState.value = LoginUiState.Failure(error) - } - } - } - } diff --git a/app/src/main/java/com/into/websoso/ui/login/model/LoginUiState.kt b/app/src/main/java/com/into/websoso/ui/login/model/LoginUiState.kt deleted file mode 100644 index 1128e5880..000000000 --- a/app/src/main/java/com/into/websoso/ui/login/model/LoginUiState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.into.websoso.ui.login.model - -sealed class LoginUiState { - data object Idle : LoginUiState() - - data object Loading : LoginUiState() - - data class Success( - val isRegistered: Boolean, - ) : LoginUiState() - - data class Failure( - val error: Throwable, - ) : LoginUiState() -} diff --git a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt index 31996f282..b988a3dba 100644 --- a/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt +++ b/app/src/main/java/com/into/websoso/ui/main/home/HomeFragment.kt @@ -22,7 +22,6 @@ import com.into.websoso.core.common.ui.model.ResultFrom.ProfileEditSuccess import com.into.websoso.core.common.util.collectWithLifecycle import com.into.websoso.core.common.util.tracker.Tracker import com.into.websoso.core.resource.R.string.home_nickname_interest_feed -import com.into.websoso.data.repository.AuthRepository import com.into.websoso.databinding.FragmentHomeBinding import com.into.websoso.ui.feedDetail.FeedDetailActivity import com.into.websoso.ui.main.MainViewModel @@ -43,8 +42,6 @@ class HomeFragment : BaseFragment(fragment_home) { @Inject lateinit var tracker: Tracker - @Inject - lateinit var authRepository: AuthRepository private val homeViewModel: HomeViewModel by viewModels() private val mainViewModel: MainViewModel by activityViewModels() diff --git a/app/src/main/java/com/into/websoso/ui/onboarding/OnboardingActivity.kt b/app/src/main/java/com/into/websoso/ui/onboarding/OnboardingActivity.kt index 20925b8e1..5d09232c2 100644 --- a/app/src/main/java/com/into/websoso/ui/onboarding/OnboardingActivity.kt +++ b/app/src/main/java/com/into/websoso/ui/onboarding/OnboardingActivity.kt @@ -95,15 +95,5 @@ class OnboardingActivity : BaseActivity(R.layout.acti const val REFRESH_TOKEN_KEY = "REFRESH_TOKEN" fun getIntent(context: Context): Intent = Intent(context, OnboardingActivity::class.java) - - fun getIntent( - context: Context, - accessToken: String, - refreshToken: String, - ): Intent = - Intent(context, OnboardingActivity::class.java).apply { - putExtra(ACCESS_TOKEN_KEY, accessToken) - putExtra(REFRESH_TOKEN_KEY, refreshToken) - } } } diff --git a/app/src/main/res/drawable/bg_login_white_radius_14dp_stroke_primary100_1dp.xml b/app/src/main/res/drawable/bg_login_white_radius_14dp_stroke_primary100_1dp.xml deleted file mode 100644 index c278e68a6..000000000 --- a/app/src/main/res/drawable/bg_login_white_radius_14dp_stroke_primary100_1dp.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 731f10745..790133b2b 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -1,6 +1,5 @@ @@ -10,70 +9,7 @@ - - - - - - - - - - diff --git a/core/auth/build.gradle.kts b/core/auth/build.gradle.kts index 03b973b77..4a4ae1bee 100644 --- a/core/auth/build.gradle.kts +++ b/core/auth/build.gradle.kts @@ -1,4 +1,5 @@ plugins { id("websoso.jvm.kotlin") + id("websoso.kotlin.coroutines") id("websoso.dagger") } diff --git a/core/auth/src/main/java/com/into/websoso/core/auth/AuthSessionManager.kt b/core/auth/src/main/java/com/into/websoso/core/auth/AuthSessionManager.kt new file mode 100644 index 000000000..79fbbb542 --- /dev/null +++ b/core/auth/src/main/java/com/into/websoso/core/auth/AuthSessionManager.kt @@ -0,0 +1,9 @@ +package com.into.websoso.core.auth + +import kotlinx.coroutines.flow.SharedFlow + +interface AuthSessionManager { + val sessionExpired: SharedFlow + + suspend fun onSessionExpired() +} diff --git a/core/common/src/main/java/com/into/websoso/core/common/dispatchers/DispatchersModule.kt b/core/common/src/main/java/com/into/websoso/core/common/dispatchers/DispatchersModule.kt new file mode 100644 index 000000000..49c7e3f5e --- /dev/null +++ b/core/common/src/main/java/com/into/websoso/core/common/dispatchers/DispatchersModule.kt @@ -0,0 +1,27 @@ +package com.into.websoso.core.common.dispatchers + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Qualifier + +@Module +@InstallIn(SingletonComponent::class) +internal object DispatchersModule { + @Provides + @Dispatcher(WebsosoDispatchers.IO) + fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO +} + +@Qualifier +@Retention(AnnotationRetention.RUNTIME) +annotation class Dispatcher( + val dispatchers: WebsosoDispatchers, +) + +enum class WebsosoDispatchers { + IO, +} diff --git a/core/common/src/main/java/com/into/websoso/core/common/extensions/ThrottleHelper.kt b/core/common/src/main/java/com/into/websoso/core/common/extensions/ThrottleHelper.kt new file mode 100644 index 000000000..aeb6ff35b --- /dev/null +++ b/core/common/src/main/java/com/into/websoso/core/common/extensions/ThrottleHelper.kt @@ -0,0 +1,42 @@ +package com.into.websoso.core.common.extensions + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +@Singleton +class ThrottleHelper + @Inject + constructor() { + private val mutex = Mutex() + private var lastExecutionTime: TimeMark? = null + + suspend operator fun invoke( + durationMillis: Long = DEFAULT_THROTTLE_DURATION, + block: suspend () -> T, + ): T? { + val now = TimeSource.Monotonic.markNow() + val shouldExecute = mutex.withLock { + val canRun = if (lastExecutionTime == null) { + true + } else { + val elapsed = lastExecutionTime!!.elapsedNow() + elapsed >= durationMillis.milliseconds + } + + if (canRun) lastExecutionTime = now + + canRun + } + + return if (shouldExecute) block() else null + } + + companion object { + private const val DEFAULT_THROTTLE_DURATION = 1000L + } + } diff --git a/core/common/src/main/java/com/into/websoso/core/common/navigator/NavigatorProvider.kt b/core/common/src/main/java/com/into/websoso/core/common/navigator/NavigatorProvider.kt new file mode 100644 index 000000000..86308dc32 --- /dev/null +++ b/core/common/src/main/java/com/into/websoso/core/common/navigator/NavigatorProvider.kt @@ -0,0 +1,19 @@ +package com.into.websoso.core.common.navigator + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +interface NavigatorProvider { + fun navigateToLoginActivity() + + fun navigateToMainActivity() + + fun navigateToOnboardingActivity() +} + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface NavigatorEntryPoint { + fun provideNavigator(): NavigatorProvider +} diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 72febb4a1..422e502e9 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -9,5 +9,9 @@ android { } dependencies { + // 데이터 레이어 의존성 + implementation(projects.data.account) + + // Datastore 라이브러리 implementation(libs.datastore.preferences) } diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/account/DefaultAccountDataSource.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/account/DefaultAccountDataSource.kt new file mode 100644 index 000000000..9d1c41c43 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/account/DefaultAccountDataSource.kt @@ -0,0 +1,73 @@ +package com.into.websoso.core.datastore.datasource.account + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.into.websoso.core.datastore.di.AccountDataStore +import com.into.websoso.data.account.datasource.AccountLocalDataSource +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +internal class DefaultAccountDataSource + @Inject + constructor( + @AccountDataStore private val accountDataStore: DataStore, + ) : AccountLocalDataSource { + override suspend fun accessToken(): String = + accountDataStore.data + .map { preferences -> + preferences[ACCESS_TOKEN].orEmpty() + }.first() + + override suspend fun refreshToken(): String = + accountDataStore.data + .map { preferences -> + preferences[REFRESH_TOKEN].orEmpty() + }.first() + + override suspend fun isAutoLogin(): Boolean = + accountDataStore.data + .map { preferences -> + preferences[IS_LOGIN] ?: false + }.first() + + override suspend fun saveAccessToken(accessToken: String) { + accountDataStore.edit { preferences -> + preferences[ACCESS_TOKEN] = accessToken + } + } + + override suspend fun saveRefreshToken(refreshToken: String) { + accountDataStore.edit { preferences -> + preferences[REFRESH_TOKEN] = refreshToken + } + } + + override suspend fun saveIsAutoLogin(isAutoLogin: Boolean) { + accountDataStore.edit { preferences -> + preferences[IS_LOGIN] = isAutoLogin + } + } + + companion object { + private val ACCESS_TOKEN = stringPreferencesKey("ACCESS_TOKEN") + private val REFRESH_TOKEN = stringPreferencesKey("REFRESH_TOKEN") + private val IS_LOGIN = booleanPreferencesKey("IS_LOGIN") + } + } + +@Module +@InstallIn(SingletonComponent::class) +internal interface AccountDataSourceModule { + @Binds + @Singleton + fun bindAccountLocalDataSource(defaultAccountDataSource: DefaultAccountDataSource): AccountLocalDataSource +} diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/gitkeep b/core/datastore/src/main/java/com/into/websoso/core/datastore/datasource/gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt index ad142bf26..9d7d13423 100644 --- a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreModule.kt @@ -17,9 +17,10 @@ internal object DataStoreModule { private const val ACCOUNT_DATASTORE = "ACCOUNT_DATASTORE" private val Context.accountDataStore: DataStore by preferencesDataStore(name = ACCOUNT_DATASTORE) - @Singleton @Provides - fun provideAccountPreferencesDataStore( + @Singleton + @AccountDataStore + internal fun provideAccountPreferencesDataStore( @ApplicationContext context: Context, ): DataStore = context.accountDataStore } diff --git a/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt new file mode 100644 index 000000000..bcf9786f0 --- /dev/null +++ b/core/datastore/src/main/java/com/into/websoso/core/datastore/di/DataStoreQualifier.kt @@ -0,0 +1,7 @@ +package com.into.websoso.core.datastore.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +internal annotation class AccountDataStore diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 4bf8ef41f..1d037d422 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { // 프로젝트 의존성 implementation(projects.core.auth) + implementation(projects.core.common) // 네트워크 관련 라이브러리 implementation(libs.retrofit) diff --git a/core/network/src/main/java/com/into/websoso/core/network/authenticator/AuthorizationAuthenticator.kt b/core/network/src/main/java/com/into/websoso/core/network/authenticator/AuthorizationAuthenticator.kt new file mode 100644 index 000000000..9126db0f5 --- /dev/null +++ b/core/network/src/main/java/com/into/websoso/core/network/authenticator/AuthorizationAuthenticator.kt @@ -0,0 +1,84 @@ +package com.into.websoso.core.network.authenticator + +import com.into.websoso.core.auth.AuthSessionManager +import com.into.websoso.core.common.dispatchers.Dispatcher +import com.into.websoso.core.common.dispatchers.WebsosoDispatchers +import com.into.websoso.core.common.extensions.ThrottleHelper +import com.into.websoso.data.account.AccountRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.Authenticator +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +internal class AuthorizationAuthenticator + @Inject + constructor( + private val accountRepository: Provider, + private val sessionManager: AuthSessionManager, + private val throttle: ThrottleHelper, + @Dispatcher(WebsosoDispatchers.IO) private val dispatcher: CoroutineDispatcher, + ) : Authenticator { + private val mutex: Mutex = Mutex() + + override fun authenticate( + route: Route?, + response: Response, + ): Request? { + if (shouldSkipCondition(response)) return null + + val renewedToken = runBlocking(dispatcher) { + mutex.withLock { + when (response.isRefreshNeeded()) { + true -> throttle { renewToken() } + false -> return@withLock accountRepository.get().accessToken() + } + } + } ?: return null + + return response.request + .newBuilder() + .header("Authorization", "Bearer $renewedToken") + .build() + } + + private fun shouldSkipCondition(response: Response): Boolean = + response.request.header("Authorization").isNullOrBlank() || + response.retryAttemptCount() >= MAX_ATTEMPT_COUNT + + private fun Response.retryAttemptCount(): Int = + generateSequence(this) { + it.priorResponse + }.count() + + private suspend fun Response.isRefreshNeeded(): Boolean { + val updatedAccessToken = accountRepository.get().accessToken() + val oldAccessToken = request + .header("Authorization") + ?.removePrefix("Bearer ") + + return oldAccessToken == updatedAccessToken + } + + private suspend fun renewToken(): String? = + runCatching { + accountRepository.get().renewToken() + }.fold( + onSuccess = { updatedAccessToken -> updatedAccessToken }, + onFailure = { + sessionManager.onSessionExpired() + null + }, + ) + + companion object { + private const val MAX_ATTEMPT_COUNT = 2 + } + } diff --git a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/AccountApi.kt b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/AccountApi.kt index 7d40cfbaf..c9f15a18d 100644 --- a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/AccountApi.kt +++ b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/AccountApi.kt @@ -1,10 +1,14 @@ package com.into.websoso.core.network.datasource.account +import com.into.websoso.core.network.datasource.account.model.KakaoLoginResponseDto +import com.into.websoso.core.network.datasource.account.model.TokenReissueRequestDto +import com.into.websoso.core.network.datasource.account.model.TokenReissueResponseDto import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import retrofit2.Retrofit +import retrofit2.http.Body import retrofit2.http.Header import retrofit2.http.POST import javax.inject.Singleton @@ -14,6 +18,11 @@ internal interface AccountApi { suspend fun postLoginWithKakao( @Header("Kakao-Access-Token") accessToken: String, ): KakaoLoginResponseDto + + @POST("reissue") + suspend fun postReissueToken( + @Body tokenReissueRequestDto: TokenReissueRequestDto, + ): TokenReissueResponseDto } @Module @@ -21,5 +30,5 @@ internal interface AccountApi { internal object AccountApiModule { @Provides @Singleton - fun provideAccountApi(retrofit: Retrofit): AccountApi = retrofit.create(AccountApi::class.java) + internal fun provideAccountApi(retrofit: Retrofit): AccountApi = retrofit.create(AccountApi::class.java) } diff --git a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/AccountDataSourceModule.kt b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/AccountDataSourceModule.kt deleted file mode 100644 index e3de40538..000000000 --- a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/AccountDataSourceModule.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.into.websoso.core.network.datasource.account - -import com.into.websoso.data.account.AccountRemoteDataSource -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -internal interface AccountDataSourceModule { - @Binds - @Singleton - fun bindAccountRemoteDataSource(defaultAccountDataSource: DefaultAccountDataSource): AccountRemoteDataSource -} diff --git a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/DefaultAccountDataSource.kt b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/DefaultAccountDataSource.kt index f9260d2f0..9b5447a87 100644 --- a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/DefaultAccountDataSource.kt +++ b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/DefaultAccountDataSource.kt @@ -2,11 +2,17 @@ package com.into.websoso.core.network.datasource.account import com.into.websoso.core.auth.AuthPlatform import com.into.websoso.core.auth.AuthToken -import com.into.websoso.data.account.AccountRemoteDataSource +import com.into.websoso.core.network.datasource.account.model.TokenReissueRequestDto +import com.into.websoso.data.account.datasource.AccountRemoteDataSource +import com.into.websoso.data.account.model.AccountEntity +import com.into.websoso.data.account.model.TokenEntity +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent import javax.inject.Inject import javax.inject.Singleton -@Singleton internal class DefaultAccountDataSource @Inject constructor( @@ -15,7 +21,28 @@ internal class DefaultAccountDataSource override suspend fun postLogin( platform: AuthPlatform, authToken: AuthToken, - ) = when (platform) { - AuthPlatform.KAKAO -> accountApi.postLoginWithKakao(authToken.accessToken).toData() - } + ): AccountEntity = + when (platform) { + AuthPlatform.KAKAO -> + accountApi + .postLoginWithKakao( + accessToken = authToken.accessToken, + ).toData() + } + + override suspend fun postReissue(refreshToken: String): TokenEntity = + accountApi + .postReissueToken( + tokenReissueRequestDto = TokenReissueRequestDto( + refreshToken = refreshToken, + ), + ).toData() } + +@Module +@InstallIn(SingletonComponent::class) +internal interface AccountDataSourceModule { + @Binds + @Singleton + fun bindAccountRemoteDataSource(defaultAccountDataSource: DefaultAccountDataSource): AccountRemoteDataSource +} diff --git a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/KakaoLoginResponseDto.kt b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/KakaoLoginResponseDto.kt similarity index 52% rename from core/network/src/main/java/com/into/websoso/core/network/datasource/account/KakaoLoginResponseDto.kt rename to core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/KakaoLoginResponseDto.kt index 6b765f2c9..ab428c724 100644 --- a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/KakaoLoginResponseDto.kt +++ b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/KakaoLoginResponseDto.kt @@ -1,6 +1,7 @@ -package com.into.websoso.core.network.datasource.account +package com.into.websoso.core.network.datasource.account.model -import com.into.websoso.data.account.AccountEntity +import com.into.websoso.data.account.model.AccountEntity +import com.into.websoso.data.account.model.TokenEntity import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -13,10 +14,12 @@ internal class KakaoLoginResponseDto( @SerialName("isRegister") val isRegister: Boolean, ) { - fun toData(): AccountEntity = + internal fun toData(): AccountEntity = AccountEntity( - authorization = authorization, - refreshToken = refreshToken, + token = TokenEntity( + accessToken = authorization, + refreshToken = refreshToken, + ), isRegister = isRegister, ) } diff --git a/app/src/main/java/com/into/websoso/data/remote/request/TokenReissueRequestDto.kt b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/TokenReissueRequestDto.kt similarity index 60% rename from app/src/main/java/com/into/websoso/data/remote/request/TokenReissueRequestDto.kt rename to core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/TokenReissueRequestDto.kt index 7c7254cb0..0561ac4ad 100644 --- a/app/src/main/java/com/into/websoso/data/remote/request/TokenReissueRequestDto.kt +++ b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/TokenReissueRequestDto.kt @@ -1,10 +1,10 @@ -package com.into.websoso.data.remote.request +package com.into.websoso.core.network.datasource.account.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class TokenReissueRequestDto( +internal data class TokenReissueRequestDto( @SerialName("refreshToken") val refreshToken: String, ) diff --git a/core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/TokenReissueResponseDto.kt b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/TokenReissueResponseDto.kt new file mode 100644 index 000000000..9b4159bcc --- /dev/null +++ b/core/network/src/main/java/com/into/websoso/core/network/datasource/account/model/TokenReissueResponseDto.kt @@ -0,0 +1,19 @@ +package com.into.websoso.core.network.datasource.account.model + +import com.into.websoso.data.account.model.TokenEntity +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class TokenReissueResponseDto( + @SerialName("Authorization") + val authorization: String, + @SerialName("refreshToken") + val refreshToken: String, +) { + internal fun toData(): TokenEntity = + TokenEntity( + accessToken = authorization, + refreshToken = refreshToken, + ) +} diff --git a/core/network/src/main/java/com/into/websoso/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/into/websoso/core/network/di/NetworkModule.kt index e499653db..a232550b9 100644 --- a/core/network/src/main/java/com/into/websoso/core/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/into/websoso/core/network/di/NetworkModule.kt @@ -1,27 +1,44 @@ package com.into.websoso.core.network.di import com.into.websoso.core.network.BuildConfig +import com.into.websoso.core.network.authenticator.AuthorizationAuthenticator +import com.into.websoso.core.network.interceptor.AuthorizationInterceptor import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json +import okhttp3.Dispatcher import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit +import java.util.concurrent.TimeUnit import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -object NetworkModule { +internal object NetworkModule { private const val BASE_URL = BuildConfig.BASE_URL + private const val CONNECT_TIME_LIMIT = 60L + private const val READ_TIME_LIMIT = 30L + private const val WRITE_TIME_LIMIT = 15L private const val CONTENT_TYPE = "application/json" + private val httpLoggingInterceptor: HttpLoggingInterceptor by lazy { + HttpLoggingInterceptor().apply { + if (BuildConfig.DEBUG) setLevel(HttpLoggingInterceptor.Level.BODY) + } + } + private val dispatcher: Dispatcher by lazy { + Dispatcher().apply { + maxRequestsPerHost = 20 + } + } @Provides @Singleton - fun provideRetrofit( + internal fun provideRetrofit( json: Json, client: OkHttpClient, ): Retrofit = @@ -38,12 +55,18 @@ object NetworkModule { @Provides @Singleton - internal fun provideOkHttpClient(): OkHttpClient = + internal fun provideOkHttpClient( + authorizationAuthenticator: AuthorizationAuthenticator, + authorizationInterceptor: AuthorizationInterceptor, + ): OkHttpClient = OkHttpClient .Builder() - .addInterceptor( - HttpLoggingInterceptor().apply { - if (BuildConfig.DEBUG) setLevel(HttpLoggingInterceptor.Level.BODY) - }, - ).build() + .dispatcher(dispatcher) + .addInterceptor(httpLoggingInterceptor) + .addInterceptor(authorizationInterceptor) + .authenticator(authorizationAuthenticator) + .connectTimeout(CONNECT_TIME_LIMIT, TimeUnit.SECONDS) + .readTimeout(READ_TIME_LIMIT, TimeUnit.SECONDS) + .writeTimeout(WRITE_TIME_LIMIT, TimeUnit.SECONDS) + .build() } diff --git a/core/network/src/main/java/com/into/websoso/core/network/interceptor/AuthorizationInterceptor.kt b/core/network/src/main/java/com/into/websoso/core/network/interceptor/AuthorizationInterceptor.kt new file mode 100644 index 000000000..42eca870c --- /dev/null +++ b/core/network/src/main/java/com/into/websoso/core/network/interceptor/AuthorizationInterceptor.kt @@ -0,0 +1,51 @@ +package com.into.websoso.core.network.interceptor + +import com.into.websoso.core.common.dispatchers.Dispatcher +import com.into.websoso.core.common.dispatchers.WebsosoDispatchers +import com.into.websoso.data.account.AccountRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +internal class AuthorizationInterceptor + @Inject + constructor( + private val accountRepository: Provider, + @Dispatcher(WebsosoDispatchers.IO) private val dispatcher: CoroutineDispatcher, + ) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (shouldSkipCondition(request)) return chain.proceed(request) + + val token = runBlocking(dispatcher) { accountRepository.get().accessToken() } + + if (token.isBlank()) return chain.proceed(request) + + val newRequest = request + .newBuilder() + .addHeader("Authorization", "Bearer $token") + .build() + + return chain.proceed(newRequest) + } + + private fun shouldSkipCondition(request: Request): Boolean = + EXCLUDED_PATHS.any { path -> + request.url.encodedPath.contains(path) + } + + companion object { + private val EXCLUDED_PATHS = listOf( + "auth/login/kakao", + "reissue", + "minimum-version", + ) + } + } diff --git a/data/account/src/main/java/com/into/websoso/data/account/AccountEntity.kt b/data/account/src/main/java/com/into/websoso/data/account/AccountEntity.kt deleted file mode 100644 index 7510ce030..000000000 --- a/data/account/src/main/java/com/into/websoso/data/account/AccountEntity.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.into.websoso.data.account - -data class AccountEntity( - val authorization: String, - val refreshToken: String, - val isRegister: Boolean, -) diff --git a/data/account/src/main/java/com/into/websoso/data/account/AccountRepository.kt b/data/account/src/main/java/com/into/websoso/data/account/AccountRepository.kt index 2c5a6d2a9..9c8c35283 100644 --- a/data/account/src/main/java/com/into/websoso/data/account/AccountRepository.kt +++ b/data/account/src/main/java/com/into/websoso/data/account/AccountRepository.kt @@ -2,20 +2,48 @@ package com.into.websoso.data.account import com.into.websoso.core.auth.AuthPlatform import com.into.websoso.core.auth.AuthToken +import com.into.websoso.data.account.datasource.AccountLocalDataSource +import com.into.websoso.data.account.datasource.AccountRemoteDataSource import javax.inject.Inject +import javax.inject.Singleton +// TODO: 인스턴스 싱글톤 참고하기 +@Singleton class AccountRepository @Inject constructor( private val accountRemoteDataSource: AccountRemoteDataSource, + private val accountLocalDataSource: AccountLocalDataSource, ) { + suspend fun accessToken(): String = accountLocalDataSource.accessToken() + + suspend fun refreshToken(): String = accountLocalDataSource.refreshToken() + suspend fun saveToken( platform: AuthPlatform, authToken: AuthToken, - ) { - accountRemoteDataSource.postLogin( + ): Boolean { + val account = accountRemoteDataSource.postLogin( platform = platform, authToken = authToken, ) + + accountLocalDataSource.saveAccessToken(account.token.accessToken) + accountLocalDataSource.saveRefreshToken(account.token.refreshToken) + + if (accountLocalDataSource.isAutoLogin().not()) { + accountLocalDataSource.saveIsAutoLogin(true) + } + + return account.isRegister + } + + suspend fun renewToken(): String { + val tokens = accountRemoteDataSource.postReissue(refreshToken = refreshToken()) + + accountLocalDataSource.saveAccessToken(tokens.accessToken) + accountLocalDataSource.saveRefreshToken(tokens.refreshToken) + + return tokens.accessToken } } diff --git a/data/account/src/main/java/com/into/websoso/data/account/datasource/AccountLocalDataSource.kt b/data/account/src/main/java/com/into/websoso/data/account/datasource/AccountLocalDataSource.kt new file mode 100644 index 000000000..584daa6b9 --- /dev/null +++ b/data/account/src/main/java/com/into/websoso/data/account/datasource/AccountLocalDataSource.kt @@ -0,0 +1,15 @@ +package com.into.websoso.data.account.datasource + +interface AccountLocalDataSource { + suspend fun accessToken(): String + + suspend fun refreshToken(): String + + suspend fun isAutoLogin(): Boolean + + suspend fun saveAccessToken(accessToken: String) + + suspend fun saveRefreshToken(refreshToken: String) + + suspend fun saveIsAutoLogin(isAutoLogin: Boolean) +} diff --git a/data/account/src/main/java/com/into/websoso/data/account/AccountRemoteDataSource.kt b/data/account/src/main/java/com/into/websoso/data/account/datasource/AccountRemoteDataSource.kt similarity index 51% rename from data/account/src/main/java/com/into/websoso/data/account/AccountRemoteDataSource.kt rename to data/account/src/main/java/com/into/websoso/data/account/datasource/AccountRemoteDataSource.kt index 14790f206..bc13f2444 100644 --- a/data/account/src/main/java/com/into/websoso/data/account/AccountRemoteDataSource.kt +++ b/data/account/src/main/java/com/into/websoso/data/account/datasource/AccountRemoteDataSource.kt @@ -1,11 +1,15 @@ -package com.into.websoso.data.account +package com.into.websoso.data.account.datasource import com.into.websoso.core.auth.AuthPlatform import com.into.websoso.core.auth.AuthToken +import com.into.websoso.data.account.model.AccountEntity +import com.into.websoso.data.account.model.TokenEntity interface AccountRemoteDataSource { suspend fun postLogin( platform: AuthPlatform, authToken: AuthToken, ): AccountEntity + + suspend fun postReissue(refreshToken: String): TokenEntity } diff --git a/data/account/src/main/java/com/into/websoso/data/account/model/AccountEntity.kt b/data/account/src/main/java/com/into/websoso/data/account/model/AccountEntity.kt new file mode 100644 index 000000000..b19d6ffa3 --- /dev/null +++ b/data/account/src/main/java/com/into/websoso/data/account/model/AccountEntity.kt @@ -0,0 +1,6 @@ +package com.into.websoso.data.account.model + +data class AccountEntity( + val token: TokenEntity, + val isRegister: Boolean, +) diff --git a/data/account/src/main/java/com/into/websoso/data/account/model/TokenEntity.kt b/data/account/src/main/java/com/into/websoso/data/account/model/TokenEntity.kt new file mode 100644 index 000000000..fafc884f0 --- /dev/null +++ b/data/account/src/main/java/com/into/websoso/data/account/model/TokenEntity.kt @@ -0,0 +1,6 @@ +package com.into.websoso.data.account.model + +data class TokenEntity( + val accessToken: String, + val refreshToken: String, +) diff --git a/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt b/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt index b82955222..be68a169a 100644 --- a/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt +++ b/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInScreen.kt @@ -18,8 +18,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.into.websoso.core.auth.AuthClient import com.into.websoso.core.auth.AuthPlatform import com.into.websoso.core.common.extensions.collectAsEventWithLifecycle +import com.into.websoso.core.common.navigator.NavigatorProvider import com.into.websoso.core.designsystem.theme.Gray50 import com.into.websoso.core.designsystem.theme.WebsosoTheme +import com.into.websoso.feature.signin.UiEffect.NavigateToHome +import com.into.websoso.feature.signin.UiEffect.NavigateToOnboarding import com.into.websoso.feature.signin.UiEffect.ScrollToPage import com.into.websoso.feature.signin.UiEffect.ShowToast import com.into.websoso.feature.signin.component.OnboardingDotsIndicator @@ -30,6 +33,7 @@ import com.into.websoso.feature.signin.component.SignInButtons @Composable fun SignInScreen( authClient: (platform: AuthPlatform) -> AuthClient, + websosoNavigator: NavigatorProvider, signInViewModel: SignInViewModel = hiltViewModel(), ) { val latestEvent by rememberUpdatedState(signInViewModel.uiEvent) @@ -37,15 +41,19 @@ fun SignInScreen( latestEvent.collectAsEventWithLifecycle { event -> when (event) { - is ScrollToPage -> { + ScrollToPage -> { pagerState.animateScrollToPage( page = (pagerState.currentPage + 1) % pagerState.pageCount, ) } - is ShowToast -> { + ShowToast -> { // TODO: 실패 시 커스텀 스낵 바 구현 } + + NavigateToHome -> websosoNavigator.navigateToMainActivity() + + NavigateToOnboarding -> websosoNavigator.navigateToOnboardingActivity() } } diff --git a/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInViewModel.kt b/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInViewModel.kt index dc11914ba..03b29bdef 100644 --- a/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInViewModel.kt +++ b/feature/signin/src/main/java/com/into/websoso/feature/signin/SignInViewModel.kt @@ -45,10 +45,19 @@ class SignInViewModel authToken: AuthToken, ) { viewModelScope.launch { - accountRepository.saveToken( - platform = platform, - authToken = authToken, - ) + runCatching { + accountRepository.saveToken( + platform = platform, + authToken = authToken, + ) + }.onSuccess { isRegister -> + when (isRegister) { + true -> _uiEvent.send(UiEffect.NavigateToHome) + false -> _uiEvent.send(UiEffect.NavigateToOnboarding) + } + }.onFailure { + signInWithFailure() + } } } @@ -76,4 +85,8 @@ sealed interface UiEffect { data object ScrollToPage : UiEffect data object ShowToast : UiEffect + + data object NavigateToHome : UiEffect + + data object NavigateToOnboarding : UiEffect }