diff --git a/Prezel/app/build.gradle.kts b/Prezel/app/build.gradle.kts index 6ac709f8..5237d05f 100644 --- a/Prezel/app/build.gradle.kts +++ b/Prezel/app/build.gradle.kts @@ -33,8 +33,10 @@ dependencies { implementation(projects.coreAuth) implementation(projects.coreData) implementation(projects.coreDesignsystem) + implementation(projects.coreDomain) implementation(projects.coreNavigation) implementation(projects.coreUi) + implementation(projects.coreCommon) implementation(projects.featureSplashApi) implementation(projects.featureSplashImpl) @@ -51,6 +53,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.navigation3.ui) + implementation(libs.orhanobut.logger) implementation(libs.timber) implementation(libs.kotlinx.collections.immutable) } diff --git a/Prezel/app/src/main/AndroidManifest.xml b/Prezel/app/src/main/AndroidManifest.xml index 3e52a766..246b5326 100644 --- a/Prezel/app/src/main/AndroidManifest.xml +++ b/Prezel/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:usesCleartextTraffic="true" android:theme="@style/Theme.PrezelSplashScreen"> .() -> Unit> @@ -28,12 +32,11 @@ class MainActivity : ComponentActivity() { setContent { PrezelTheme { - val appState = rememberPrezelAppState( - networkMonitor = networkMonitor, - ) + val appState = rememberPrezelAppState(networkMonitor = networkMonitor) PrezelApp( appState = appState, + globalEventBus = globalEventBus, entryBuilders = entryBuilders.toImmutableSet(), ) } diff --git a/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt b/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt index 4a7ff0bb..9a894668 100644 --- a/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt +++ b/Prezel/app/src/main/java/com/team/prezel/PrezelApplication.kt @@ -2,6 +2,7 @@ package com.team.prezel import android.app.Application import com.team.prezel.core.auth.AuthInitializer +import com.team.prezel.util.PrettyLoggerTree import dagger.hilt.android.HiltAndroidApp import timber.log.Timber @@ -9,10 +10,8 @@ import timber.log.Timber class PrezelApplication : Application() { override fun onCreate() { super.onCreate() - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) - } + Timber.plant(PrettyLoggerTree()) AuthInitializer.init(this) } } diff --git a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt index f4330a9b..8a419d76 100644 --- a/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt +++ b/Prezel/app/src/main/java/com/team/prezel/ui/PrezelApp.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -16,18 +17,23 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.ui.NavDisplay +import com.team.prezel.core.common.event.GlobalEvent +import com.team.prezel.core.common.event.GlobalEventBus import com.team.prezel.core.designsystem.component.PrezelNavigationScaffold +import com.team.prezel.core.designsystem.component.PrezelNavigationScope import com.team.prezel.core.navigation.LocalNavigator import com.team.prezel.core.navigation.Navigator import com.team.prezel.core.navigation.ProvideSharedTransitionScope import com.team.prezel.core.navigation.toEntries import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.splash.api.SplashNavKey import com.team.prezel.navigation.MAIN_NAV_ITEMS import kotlinx.collections.immutable.ImmutableSet @Composable fun PrezelApp( appState: PrezelAppState, + globalEventBus: GlobalEventBus, entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = remember(appState.navigationState) { Navigator(appState.navigationState) } @@ -41,6 +47,7 @@ fun PrezelApp( PrezelAppContent( appState = appState, + globalEventBus = globalEventBus, entryBuilders = entryBuilders, ) } @@ -49,11 +56,15 @@ fun PrezelApp( @Composable private fun PrezelAppContent( appState: PrezelAppState, + globalEventBus: GlobalEventBus, entryBuilders: ImmutableSet.() -> Unit>, ) { val navigator = LocalNavigator.current - val snackbarHostState = LocalSnackbarHostState.current - val showNavigationBar = appState.shouldShowNavigationBar + + ObserveGlobalEvents( + globalEventBus = globalEventBus, + navigateToSplash = { navigator.replaceRoot(SplashNavKey) }, + ) SharedTransitionLayout { ProvideSharedTransitionScope(this@SharedTransitionLayout) { @@ -64,18 +75,9 @@ private fun PrezelAppContent( } PrezelNavigationScaffold( - showNavigationBar = showNavigationBar, - snackbarHostState = snackbarHostState, - navigationItems = { - MAIN_NAV_ITEMS.forEach { (key, item) -> - Item( - selected = key == appState.navigationState.currentTopLevelKey, - onClick = { navigator.navigate(key) }, - label = stringResource(item.titleTextId), - iconResId = item.iconRes, - ) - } - }, + showNavigationBar = appState.shouldShowNavigationBar, + snackbarHostState = LocalSnackbarHostState.current, + navigationItems = { AppNavigationItems(appState = appState, navigateToKey = { key -> navigator.navigate(key) }) }, ) { padding -> NavDisplay( entries = appState.navigationState.toEntries(provider), @@ -98,3 +100,32 @@ private fun PrezelAppContent( } } } + +@Composable +private fun ObserveGlobalEvents( + globalEventBus: GlobalEventBus, + navigateToSplash: () -> Unit, +) { + LaunchedEffect(globalEventBus) { + globalEventBus.events.collect { event -> + when (event) { + GlobalEvent.ForceLogout -> navigateToSplash() + } + } + } +} + +@Composable +private fun PrezelNavigationScope.AppNavigationItems( + appState: PrezelAppState, + navigateToKey: (NavKey) -> Unit, +) { + MAIN_NAV_ITEMS.forEach { (key, item) -> + Item( + selected = key == appState.navigationState.currentTopLevelKey, + onClick = { navigateToKey(key) }, + label = stringResource(item.titleTextId), + iconResId = item.iconRes, + ) + } +} diff --git a/Prezel/app/src/main/java/com/team/prezel/util/PrettyLoggerTree.kt b/Prezel/app/src/main/java/com/team/prezel/util/PrettyLoggerTree.kt new file mode 100644 index 00000000..23929804 --- /dev/null +++ b/Prezel/app/src/main/java/com/team/prezel/util/PrettyLoggerTree.kt @@ -0,0 +1,31 @@ +package com.team.prezel.util + +import com.orhanobut.logger.AndroidLogAdapter +import com.orhanobut.logger.Logger +import com.orhanobut.logger.PrettyFormatStrategy +import timber.log.Timber + +internal class PrettyLoggerTree : Timber.DebugTree() { + init { + Logger.clearLogAdapters() + Logger.addLogAdapter( + AndroidLogAdapter( + PrettyFormatStrategy + .newBuilder() + .showThreadInfo(false) + .methodCount(0) + .tag("PREZEL") + .build(), + ), + ) + } + + override fun log( + priority: Int, + tag: String?, + message: String, + t: Throwable?, + ) { + Logger.log(priority, tag, message, t) + } +} diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthClient.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthClient.kt index 7cf5c259..38a61981 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthClient.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthClient.kt @@ -4,7 +4,11 @@ import android.content.Context import com.team.prezel.core.auth.model.AuthResult interface AuthClient { + suspend fun isLoggedIn(): Boolean + suspend fun login(context: Context): AuthResult suspend fun logout(): Result + + suspend fun unlink(): Result } diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt index 3e564130..b0b3e858 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/AuthManager.kt @@ -1,45 +1,23 @@ package com.team.prezel.core.auth import android.content.Context -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult import javax.inject.Inject import javax.inject.Singleton @Singleton -class AuthManager - @Inject - constructor( - private val authClients: Map, - ) { - var currentProvider: AuthProvider? = null - private set - - suspend fun login( - context: Context, - provider: AuthProvider, - ): AuthResult { - val authClient = authClients[provider] ?: return AuthResult.Failure.Unknown - val result = authClient.login(context = context) - - if (result is AuthResult.Success) { - currentProvider = provider - } - - return result - } - - suspend fun logout(): Result { - val provider = currentProvider ?: return Result.failure( - IllegalStateException("로그인된 AuthProvider가 없습니다."), - ) - - val authClient = authClients[provider] ?: return Result.failure( - IllegalStateException("해당 AuthProvider에 대한 AuthClient를 찾을 수 없습니다. provider=$provider"), - ) +class AuthManager @Inject constructor( + private val authClient: AuthClient, +) { + suspend fun login(context: Context): AuthResult = authClient.login(context = context) + + suspend fun logout(): Result { + if (!authClient.isLoggedIn()) return Result.success(Unit) + return authClient.logout() + } - return authClient.logout().onSuccess { - currentProvider = null - } - } + suspend fun unlink(): Result { + if (!authClient.isLoggedIn()) return Result.success(Unit) + return authClient.unlink() } +} diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt index 1ec4bc6e..11d10653 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/KakaoAuthClient.kt @@ -1,6 +1,7 @@ package com.team.prezel.core.auth import android.content.Context +import com.kakao.sdk.auth.AuthApiClient import com.kakao.sdk.auth.model.OAuthToken import com.kakao.sdk.common.model.AuthError import com.kakao.sdk.common.model.AuthErrorCause @@ -14,92 +15,129 @@ import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.resume -class KakaoAuthClient - @Inject - constructor() : AuthClient { - override suspend fun login(context: Context): AuthResult = - suspendCancellableCoroutine { continuation -> - if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { - loginWithKakaoTalk(context = context, continuation = continuation) - return@suspendCancellableCoroutine +class KakaoAuthClient @Inject constructor() : AuthClient { + override suspend fun isLoggedIn(): Boolean { + if (!AuthApiClient.instance.hasToken()) return false + + return suspendCancellableCoroutine { continuation -> + UserApiClient.instance.accessTokenInfo { _, error -> + if (continuation.isActive) { + continuation.resume(error == null) } + } + } + } - loginWithKakaoAccount(context = context, continuation = continuation) + override suspend fun login(context: Context): AuthResult = + suspendCancellableCoroutine { continuation -> + if (UserApiClient.instance.isKakaoTalkLoginAvailable(context)) { + loginWithKakaoTalk(context = context, continuation = continuation) + return@suspendCancellableCoroutine } - override suspend fun logout(): Result = - suspendCancellableCoroutine { continuation -> - Timber.d("카카오 로그아웃 시도") + loginWithKakaoAccount(context = context, continuation = continuation) + } - UserApiClient.instance.logout { error -> - if (error != null) { - Timber.e(error, "카카오 로그아웃에 실패했습니다.") - continuation.resume(Result.failure(error)) - return@logout - } + override suspend fun logout(): Result = + suspendCancellableCoroutine { continuation -> + Timber.d("카카오 로그아웃 시도") - Timber.d("카카오 로그아웃에 성공했습니다.") - continuation.resume(Result.success(Unit)) + UserApiClient.instance.logout { error -> + if (error != null) { + Timber.e(error, "카카오 로그아웃에 실패했습니다.") + continuation.resume(Result.failure(error)) + return@logout } - } - private fun loginWithKakaoTalk( - context: Context, - continuation: CancellableContinuation, - ) { - Timber.d("카카오톡으로 로그인 시도") - UserApiClient.instance.loginWithKakaoTalk( - context = context, - callback = continuation.loginCallback(loginType = "카카오톡"), - ) + Timber.d("카카오 로그아웃에 성공했습니다.") + continuation.resume(Result.success(Unit)) + } } - private fun loginWithKakaoAccount( - context: Context, - continuation: CancellableContinuation, - ) { - Timber.d("카카오 계정으로 로그인 시도") - UserApiClient.instance.loginWithKakaoAccount( - context = context, - callback = continuation.loginCallback(loginType = "카카오 계정"), - ) + override suspend fun unlink(): Result = + suspendCancellableCoroutine { continuation -> + Timber.d("카카오 회원탈퇴 시도") + + UserApiClient.instance.unlink { error -> + if (error != null) { + Timber.e(error, "카카오 회원탈퇴에 실패했습니다.") + continuation.resume(Result.failure(error)) + return@unlink + } + + Timber.d("카카오 회원탈퇴에 성공했습니다.") + continuation.resume(Result.success(Unit)) + } } - private fun CancellableContinuation.loginCallback(loginType: String): (OAuthToken?, Throwable?) -> Unit = - { token, error -> - when { - error != null -> { - val authResult = error.toAuthResult() - Timber.e(error, "$loginType 로그인에 실패했습니다. ($authResult)") - resume(authResult) + private fun loginWithKakaoTalk( + context: Context, + continuation: CancellableContinuation, + ) { + Timber.d("카카오톡으로 로그인 시도") + UserApiClient.instance.loginWithKakaoTalk( + context = context, + callback = continuation.loginCallback(loginType = "카카오톡"), + ) + } + + private fun loginWithKakaoAccount( + context: Context, + continuation: CancellableContinuation, + ) { + Timber.d("카카오 계정으로 로그인 시도") + UserApiClient.instance.loginWithKakaoAccount( + context = context, + callback = continuation.loginCallback(loginType = "카카오 계정"), + ) + } + + private fun CancellableContinuation.loginCallback(loginType: String): (OAuthToken?, Throwable?) -> Unit = + { token, error -> + when { + error != null -> { + val authResult = error.toAuthResult() + when (authResult) { + AuthResult.Cancelled -> Timber.i(error, "$loginType 로그인이 취소되었습니다.") + is AuthResult.Failure -> Timber.w(error, "$loginType 로그인에 실패했습니다.") + is AuthResult.Success -> Unit } + resume(authResult) + } - token != null -> { + token != null -> { + val idToken = token.idToken + if (idToken.isNullOrBlank()) { + val throwable = IllegalStateException("$loginType 로그인에 성공했지만 idToken이 비어있습니다.") + Timber.w(throwable) + resume(AuthResult.Failure(throwable)) + } else { Timber.d("$loginType 로그인에 성공했습니다.") - resume(AuthResult.Success) + resume(AuthResult.Success(idToken = idToken)) } + } - else -> { - Timber.e("$loginType 로그인 결과가 비어있습니다.") - resume(AuthResult.Failure.Unknown) - } + else -> { + val throwable = IllegalStateException("$loginType 로그인 결과가 비어있습니다.") + Timber.w(throwable) + resume(AuthResult.Failure(throwable)) } } - - private fun Throwable.toAuthResult(): AuthResult { - if (this is AuthError) return toAuthErrorResult() - if (this is ClientError) return toClientErrorResult() - return AuthResult.Failure.Unknown } - private fun AuthError.toAuthErrorResult(): AuthResult { - if (statusCode == 429) return AuthResult.Failure.RateLimited - if (reason == AuthErrorCause.AccessDenied) return AuthResult.Cancelled - return AuthResult.Failure.Unknown - } + private fun Throwable.toAuthResult(): AuthResult { + if (this is AuthError) return toAuthErrorResult() + if (this is ClientError) return toClientErrorResult() + return AuthResult.Failure(this) + } - private fun ClientError.toClientErrorResult(): AuthResult { - if (reason == ClientErrorCause.Cancelled) return AuthResult.Cancelled - return AuthResult.Failure.Unknown - } + private fun AuthError.toAuthErrorResult(): AuthResult { + if (reason == AuthErrorCause.AccessDenied) return AuthResult.Cancelled + return AuthResult.Failure(this) + } + + private fun ClientError.toClientErrorResult(): AuthResult { + if (reason == ClientErrorCause.Cancelled) return AuthResult.Cancelled + return AuthResult.Failure(this) } +} diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/di/AuthModule.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/di/AuthModule.kt index b25e5c44..a98fb108 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/di/AuthModule.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/di/AuthModule.kt @@ -2,19 +2,14 @@ package com.team.prezel.core.auth.di import com.team.prezel.core.auth.AuthClient import com.team.prezel.core.auth.KakaoAuthClient -import com.team.prezel.core.auth.model.AuthProvider -import com.team.prezel.core.auth.model.AuthProviderKey import dagger.Binds import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import dagger.multibindings.IntoMap @Module @InstallIn(SingletonComponent::class) abstract class AuthModule { @Binds - @IntoMap - @AuthProviderKey(AuthProvider.KAKAO) abstract fun bindKakaoAuthClient(kakaoAuthClient: KakaoAuthClient): AuthClient } diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProvider.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProvider.kt deleted file mode 100644 index e553c24d..00000000 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProvider.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.team.prezel.core.auth.model - -enum class AuthProvider { - KAKAO, -} diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProviderKey.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProviderKey.kt deleted file mode 100644 index 3076cfa0..00000000 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthProviderKey.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.team.prezel.core.auth.model - -import dagger.MapKey - -@MapKey -internal annotation class AuthProviderKey( - val value: AuthProvider, -) diff --git a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt index 7c608e7b..108167c3 100644 --- a/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt +++ b/Prezel/core/auth/src/main/java/com/team/prezel/core/auth/model/AuthResult.kt @@ -1,13 +1,13 @@ package com.team.prezel.core.auth.model sealed interface AuthResult { - data object Success : AuthResult + data class Success( + val idToken: String, + ) : AuthResult data object Cancelled : AuthResult - sealed interface Failure : AuthResult { - data object Unknown : Failure - - data object RateLimited : Failure - } + data class Failure( + val throwable: Throwable, + ) : AuthResult } diff --git a/Prezel/core/common/build.gradle.kts b/Prezel/core/common/build.gradle.kts new file mode 100644 index 00000000..b45fa124 --- /dev/null +++ b/Prezel/core/common/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.prezel.jvm.library) + alias(libs.plugins.prezel.hilt) +} + +dependencies { + implementation(libs.javax.inject) + implementation(libs.kotlinx.coroutines.core) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/CoroutineScopeModule.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/CoroutineScopesModule.kt similarity index 57% rename from Prezel/core/network/src/main/java/com/team/prezel/core/network/di/CoroutineScopeModule.kt rename to Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/CoroutineScopesModule.kt index 5319bfc9..ea553131 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/CoroutineScopeModule.kt +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/CoroutineScopesModule.kt @@ -1,13 +1,11 @@ -package com.team.prezel.core.network.di +package com.team.prezel.core.common.di -import com.team.prezel.core.network.Dispatcher -import com.team.prezel.core.network.PrezelDispatchers import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import javax.inject.Qualifier import javax.inject.Singleton @@ -22,7 +20,5 @@ internal object CoroutineScopesModule { @Provides @Singleton @ApplicationScope - fun providesCoroutineScope( - @Dispatcher(PrezelDispatchers.Default) dispatcher: CoroutineDispatcher, - ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + fun providesCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/GlobalEventModule.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/GlobalEventModule.kt new file mode 100644 index 00000000..da6ba518 --- /dev/null +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/di/GlobalEventModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.common.di + +import com.team.prezel.core.common.event.GlobalEventBus +import com.team.prezel.core.common.event.GlobalEventBusImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class GlobalEventModule { + @Binds + @Singleton + abstract fun bindsGlobalEventBus(globalEventBus: GlobalEventBusImpl): GlobalEventBus +} diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt new file mode 100644 index 00000000..65d4894a --- /dev/null +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEvent.kt @@ -0,0 +1,5 @@ +package com.team.prezel.core.common.event + +sealed interface GlobalEvent { + data object ForceLogout : GlobalEvent +} diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt new file mode 100644 index 00000000..a92e62cd --- /dev/null +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBus.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.common.event + +import kotlinx.coroutines.flow.Flow + +interface GlobalEventBus { + val events: Flow + + suspend fun emit(event: GlobalEvent) +} diff --git a/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt new file mode 100644 index 00000000..141317ea --- /dev/null +++ b/Prezel/core/common/src/main/kotlin/com/team/prezel/core/common/event/GlobalEventBusImpl.kt @@ -0,0 +1,22 @@ +package com.team.prezel.core.common.event + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GlobalEventBusImpl @Inject constructor() : GlobalEventBus { + private val _events = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val events: Flow = _events.asSharedFlow() + + override suspend fun emit(event: GlobalEvent) { + _events.emit(event) + } +} diff --git a/Prezel/core/common/src/test/kotlin/com/team/prezel/core/common/.gitkeep b/Prezel/core/common/src/test/kotlin/com/team/prezel/core/common/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/Prezel/core/data/build.gradle.kts b/Prezel/core/data/build.gradle.kts index b42a1270..210035fd 100644 --- a/Prezel/core/data/build.gradle.kts +++ b/Prezel/core/data/build.gradle.kts @@ -9,9 +9,11 @@ android { } dependencies { - implementation(projects.coreNetwork) - implementation(projects.coreModel) + implementation(projects.coreCommon) + implementation(projects.coreDatastore) implementation(projects.coreDomain) + implementation(projects.coreModel) + implementation(projects.coreNetwork) implementation(libs.kotlinx.coroutines.core) } diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/TokenProviderImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/TokenProviderImpl.kt new file mode 100644 index 00000000..7ebacc19 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/auth/TokenProviderImpl.kt @@ -0,0 +1,24 @@ +package com.team.prezel.core.data.auth + +import com.team.prezel.core.datastore.auth.AuthLocalDataSource +import com.team.prezel.core.model.auth.AuthTokens +import com.team.prezel.core.network.auth.TokenProvider +import kotlinx.coroutines.flow.firstOrNull +import javax.inject.Inject + +internal class TokenProviderImpl @Inject constructor( + private val dataSource: AuthLocalDataSource, +) : TokenProvider { + override suspend fun getTokens(): AuthTokens? = dataSource.tokens.firstOrNull() + + override suspend fun updateTokens( + accessToken: String, + refreshToken: String, + ) { + dataSource.saveTokens(accessToken = accessToken, refreshToken = refreshToken) + } + + override suspend fun clearTokens() { + dataSource.clearTokens() + } +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt new file mode 100644 index 00000000..dcce5276 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/AuthModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.data.di + +import com.team.prezel.core.data.auth.TokenProviderImpl +import com.team.prezel.core.network.auth.TokenProvider +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AuthModule { + @Binds + @Singleton + abstract fun bindsTokenProvider(tokenProvider: TokenProviderImpl): TokenProvider +} diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt index f76e3c20..b027abf2 100644 --- a/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.kt @@ -1,6 +1,8 @@ package com.team.prezel.core.data.di +import com.team.prezel.core.data.repository.AuthRepositoryImpl import com.team.prezel.core.data.repository.UserRepositoryImpl +import com.team.prezel.core.domain.repository.auth.AuthRepository import com.team.prezel.core.domain.repository.profile.UserRepository import dagger.Binds import dagger.Module @@ -11,6 +13,10 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) internal abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository + @Binds @Singleton abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository diff --git a/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt new file mode 100644 index 00000000..b7971164 --- /dev/null +++ b/Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/AuthRepositoryImpl.kt @@ -0,0 +1,69 @@ +package com.team.prezel.core.data.repository + +import com.team.prezel.core.common.di.ApplicationScope +import com.team.prezel.core.datastore.auth.AuthLocalDataSource +import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.model.auth.LoginStatus +import com.team.prezel.core.model.auth.WithdrawReason +import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +internal class AuthRepositoryImpl @Inject constructor( + private val authRemoteDataSource: AuthRemoteDataSource, + private val authLocalDataSource: AuthLocalDataSource, + @param:ApplicationScope private val externalScope: CoroutineScope, +) : AuthRepository { + override val loginStatus: StateFlow = + authLocalDataSource.tokens + .map { tokens -> if (tokens == null) LoginStatus.UNAUTHENTICATED else LoginStatus.AUTHENTICATED } + .stateIn( + scope = externalScope, + started = SharingStarted.Eagerly, + initialValue = LoginStatus.LOADING, + ) + + override suspend fun logout(): Result = + runCatching { + authRemoteDataSource.logout() + authLocalDataSource.clearTokens() + } + + override suspend fun login(idToken: String): Result = + runCatching { + val response = authRemoteDataSource.login(idToken = idToken) + authLocalDataSource.saveTokens( + accessToken = response.accessToken, + refreshToken = response.refreshToken, + ) + } + + override suspend fun withdraw(reason: WithdrawReason): Result = + runCatching { + authRemoteDataSource.withdraw( + reasonCategory = reason.toCategory(), + reasonText = reason.toReasonText(), + ) + authLocalDataSource.clearTokens() + } + + private fun WithdrawReason.toCategory(): String = + when (this) { + WithdrawReason.NotUsedOften -> "NOT_USED_OFTEN" + WithdrawReason.NoLongerNeeded -> "NO_LONGER_NEEDED" + WithdrawReason.TooDifficultOrComplex -> "TOO_COMPLEX" + WithdrawReason.AnalysisResultInaccurate -> "INACCURATE_ANALYSIS" + WithdrawReason.TooManyErrors -> "MANY_ERRORS" + is WithdrawReason.Other -> "ETC" + } + + private fun WithdrawReason.toReasonText(): String = + when (this) { + is WithdrawReason.Other -> text + else -> "" + } +} diff --git a/Prezel/core/datastore/build.gradle.kts b/Prezel/core/datastore/build.gradle.kts new file mode 100644 index 00000000..e9e38d8c --- /dev/null +++ b/Prezel/core/datastore/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + alias(libs.plugins.prezel.android.library) + alias(libs.plugins.prezel.hilt) +} + +android { + namespace = "com.team.prezel.core.datastore" + testOptions.unitTests.isIncludeAndroidResources = true +} + +dependencies { + implementation(projects.coreCommon) + implementation(projects.coreModel) + + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) +} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt new file mode 100644 index 00000000..48dd2cc7 --- /dev/null +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSource.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.datastore.auth + +import com.team.prezel.core.model.auth.AuthTokens +import kotlinx.coroutines.flow.Flow + +interface AuthLocalDataSource { + val tokens: Flow + + suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) + + suspend fun clearTokens() +} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt new file mode 100644 index 00000000..9f0e80b0 --- /dev/null +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/auth/AuthLocalDataSourceImpl.kt @@ -0,0 +1,63 @@ +package com.team.prezel.core.datastore.auth + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import com.team.prezel.core.model.auth.AuthTokens +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class AuthLocalDataSourceImpl @Inject constructor( + private val dataStore: DataStore, +) : AuthLocalDataSource { + override val tokens: Flow + get() = dataStore.data + .catch { throwable -> + if (throwable is IOException) { + emit(emptyPreferences()) + } else { + throw throwable + } + }.map { preferences -> + val accessToken = preferences[ACCESS_TOKEN_KEY] + val refreshToken = preferences[REFRESH_TOKEN_KEY] + + if (accessToken.isNullOrBlank() || refreshToken.isNullOrBlank()) { + null + } else { + AuthTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + } + } + + override suspend fun saveTokens( + accessToken: String, + refreshToken: String, + ) { + dataStore.edit { preferences -> + preferences[ACCESS_TOKEN_KEY] = accessToken + preferences[REFRESH_TOKEN_KEY] = refreshToken + } + } + + override suspend fun clearTokens() { + dataStore.edit { preferences -> + preferences.remove(ACCESS_TOKEN_KEY) + preferences.remove(REFRESH_TOKEN_KEY) + } + } + + private companion object { + val ACCESS_TOKEN_KEY = stringPreferencesKey("auth_access_token") + val REFRESH_TOKEN_KEY = stringPreferencesKey("auth_refresh_token") + } +} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataSourceModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataSourceModule.kt new file mode 100644 index 00000000..a9cc8340 --- /dev/null +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataSourceModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.datastore.di + +import com.team.prezel.core.datastore.auth.AuthLocalDataSource +import com.team.prezel.core.datastore.auth.AuthLocalDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class DataSourceModule { + @Binds + @Singleton + abstract fun bindAuthLocalDataSource(impl: AuthLocalDataSourceImpl): AuthLocalDataSource +} diff --git a/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt new file mode 100644 index 00000000..934d226e --- /dev/null +++ b/Prezel/core/datastore/src/main/java/com/team/prezel/core/datastore/di/DataStoreModule.kt @@ -0,0 +1,32 @@ +package com.team.prezel.core.datastore.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStoreFile +import com.team.prezel.core.common.di.ApplicationScope +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object DataStoreModule { + private const val PREFERENCES_NAME = "auth_token_preferences" + + @Provides + @Singleton + fun providePreferencesDataStore( + @ApplicationContext context: Context, + @ApplicationScope scope: CoroutineScope, + ): DataStore = + PreferenceDataStoreFactory.create( + scope = scope, + produceFile = { context.preferencesDataStoreFile(PREFERENCES_NAME) }, + ) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt new file mode 100644 index 00000000..64b8efdd --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/auth/AuthRepository.kt @@ -0,0 +1,15 @@ +package com.team.prezel.core.domain.repository.auth + +import com.team.prezel.core.model.auth.LoginStatus +import com.team.prezel.core.model.auth.WithdrawReason +import kotlinx.coroutines.flow.Flow + +interface AuthRepository { + val loginStatus: Flow + + suspend fun logout(): Result + + suspend fun login(idToken: String): Result + + suspend fun withdraw(reason: WithdrawReason): Result +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt new file mode 100644 index 00000000..c68009a6 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/CheckLoginStatusUseCase.kt @@ -0,0 +1,21 @@ +package com.team.prezel.core.domain.usecase.auth + +import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.model.auth.LoginStatus +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * 저장된 인증 토큰을 기반으로 현재 로그인 상태를 확인하는 UseCase. + * + * ### 동작 흐름 + * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.loginStatus]를 구독합니다. + * 2. 저장된 토큰을 기반으로 로그인 상태 판별은 repository 내부에서 처리합니다. + * 3. 판별 결과를 [Flow] 형태로 반환합니다. + * + */ +class CheckLoginStatusUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + operator fun invoke(): Flow = authRepository.loginStatus +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt new file mode 100644 index 00000000..77ba85e4 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LoginUseCase.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.domain.usecase.auth + +import com.team.prezel.core.domain.repository.auth.AuthRepository +import javax.inject.Inject + +/** + * 소셜 로그인에 사용되는 ID 토큰으로 서버 로그인을 수행하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 ID 토큰을 입력값으로 받습니다. + * 2. [com.team.prezel.core.domain.repository.auth.AuthRepository.login]을 호출하여 서버 로그인 요청을 수행합니다. + * 3. 로그인 결과에 따라 성공 여부 또는 예외를 포함한 [Result]를 반환합니다. + * + */ +class LoginUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(idToken: String): Result = authRepository.login(idToken = idToken) +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt new file mode 100644 index 00000000..7c1545d6 --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/LogoutUseCase.kt @@ -0,0 +1,18 @@ +package com.team.prezel.core.domain.usecase.auth + +import com.team.prezel.core.domain.repository.auth.AuthRepository +import javax.inject.Inject + +/** + * 현재 로그인 세션의 로그아웃을 요청하는 UseCase. + * + * ### 동작 흐름 + * 1. [com.team.prezel.core.domain.repository.auth.AuthRepository.logout]을 호출합니다. + * 2. repository가 저장된 토큰 조회와 서버 로그아웃 요청을 처리합니다. + * 3. 결과를 [Result]로 반환합니다. + */ +class LogoutUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(): Result = authRepository.logout() +} diff --git a/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt new file mode 100644 index 00000000..b21e139b --- /dev/null +++ b/Prezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/auth/WithdrawUseCase.kt @@ -0,0 +1,19 @@ +package com.team.prezel.core.domain.usecase.auth + +import com.team.prezel.core.domain.repository.auth.AuthRepository +import com.team.prezel.core.model.auth.WithdrawReason +import javax.inject.Inject + +/** + * 회원 탈퇴 사유와 함께 탈퇴 요청을 수행하는 UseCase. + * + * ### 동작 흐름 + * 1. 호출부로부터 전달받은 [WithdrawReason]으로 [com.team.prezel.core.domain.repository.auth.AuthRepository.withdraw]를 호출합니다. + * 2. repository가 저장된 토큰 조회와 서버 회원 탈퇴 요청을 처리합니다. + * 3. 결과를 [Result]로 반환합니다. + */ +class WithdrawUseCase @Inject constructor( + private val authRepository: AuthRepository, +) { + suspend operator fun invoke(reason: WithdrawReason): Result = authRepository.withdraw(reason = reason) +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthTokens.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthTokens.kt new file mode 100644 index 00000000..72d69b97 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/AuthTokens.kt @@ -0,0 +1,6 @@ +package com.team.prezel.core.model.auth + +data class AuthTokens( + val accessToken: String, + val refreshToken: String, +) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt new file mode 100644 index 00000000..4a7d0db5 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/LoginStatus.kt @@ -0,0 +1,7 @@ +package com.team.prezel.core.model.auth + +enum class LoginStatus { + LOADING, + AUTHENTICATED, + UNAUTHENTICATED, +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt new file mode 100644 index 00000000..0505c618 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/auth/WithdrawReason.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.model.auth + +sealed interface WithdrawReason { + data object NotUsedOften : WithdrawReason + + data object NoLongerNeeded : WithdrawReason + + data object TooDifficultOrComplex : WithdrawReason + + data object AnalysisResultInaccurate : WithdrawReason + + data object TooManyErrors : WithdrawReason + + data class Other( + val text: String, + ) : WithdrawReason +} diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ApiException.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ApiException.kt new file mode 100644 index 00000000..620f20e5 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ApiException.kt @@ -0,0 +1,7 @@ +package com.team.prezel.core.model.base + +class ApiException( + val status: Int, + val errorCode: ServerErrorCode, + override val message: String, +) : Exception(message) diff --git a/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt new file mode 100644 index 00000000..49cb56b8 --- /dev/null +++ b/Prezel/core/model/src/main/java/com/team/prezel/core/model/base/ServerErrorCode.kt @@ -0,0 +1,30 @@ +package com.team.prezel.core.model.base + +enum class ServerErrorCode( + val code: String, +) { + INVALID_REQUEST("C001"), + SERVER_ERROR("C002"), + + UNAUTHORIZED("U001"), + FORBIDDEN("U002"), + USER_NOT_FOUND("U003"), + DUPLICATE_NICKNAME("U004"), + + INVALID_TOKEN("T001"), + TOKEN_STOLEN("T002"), + INVALID_ID_TOKEN("T003"), + + TERMS_NOT_FOUND("TR001"), + REQUIRED_TERMS_DISAGREED("TR002"), + + FILE_IS_EMPTY("F001"), + FILE_UPLOAD_FAILED("F002"), + + UNKNOWN("UNKNOWN"), + ; + + companion object { + fun from(code: String?): ServerErrorCode = entries.firstOrNull { it.code == code } ?: UNKNOWN + } +} diff --git a/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/NavigationState.kt b/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/NavigationState.kt index abe707f1..fe306753 100644 --- a/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/NavigationState.kt +++ b/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/NavigationState.kt @@ -13,7 +13,6 @@ import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.rememberDecoratedNavEntries import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import com.team.prezel.core.navigation.decorator.LoggingDecorator import kotlinx.collections.immutable.ImmutableSet /** @@ -84,7 +83,6 @@ fun NavigationState.toEntries(entryProvider: (NavKey) -> NavEntry): Snap val decorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), - LoggingDecorator(), ) rememberDecoratedNavEntries( diff --git a/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/decorator/LoggingDecorator.kt b/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/decorator/LoggingDecorator.kt deleted file mode 100644 index 19369194..00000000 --- a/Prezel/core/navigation/src/main/java/com/team/prezel/core/navigation/decorator/LoggingDecorator.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.team.prezel.core.navigation.decorator - -import androidx.compose.runtime.DisposableEffect -import androidx.navigation3.runtime.NavEntryDecorator -import timber.log.Timber - -private const val NAVIGATION_LOGGING_TAG = "Navigation" - -class LoggingDecorator : - NavEntryDecorator( - decorate = { entry -> - DisposableEffect(entry.contentKey, entry.metadata) { - Timber - .tag(NAVIGATION_LOGGING_TAG) - .d("SCREEN_ENTER | screen=${entry.contentKey} | metadata=${entry.metadata}") - - onDispose { - Timber - .tag(NAVIGATION_LOGGING_TAG) - .d(message = "SCREEN_EXIT | screen=${entry.contentKey} | metadata=${entry.metadata}") - } - } - - entry.Content() - }, - ) diff --git a/Prezel/core/network/build.gradle.kts b/Prezel/core/network/build.gradle.kts index efb6ce21..bb9ec2b4 100644 --- a/Prezel/core/network/build.gradle.kts +++ b/Prezel/core/network/build.gradle.kts @@ -17,18 +17,22 @@ android { } dependencies { + implementation(projects.coreCommon) + implementation(projects.coreModel) + implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.auth) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.logging) implementation(libs.kotlinx.serialization.json) implementation(libs.timber) - implementation(libs.ktorfit.lib) ksp(libs.ktorfit.ksp) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.ktor.client.mock) } androidComponents { diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt deleted file mode 100644 index 5569c137..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/ApiResponseConverterFactory.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.team.prezel.core.network - -import com.team.prezel.core.network.model.ApiResponse -import de.jensklingenberg.ktorfit.Ktorfit -import de.jensklingenberg.ktorfit.converter.Converter -import de.jensklingenberg.ktorfit.converter.KtorfitResult -import de.jensklingenberg.ktorfit.converter.TypeData -import io.ktor.client.call.body -import io.ktor.client.plugins.ResponseException -import io.ktor.client.statement.HttpResponse -import io.ktor.util.reflect.TypeInfo -import timber.log.Timber -import java.io.IOException -import kotlin.coroutines.cancellation.CancellationException - -class ApiResponseConverterFactory : Converter.Factory { - override fun suspendResponseConverter( - typeData: TypeData, - ktorfit: Ktorfit, - ): Converter.SuspendResponseConverter? { - if (typeData.typeInfo.type != ApiResponse::class) return null - val bodyTypeInfo = typeData.typeArgs.firstOrNull()?.typeInfo ?: return null - - return object : Converter.SuspendResponseConverter> { - override suspend fun convert(result: KtorfitResult): ApiResponse = - when (result) { - is KtorfitResult.Success -> parseSuccess(result.response, bodyTypeInfo) - is KtorfitResult.Failure -> mapFailure(result.throwable) - } - } - } - - private suspend fun parseSuccess( - response: HttpResponse, - bodyTypeInfo: TypeInfo, - ): ApiResponse = - try { - val body = response.body(bodyTypeInfo) - ApiResponse.Success(body) - } catch (t: Throwable) { - t.rethrowIfCancellation() - Timber.e(t, "Response parsing failed") - ApiResponse.Failure.NetworkError(t) - } - - private fun mapFailure(t: Throwable): ApiResponse { - t.rethrowIfCancellation() - - return when (t) { - is IOException -> { - Timber.e(t, "Network error") - ApiResponse.Failure.NetworkError(t) - } - - is ResponseException -> { - Timber.e(t, "HTTP error ${t.response.status.value}") - ApiResponse.Failure.HttpError(t) - } - - else -> { - Timber.e(t, "Unknown error") - ApiResponse.Failure.NetworkError(t) - } - } - } - - private fun Throwable.rethrowIfCancellation() { - if (this is CancellationException) throw this - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthRequestAttributes.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthRequestAttributes.kt new file mode 100644 index 00000000..b6cf8cfa --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/AuthRequestAttributes.kt @@ -0,0 +1,9 @@ +package com.team.prezel.core.network.auth + +import io.ktor.util.AttributeKey + +internal object AuthRequestAttributes { + const val SKIP_AUTH = "skipAuth" + + val SkipAuthKey = AttributeKey(SKIP_AUTH) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt new file mode 100644 index 00000000..cfff3460 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/auth/TokenProvider.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.network.auth + +import com.team.prezel.core.model.auth.AuthTokens + +interface TokenProvider { + suspend fun getTokens(): AuthTokens? + + suspend fun updateTokens( + accessToken: String, + refreshToken: String, + ) + + suspend fun clearTokens() +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt new file mode 100644 index 00000000..8778808a --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/HttpClientFactory.kt @@ -0,0 +1,144 @@ +package com.team.prezel.core.network.client + +import android.os.Build +import com.team.prezel.core.common.event.GlobalEvent +import com.team.prezel.core.common.event.GlobalEventBus +import com.team.prezel.core.model.auth.AuthTokens +import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.auth.AuthRequestAttributes +import com.team.prezel.core.network.auth.TokenProvider +import com.team.prezel.core.network.model.auth.reissue.ReissueRequest +import com.team.prezel.core.network.model.auth.reissue.ReissueResponse +import com.team.prezel.core.network.model.requireData +import com.team.prezel.core.network.service.AuthService +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.auth.Auth +import io.ktor.client.plugins.auth.clearAuthTokens +import io.ktor.client.plugins.auth.providers.BearerTokens +import io.ktor.client.plugins.auth.providers.RefreshTokensParams +import io.ktor.client.plugins.auth.providers.bearer +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.logging.LogLevel +import io.ktor.client.plugins.logging.Logging +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +internal class HttpClientFactory @Inject constructor( + private val tokenProvider: TokenProvider, + private val authServiceProvider: Provider, + private val globalEventBus: GlobalEventBus, +) { + private val networkJson: Json = + Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = true + } + + fun create( + block: HttpClientConfig<*>.() -> Unit = { + configureDefaultRequest() + installContentNegotiation() + installUserAgent() + installLogging() + installAuth() + }, + ): HttpClient = HttpClient(OkHttp) { block() } + + internal fun HttpClientConfig<*>.configureDefaultRequest() { + defaultRequest { + contentType(ContentType.Application.Json) + } + } + + internal fun HttpClientConfig<*>.installContentNegotiation() { + install(ContentNegotiation) { + json(networkJson) + } + } + + internal fun HttpClientConfig<*>.installUserAgent() { + install(UserAgent) { + agent = buildUserAgent() + } + } + + internal fun HttpClientConfig<*>.installLogging() { + install(Logging) { + logger = KtorPrettyLogger + sanitizeHeader { header -> header == HttpHeaders.Authorization } + level = if (BuildConfig.DEBUG) LogLevel.ALL else LogLevel.NONE + } + } + + internal fun HttpClientConfig<*>.installAuth() { + install(Auth) { + bearer { + cacheTokens = true + + loadTokens { tokenProvider.getTokens()?.toBearerTokens() } + + refreshTokens { refreshBearerTokens() } + + sendWithoutRequest { request -> + request.attributes.getOrNull(AuthRequestAttributes.SkipAuthKey) != true + } + } + } + } + + private suspend fun RefreshTokensParams.refreshBearerTokens(): BearerTokens? { + val refreshToken = oldTokens?.refreshToken ?: return null + + return try { + authServiceProvider + .get() + .reissue(request = ReissueRequest(refreshToken)) + .requireData() + .let { response -> + tokenProvider.updateTokens(accessToken = response.accessToken, refreshToken = response.refreshToken) + client.clearAuthTokens() + response.toBearerTokens() + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + tokenProvider.clearTokens() + client.clearAuthTokens() + globalEventBus.emit(GlobalEvent.ForceLogout) + null + } + } + + private fun AuthTokens.toBearerTokens(): BearerTokens = + BearerTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + + private fun ReissueResponse.toBearerTokens(): BearerTokens = + BearerTokens( + accessToken = accessToken, + refreshToken = refreshToken, + ) + + private fun buildUserAgent(): String = + buildString { + append("Prezel-Android/${BuildConfig.BUILD_TYPE} ") + append("(Android ${Build.VERSION.RELEASE}; ") + append("SDK ${Build.VERSION.SDK_INT}; ") + append("${Build.MANUFACTURER} ${Build.MODEL})") + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt new file mode 100644 index 00000000..0312cdbb --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/client/KtorPrettyLogger.kt @@ -0,0 +1,98 @@ +package com.team.prezel.core.network.client + +import io.ktor.client.plugins.logging.Logger +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import timber.log.Timber + +@OptIn(ExperimentalSerializationApi::class) +internal object KtorPrettyLogger : Logger { + private const val TAG = "NETWORK" + private const val REQUEST = "REQUEST:" + private const val RESPONSE = "RESPONSE:" + private const val REQUEST_EMOJI = "🚀" + private const val RESPONSE_EMOJI = "📥" + private const val COMMON_HEADERS = "COMMON HEADERS" + private const val CONTENT_HEADERS = "CONTENT HEADERS" + private const val BODY_START = "BODY START" + private const val BODY_END = "BODY END" + + private val json = Json { + prettyPrint = true + prettyPrintIndent = "\t" + isLenient = true + } + + override fun log(message: String) { + Timber.tag(TAG).d( + message + .prettifyBodyJson() + .formatSections(), + ) + } + + private fun String.prettifyBodyJson(): String { + val original = this + + val bodyStartIndex = original.indexOf(BODY_START) + if (bodyStartIndex == -1) return original + + val bodyEndIndex = original.indexOf(BODY_END, startIndex = bodyStartIndex) + if (bodyEndIndex == -1) return original + + val body = original + .substring( + startIndex = bodyStartIndex + BODY_START.length, + endIndex = bodyEndIndex, + ).trim() + + val prettyBody = body.parsePrettyJson() ?: body + + return buildString { + append(original.substring(startIndex = 0, endIndex = bodyStartIndex)) + appendLine(BODY_START) + appendLine(prettyBody) + append(BODY_END) + append(original.substring(startIndex = bodyEndIndex + BODY_END.length)) + } + } + + private fun String.formatSections(): String { + if (!isKtorHttpLog()) return this + + return buildList { + lines().forEachIndexed { index, line -> + when { + index != 0 && line.isSkipMarker() -> Unit + index != 0 && line.isBlankLineMarker() -> add("") + else -> add(line.withEmojiHeader()) + } + } + }.joinToString(separator = "\n") + } + + private fun String.isKtorHttpLog(): Boolean = contains(REQUEST) || contains(RESPONSE) + + private fun String.isSkipMarker(): Boolean = startsWith(COMMON_HEADERS) || startsWith(CONTENT_HEADERS) || startsWith(BODY_END) + + private fun String.isBlankLineMarker(): Boolean = startsWith(BODY_START) + + private fun String.withEmojiHeader(): String = + when { + startsWith(REQUEST) -> replaceFirst(REQUEST, "$REQUEST_EMOJI $REQUEST") + startsWith(RESPONSE) -> replaceFirst(RESPONSE, "$RESPONSE_EMOJI $RESPONSE") + else -> this + } + + private fun String.parsePrettyJson(): String? { + val candidate = trim() + if (!candidate.looksLikeJson()) return null + + return runCatching { + val element = json.parseToJsonElement(candidate) + json.encodeToString(element) + }.getOrNull() + } + + private fun String.looksLikeJson(): Boolean = (startsWith("{") && endsWith("}")) || (startsWith("[") && endsWith("]")) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt new file mode 100644 index 00000000..a6495118 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSource.kt @@ -0,0 +1,14 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.auth.LoginResponse + +interface AuthRemoteDataSource { + suspend fun logout() + + suspend fun login(idToken: String): LoginResponse + + suspend fun withdraw( + reasonCategory: String, + reasonText: String, + ) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt new file mode 100644 index 00000000..abdd00fe --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/datasource/AuthRemoteDataSourceImpl.kt @@ -0,0 +1,29 @@ +package com.team.prezel.core.network.datasource + +import com.team.prezel.core.network.model.auth.LoginRequest +import com.team.prezel.core.network.model.auth.LoginResponse +import com.team.prezel.core.network.model.auth.WithdrawRequest +import com.team.prezel.core.network.model.requireData +import com.team.prezel.core.network.model.requireSuccess +import com.team.prezel.core.network.service.AuthService +import javax.inject.Inject + +internal class AuthRemoteDataSourceImpl @Inject constructor( + private val authService: AuthService, +) : AuthRemoteDataSource { + override suspend fun logout() { + authService.logout().requireSuccess() + } + + override suspend fun login(idToken: String): LoginResponse = authService.login(request = LoginRequest(idToken = idToken)).requireData() + + override suspend fun withdraw( + reasonCategory: String, + reasonText: String, + ) { + authService + .withdraw( + request = WithdrawRequest(reasonCategory = reasonCategory, reasonText = reasonText), + ).requireSuccess() + } +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt new file mode 100644 index 00000000..573e58bd --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/DataSourceModule.kt @@ -0,0 +1,17 @@ +package com.team.prezel.core.network.di + +import com.team.prezel.core.network.datasource.AuthRemoteDataSource +import com.team.prezel.core.network.datasource.AuthRemoteDataSourceImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class DataSourceModule { + @Binds + @Singleton + abstract fun bindAuthRemoteDataSource(impl: AuthRemoteDataSourceImpl): AuthRemoteDataSource +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt index a77a1357..48aebadd 100644 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/di/NetworkModule.kt @@ -1,24 +1,15 @@ package com.team.prezel.core.network.di -import com.team.prezel.core.network.ApiResponseConverterFactory import com.team.prezel.core.network.BuildConfig +import com.team.prezel.core.network.client.HttpClientFactory +import com.team.prezel.core.network.service.AuthService +import com.team.prezel.core.network.service.createAuthService import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import de.jensklingenberg.ktorfit.Ktorfit import io.ktor.client.HttpClient -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.plugins.logging.LogLevel -import io.ktor.client.plugins.logging.Logger -import io.ktor.client.plugins.logging.Logging -import io.ktor.http.ContentType -import io.ktor.http.contentType -import io.ktor.serialization.kotlinx.json.json -import kotlinx.serialization.json.Json -import timber.log.Timber import javax.inject.Singleton @Module @@ -26,37 +17,7 @@ import javax.inject.Singleton object NetworkModule { @Provides @Singleton - fun provideJson(): Json = - Json { - ignoreUnknownKeys = true - coerceInputValues = true - encodeDefaults = true - prettyPrint = false - } - - @Provides - @Singleton - fun provideHttpClient(json: Json): HttpClient = - HttpClient(OkHttp) { - expectSuccess = true - - install(ContentNegotiation) { - json(json) - } - - install(Logging) { - logger = object : Logger { - override fun log(message: String) { - Timber.tag("KtorClient").d(message) - } - } - level = if (BuildConfig.DEBUG) LogLevel.BODY else LogLevel.NONE - } - - defaultRequest { - contentType(ContentType.Application.Json) - } - } + internal fun provideHttpClient(factory: HttpClientFactory): HttpClient = factory.create() @Provides @Singleton @@ -65,6 +26,9 @@ object NetworkModule { .Builder() .baseUrl(BuildConfig.BASE_URL) .httpClient(httpClient) - .converterFactories(ApiResponseConverterFactory()) .build() + + @Provides + @Singleton + internal fun provideAuthService(ktorfit: Ktorfit): AuthService = ktorfit.createAuthService() } diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt deleted file mode 100644 index 83d44a66..00000000 --- a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/ApiResponse.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.team.prezel.core.network.model - -sealed interface ApiResponse { - data class Success( - val data: T, - ) : ApiResponse - - sealed interface Failure : ApiResponse { - data class HttpError( - val throwable: Throwable, - ) : Failure - - data class NetworkError( - val throwable: Throwable, - ) : Failure - } -} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt new file mode 100644 index 00000000..86b6b403 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/BaseResponse.kt @@ -0,0 +1,38 @@ +package com.team.prezel.core.network.model + +import com.team.prezel.core.model.base.ApiException +import com.team.prezel.core.model.base.ServerErrorCode +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BaseResponse( + @SerialName("status") + val status: Int, + @SerialName("code") + val code: String?, + @SerialName("data") + val data: T?, + @SerialName("message") + val message: String?, +) + +internal fun BaseResponse.requireData(): T { + requireSuccess() + + return data ?: throw ApiException( + status = status, + errorCode = ServerErrorCode.UNKNOWN, + message = "Response data is null", + ) +} + +internal fun BaseResponse<*>.requireSuccess() { + if (status in 200..299) return + + throw ApiException( + status = status, + errorCode = ServerErrorCode.from(code), + message = message.orEmpty(), + ) +} diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginRequest.kt new file mode 100644 index 00000000..f403f9da --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginRequest.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.network.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +internal data class LoginRequest( + @SerialName("idToken") + val idToken: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginResponse.kt new file mode 100644 index 00000000..bd6c07b1 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/LoginResponse.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LoginResponse( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt new file mode 100644 index 00000000..4f6fb74a --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/WithdrawRequest.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.model.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class WithdrawRequest( + @SerialName("reasonCategory") + val reasonCategory: String, + @SerialName("reasonText") + val reasonText: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt new file mode 100644 index 00000000..06d29cbe --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueRequest.kt @@ -0,0 +1,10 @@ +package com.team.prezel.core.network.model.auth.reissue + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReissueRequest( + @SerialName("refresh-token") + val refreshToken: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueResponse.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueResponse.kt new file mode 100644 index 00000000..dda88411 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/model/auth/reissue/ReissueResponse.kt @@ -0,0 +1,12 @@ +package com.team.prezel.core.network.model.auth.reissue + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ReissueResponse( + @SerialName("accessToken") + val accessToken: String, + @SerialName("refreshToken") + val refreshToken: String, +) diff --git a/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt new file mode 100644 index 00000000..b5eab043 --- /dev/null +++ b/Prezel/core/network/src/main/java/com/team/prezel/core/network/service/AuthService.kt @@ -0,0 +1,35 @@ +package com.team.prezel.core.network.service + +import com.team.prezel.core.network.auth.AuthRequestAttributes +import com.team.prezel.core.network.model.BaseResponse +import com.team.prezel.core.network.model.auth.LoginRequest +import com.team.prezel.core.network.model.auth.LoginResponse +import com.team.prezel.core.network.model.auth.WithdrawRequest +import com.team.prezel.core.network.model.auth.reissue.ReissueRequest +import com.team.prezel.core.network.model.auth.reissue.ReissueResponse +import de.jensklingenberg.ktorfit.http.Body +import de.jensklingenberg.ktorfit.http.DELETE +import de.jensklingenberg.ktorfit.http.POST +import de.jensklingenberg.ktorfit.http.Tag + +internal interface AuthService { + @POST("auth/logout") + suspend fun logout(): BaseResponse + + @POST("auth/login") + suspend fun login( + @Body request: LoginRequest, + @Tag(AuthRequestAttributes.SKIP_AUTH) skipAuth: Boolean = true, + ): BaseResponse + + @DELETE("auth/withdraw") + suspend fun withdraw( + @Body request: WithdrawRequest, + ): BaseResponse + + @POST("auth/reissue") + suspend fun reissue( + @Body request: ReissueRequest, + @Tag(AuthRequestAttributes.SKIP_AUTH) skipAuth: Boolean = true, + ): BaseResponse +} diff --git a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelLottie.kt b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelLottie.kt index c4f3d6a8..71708487 100644 --- a/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelLottie.kt +++ b/Prezel/core/ui/src/main/java/com/team/prezel/core/ui/component/PrezelLottie.kt @@ -54,7 +54,7 @@ fun PrezelLottie( private fun PrezelLottiePreview() { PrezelTheme { PrezelLottie( - resId = R.raw.asset_loading, + resId = R.raw.core_ui_asset_loading, iterations = LottieConstants.IterateForever, ) } diff --git a/Prezel/core/ui/src/main/res/raw/asset_loading.json b/Prezel/core/ui/src/main/res/raw/core_ui_asset_loading.json similarity index 100% rename from Prezel/core/ui/src/main/res/raw/asset_loading.json rename to Prezel/core/ui/src/main/res/raw/core_ui_asset_loading.json diff --git a/Prezel/feature/login/impl/build.gradle.kts b/Prezel/feature/login/impl/build.gradle.kts index bcc02c3e..a2d49e3f 100644 --- a/Prezel/feature/login/impl/build.gradle.kts +++ b/Prezel/feature/login/impl/build.gradle.kts @@ -20,6 +20,9 @@ android { dependencies { implementation(projects.coreAuth) + implementation(projects.coreDomain) + implementation(projects.coreModel) + implementation(projects.featureLoginApi) implementation(projects.featureProfileApi) implementation(projects.featureHomeApi) diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt index 3dfbdb50..0c7fbec3 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginScreen.kt @@ -30,7 +30,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.ui.LocalNavAnimatedContentScope import com.team.prezel.core.auth.AuthManager -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.designsystem.component.actions.area.PrezelButtonArea import com.team.prezel.core.designsystem.component.actions.button.config.ButtonHierarchy import com.team.prezel.core.designsystem.component.actions.button.config.ButtonSize @@ -55,8 +54,8 @@ private const val AUTH_SHARED_ELEMENT_TRANSITION_DELAY = 400 @Composable internal fun SharedTransitionScope.LoginScreen( authManager: AuthManager, - navigateToTerms: () -> Unit, navigateToHome: () -> Unit, + navigateToTerms: () -> Unit, modifier: Modifier = Modifier, viewModel: LoginViewModel = hiltViewModel(), ) { @@ -68,21 +67,19 @@ internal fun SharedTransitionScope.LoginScreen( LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { - is LoginUiEffect.LaunchLogin -> { - authManager.login(context = context, provider = effect.provider).also { result -> - viewModel.onIntent(LoginUiIntent.OnLoginResult(result = result)) - } + LoginUiEffect.LaunchLogin -> { + val result = authManager.login(context = context) + viewModel.onIntent(LoginUiIntent.OnLoginResult(result = result)) } - LoginUiEffect.NavigateToTerms -> navigateToTerms() - LoginUiEffect.NavigateToHome -> navigateToHome() + LoginUiEffect.NavigateToTerms -> navigateToTerms() + is LoginUiEffect.ShowMessage -> { val resId = when (effect.message) { - LoginUiMessage.LoginCancelled -> R.string.feature_login_impl_kakao_cancelled - LoginUiMessage.LoginFailedRateLimited -> R.string.feature_login_impl_kakao_rate_limited - LoginUiMessage.LoginFailedUnknown -> R.string.feature_login_impl_kakao_failure + LoginUiMessage.LOGIN_CANCELLED -> R.string.feature_login_impl_kakao_cancelled + LoginUiMessage.LOGIN_FAILED_UNKNOWN -> R.string.feature_login_impl_login_failed } snackbarHostState.showPrezelSnackbar(message = resources.getString(resId)) } @@ -93,7 +90,7 @@ internal fun SharedTransitionScope.LoginScreen( LoginScreen( uiState = uiState, animatedVisibilityScope = LocalNavAnimatedContentScope.current, - onLogin = { viewModel.onIntent(LoginUiIntent.OnClickLogin(provider = AuthProvider.KAKAO)) }, + onLogin = { viewModel.onIntent(LoginUiIntent.OnClickLogin) }, modifier = modifier, ) } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt index ad33f64c..4f71c871 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/LoginViewModel.kt @@ -1,10 +1,9 @@ package com.team.prezel.feature.login.impl.landing import androidx.lifecycle.viewModelScope -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult +import com.team.prezel.core.domain.usecase.auth.LoginUseCase import com.team.prezel.core.ui.base.BaseViewModel -import com.team.prezel.feature.login.impl.BuildConfig import com.team.prezel.feature.login.impl.landing.contract.LoginUiEffect import com.team.prezel.feature.login.impl.landing.contract.LoginUiIntent import com.team.prezel.feature.login.impl.landing.contract.LoginUiState @@ -14,58 +13,39 @@ import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -internal class LoginViewModel @Inject constructor() : BaseViewModel(LoginUiState()) { +internal class LoginViewModel @Inject constructor( + private val loginUseCase: LoginUseCase, +) : BaseViewModel(LoginUiState()) { override fun onIntent(intent: LoginUiIntent) { when (intent) { - is LoginUiIntent.OnClickLogin -> handleClickLogin(provider = intent.provider) + LoginUiIntent.OnClickLogin -> handleClickLogin() is LoginUiIntent.OnLoginResult -> handleLoginResult(result = intent.result) } } - private fun handleClickLogin(provider: AuthProvider) { + private fun handleClickLogin() { if (currentState.isLoading) return viewModelScope.launch { updateState { copy(isLoading = true) } - - // todo: MVP 개발 완료 후 해당 조건 제거 - if (BuildConfig.DEBUG) { - updateState { copy(isLoading = false) } - sendEffect(LoginUiEffect.NavigateToTerms) - } else { - sendEffect(LoginUiEffect.LaunchLogin(provider = provider)) - } + sendEffect(LoginUiEffect.LaunchLogin) } } private fun handleLoginResult(result: AuthResult) { - viewModelScope.launch { - when (result) { - AuthResult.Success -> fetchMyInfo() - AuthResult.Cancelled -> { - sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LoginCancelled)) - updateState { copy(isLoading = false) } - } - - is AuthResult.Failure -> { - sendEffect(LoginUiEffect.ShowMessage(result.toUiMessage())) - updateState { copy(isLoading = false) } - } - } - } - } - - private fun fetchMyInfo() { viewModelScope .launch { - val isProfileCreateComplete = true - if (isProfileCreateComplete) sendEffect(LoginUiEffect.NavigateToHome) else sendEffect(LoginUiEffect.NavigateToTerms) + when (result) { + is AuthResult.Success -> handleServerLogin(idToken = result.idToken) + is AuthResult.Failure -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) + AuthResult.Cancelled -> sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_CANCELLED)) + } }.invokeOnCompletion { updateState { copy(isLoading = false) } } } - private fun AuthResult.Failure.toUiMessage(): LoginUiMessage = - when (this) { - AuthResult.Failure.RateLimited -> LoginUiMessage.LoginFailedRateLimited - AuthResult.Failure.Unknown -> LoginUiMessage.LoginFailedUnknown - } + private suspend fun handleServerLogin(idToken: String) { + loginUseCase(idToken = idToken) + .onSuccess { sendEffect(LoginUiEffect.NavigateToTerms) } + .onFailure { sendEffect(LoginUiEffect.ShowMessage(LoginUiMessage.LOGIN_FAILED_UNKNOWN)) } + } } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt index 9b7b38b3..31053d7b 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiEffect.kt @@ -1,18 +1,15 @@ package com.team.prezel.feature.login.impl.landing.contract -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.ui.base.UiEffect import com.team.prezel.feature.login.impl.landing.model.LoginUiMessage internal sealed interface LoginUiEffect : UiEffect { - data class LaunchLogin( - val provider: AuthProvider, - ) : LoginUiEffect - - data object NavigateToTerms : LoginUiEffect + data object LaunchLogin : LoginUiEffect data object NavigateToHome : LoginUiEffect + data object NavigateToTerms : LoginUiEffect + data class ShowMessage( val message: LoginUiMessage, ) : LoginUiEffect diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt index 20610b13..e4a7033d 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/contract/LoginUiIntent.kt @@ -1,13 +1,10 @@ package com.team.prezel.feature.login.impl.landing.contract -import com.team.prezel.core.auth.model.AuthProvider import com.team.prezel.core.auth.model.AuthResult import com.team.prezel.core.ui.base.UiIntent internal sealed interface LoginUiIntent : UiIntent { - data class OnClickLogin( - val provider: AuthProvider, - ) : LoginUiIntent + data object OnClickLogin : LoginUiIntent data class OnLoginResult( val result: AuthResult, diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt index f9b4149c..eeb6fb09 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/landing/model/LoginUiMessage.kt @@ -1,7 +1,6 @@ package com.team.prezel.feature.login.impl.landing.model internal enum class LoginUiMessage { - LoginCancelled, - LoginFailedRateLimited, - LoginFailedUnknown, + LOGIN_CANCELLED, + LOGIN_FAILED_UNKNOWN, } diff --git a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt index efe85502..a826dc45 100644 --- a/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt +++ b/Prezel/feature/login/impl/src/main/java/com/team/prezel/feature/login/impl/navigation/LoginEntryBuilder.kt @@ -23,12 +23,12 @@ internal fun EntryProviderScope.featureLoginEntryBuilder(authManager: Au with(LocalSharedTransitionScope.current) { LoginScreen( authManager = authManager, - navigateToTerms = { - navigator.navigate(LoginTermsNavKey) - }, navigateToHome = { navigator.replaceRoot(HomeNavKey) }, + navigateToTerms = { + navigator.navigate(LoginTermsNavKey) + }, ) } } diff --git a/Prezel/feature/login/impl/src/main/res/values/strings.xml b/Prezel/feature/login/impl/src/main/res/values/strings.xml index cab90d27..4bd1d55a 100644 --- a/Prezel/feature/login/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/login/impl/src/main/res/values/strings.xml @@ -1,8 +1,7 @@ - 카카오 로그인에 문제가 발생했어요.\n잠시 후 다시 시도해 주세요. - 로그인 시도가 너무 많아요.\n잠시 후 다시 시도해 주세요. + 로그인에 문제가 발생했어요.\n잠시 후 다시 시도해 주세요. 로그인이 취소되었어요. 카카오로 시작하기 뒤로가기 diff --git a/Prezel/feature/my/impl/build.gradle.kts b/Prezel/feature/my/impl/build.gradle.kts index 36c7169b..f7fa170c 100644 --- a/Prezel/feature/my/impl/build.gradle.kts +++ b/Prezel/feature/my/impl/build.gradle.kts @@ -7,5 +7,10 @@ android { } dependencies { + implementation(projects.coreAuth) + implementation(projects.coreDomain) + implementation(projects.coreModel) + + implementation(projects.featureLoginApi) implementation(projects.featureMyApi) } diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt index d98dcb14..8546c09d 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyScreen.kt @@ -1,18 +1,96 @@ package com.team.prezel.feature.my.impl -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar +import com.team.prezel.core.navigation.LocalNavigator +import com.team.prezel.core.ui.state.LocalSnackbarHostState +import com.team.prezel.feature.login.api.LoginNavKey +import com.team.prezel.feature.my.impl.contract.MyUiEffect +import com.team.prezel.feature.my.impl.contract.MyUiState +import com.team.prezel.feature.my.impl.model.MyUiMessage @Composable -fun MyScreen(modifier: Modifier = Modifier) { - Box( +internal fun MyScreen( + modifier: Modifier = Modifier, + viewModel: MyViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val navigator = LocalNavigator.current + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + MyUiEffect.NavigateToLogin -> navigator.replaceRoot(LoginNavKey) + is MyUiEffect.ShowMessage -> + snackbarHostState.showPrezelSnackbar(resources.getString(effect.message.toTextRes())) + } + } + } + + MyScreenContent( + uiState = uiState, + onLogout = {}, + onWithdraw = {}, + modifier = modifier, + ) +} + +@Composable +private fun MyScreenContent( + uiState: MyUiState, + onLogout: () -> Unit, + onWithdraw: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, ) { - Text("My") + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp), + verticalArrangement = Arrangement.Center, + ) { + Button( + onClick = onLogout, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("로그아웃") + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onWithdraw, + enabled = !uiState.isLoading, + modifier = Modifier.fillMaxWidth(), + ) { + Text("회원탈퇴") + } + } } } + +private fun MyUiMessage.toTextRes(): Int = + when (this) { + MyUiMessage.AUTHENTICATION_EXPIRED -> R.string.feature_my_impl_authentication_expired + MyUiMessage.LOGOUT_FAILED -> R.string.feature_my_impl_logout_failed + MyUiMessage.WITHDRAW_FAILED -> R.string.feature_my_impl_withdraw_failed + } diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt deleted file mode 100644 index c5d3a633..00000000 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyUiState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.team.prezel.feature.my.impl - -sealed interface MyUiState { - data object Loading : MyUiState - - data object LoadFailed : MyUiState -} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt index 657f39f1..c380fea8 100644 --- a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/MyViewModel.kt @@ -1,10 +1,15 @@ package com.team.prezel.feature.my.impl -import androidx.lifecycle.ViewModel +import com.team.prezel.core.ui.base.BaseViewModel +import com.team.prezel.feature.my.impl.contract.MyUiEffect +import com.team.prezel.feature.my.impl.contract.MyUiIntent +import com.team.prezel.feature.my.impl.contract.MyUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel -class MyViewModel - @Inject - constructor() : ViewModel() +internal class MyViewModel @Inject constructor() : BaseViewModel(MyUiState()) { + override fun onIntent(intent: MyUiIntent) { + // todo: 프로필 화면 구현 작업에서 진행 + } +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt new file mode 100644 index 00000000..594b4df2 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiEffect.kt @@ -0,0 +1,12 @@ +package com.team.prezel.feature.my.impl.contract + +import com.team.prezel.core.ui.base.UiEffect +import com.team.prezel.feature.my.impl.model.MyUiMessage + +sealed interface MyUiEffect : UiEffect { + data object NavigateToLogin : MyUiEffect + + data class ShowMessage( + val message: MyUiMessage, + ) : MyUiEffect +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt new file mode 100644 index 00000000..0247d882 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiIntent.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.my.impl.contract + +import com.team.prezel.core.ui.base.UiIntent + +internal sealed interface MyUiIntent : UiIntent { + data object FetchData : MyUiIntent +} diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt new file mode 100644 index 00000000..b3aaf40a --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/contract/MyUiState.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.my.impl.contract + +import com.team.prezel.core.ui.base.UiState + +internal data class MyUiState( + val isLoading: Boolean = false, +) : UiState diff --git a/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/MyUiMessage.kt b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/MyUiMessage.kt new file mode 100644 index 00000000..f47f3e05 --- /dev/null +++ b/Prezel/feature/my/impl/src/main/java/com/team/prezel/feature/my/impl/model/MyUiMessage.kt @@ -0,0 +1,7 @@ +package com.team.prezel.feature.my.impl.model + +enum class MyUiMessage { + LOGOUT_FAILED, + WITHDRAW_FAILED, + AUTHENTICATION_EXPIRED, +} diff --git a/Prezel/feature/my/impl/src/main/res/values/strings.xml b/Prezel/feature/my/impl/src/main/res/values/strings.xml index 545704f2..4547c141 100644 --- a/Prezel/feature/my/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/my/impl/src/main/res/values/strings.xml @@ -1,2 +1,6 @@ - + + 로그아웃에 실패했습니다. + 회원탈퇴에 실패했습니다. + 인증이 만료되었어요. 다시 로그인해 주세요. + diff --git a/Prezel/feature/profile/impl/build.gradle.kts b/Prezel/feature/profile/impl/build.gradle.kts index ffd5c27a..a57b35ef 100644 --- a/Prezel/feature/profile/impl/build.gradle.kts +++ b/Prezel/feature/profile/impl/build.gradle.kts @@ -7,9 +7,11 @@ android { } dependencies { + implementation(projects.coreAuth) implementation(projects.coreDomain) implementation(projects.coreModel) - implementation(projects.featureProfileApi) + implementation(projects.featureLoginApi) implementation(projects.featureHomeApi) + implementation(projects.featureProfileApi) } diff --git a/Prezel/feature/profile/impl/src/main/res/values/strings.xml b/Prezel/feature/profile/impl/src/main/res/values/strings.xml index a4ef587f..4e3de95b 100644 --- a/Prezel/feature/profile/impl/src/main/res/values/strings.xml +++ b/Prezel/feature/profile/impl/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ + 프로필 생성 프로필 편집 diff --git a/Prezel/feature/splash/impl/build.gradle.kts b/Prezel/feature/splash/impl/build.gradle.kts index 32a9314c..fe2f1b2c 100644 --- a/Prezel/feature/splash/impl/build.gradle.kts +++ b/Prezel/feature/splash/impl/build.gradle.kts @@ -7,6 +7,8 @@ android { } dependencies { + implementation(projects.coreDomain) + implementation(projects.coreModel) implementation(projects.featureSplashApi) implementation(projects.featureLoginApi) implementation(projects.featureHomeApi) diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt index c949e706..00f3fee8 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashScreen.kt @@ -13,10 +13,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.team.prezel.core.designsystem.component.feedback.snackbar.showPrezelSnackbar import com.team.prezel.core.designsystem.preview.BasicPreview import com.team.prezel.core.designsystem.theme.PrezelTheme +import com.team.prezel.core.ui.state.LocalSnackbarHostState import com.team.prezel.feature.login.api.AUTH_LOGO_SHARED_ELEMENT_KEY import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent @@ -30,13 +33,23 @@ internal fun SharedTransitionScope.SplashScreen( modifier: Modifier = Modifier, viewModel: SplashViewModel = hiltViewModel(), ) { + val resources = LocalResources.current + val snackbarHostState = LocalSnackbarHostState.current + LaunchedEffect(Unit) { viewModel.onIntent(SplashUiIntent.CheckLoginStatus) + } + LaunchedEffect(Unit) { viewModel.uiEffect.collect { effect -> when (effect) { SplashUiEffect.NavigateToHome -> navigateToHome() SplashUiEffect.NavigateToLogin -> navigateToLogin() + SplashUiEffect.ShowRetryableFailureMessage -> { + snackbarHostState.showPrezelSnackbar( + resources.getString(R.string.feature_splash_impl_retryable_failure), + ) + } } } } diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt index a4476529..3378e332 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/SplashViewModel.kt @@ -1,31 +1,37 @@ package com.team.prezel.feature.splash.impl import androidx.lifecycle.viewModelScope +import com.team.prezel.core.domain.usecase.auth.CheckLoginStatusUseCase +import com.team.prezel.core.model.auth.LoginStatus import com.team.prezel.core.ui.base.BaseViewModel import com.team.prezel.feature.splash.impl.contract.SplashUiEffect import com.team.prezel.feature.splash.impl.contract.SplashUiIntent import com.team.prezel.feature.splash.impl.contract.SplashUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -internal class SplashViewModel @Inject constructor() : - BaseViewModel( - SplashUiState(), - ) { - override fun onIntent(intent: SplashUiIntent) { - when (intent) { - SplashUiIntent.CheckLoginStatus -> checkLoginStatus() - } +internal class SplashViewModel @Inject constructor( + private val checkLoginStatusUseCase: CheckLoginStatusUseCase, +) : BaseViewModel(SplashUiState()) { + override fun onIntent(intent: SplashUiIntent) { + when (intent) { + SplashUiIntent.CheckLoginStatus -> checkLoginStatus() } + } - private fun checkLoginStatus() { - updateState { copy(isLoading = true) } + private fun checkLoginStatus() { + updateState { copy(isLoading = true) } - viewModelScope - .launch { - sendEffect(SplashUiEffect.NavigateToLogin) - }.invokeOnCompletion { updateState { copy(isLoading = false) } } - } + viewModelScope + .launch { + when (checkLoginStatusUseCase().first { status -> status != LoginStatus.LOADING }) { + LoginStatus.AUTHENTICATED -> sendEffect(SplashUiEffect.NavigateToHome) + LoginStatus.UNAUTHENTICATED -> sendEffect(SplashUiEffect.NavigateToLogin) + LoginStatus.LOADING -> Unit + } + }.invokeOnCompletion { updateState { copy(isLoading = false) } } } +} diff --git a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt index 5bbe6918..3cd60082 100644 --- a/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt +++ b/Prezel/feature/splash/impl/src/main/java/com/team/prezel/feature/splash/impl/contract/SplashUiEffect.kt @@ -6,4 +6,6 @@ sealed interface SplashUiEffect : UiEffect { data object NavigateToHome : SplashUiEffect data object NavigateToLogin : SplashUiEffect + + data object ShowRetryableFailureMessage : SplashUiEffect } diff --git a/Prezel/feature/splash/impl/src/main/res/values/strings.xml b/Prezel/feature/splash/impl/src/main/res/values/strings.xml new file mode 100644 index 00000000..4b333fec --- /dev/null +++ b/Prezel/feature/splash/impl/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 네트워크 상태를 확인한 뒤 다시 시도해 주세요. + diff --git a/Prezel/gradle/libs.versions.toml b/Prezel/gradle/libs.versions.toml index e5015a46..0b64faba 100644 --- a/Prezel/gradle/libs.versions.toml +++ b/Prezel/gradle/libs.versions.toml @@ -8,6 +8,7 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" activityCompose = "1.12.4" +androidxDatastore = "1.2.1" androidxLifecycle = "2.10.0" composeBom = "2026.01.00" ktlint = "14.0.1" @@ -16,9 +17,10 @@ ksp = "2.3.4" hilt = "2.58" androidxHilt = "1.3.0" desugarJdk = "2.1.5" -ktor = "3.3.3" +ktor = "3.4.3" ktorfit = "2.7.2" timber = "5.0.1" +orhanobutLogger = "2.2.0" navigation3 = "1.0.0" kotlinxDatetime = "0.7.1" kotlinxCoroutines = "1.10.2" @@ -30,6 +32,7 @@ lottie = "6.6.7" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDatastore" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidxLifecycle" } @@ -56,17 +59,19 @@ javax-inject = { module = "javax.inject:javax.inject", version.ref = "javaxInjec ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-auth = { module = "io.ktor:ktor-client-auth", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktorfit-lib = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" } ktorfit-ksp = { module = "de.jensklingenberg.ktorfit:ktorfit-ksp", version.ref = "ktorfit" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } +orhanobut-logger = { group = "com.orhanobut", name = "logger", version.ref = "orhanobutLogger" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } -kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } diff --git a/Prezel/settings.gradle.kts b/Prezel/settings.gradle.kts index 3dc802f7..22be6806 100644 --- a/Prezel/settings.gradle.kts +++ b/Prezel/settings.gradle.kts @@ -39,8 +39,10 @@ enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") includeAuto( ":app", - "core:auth", + ":core:auth", + ":core:common", ":core:data", + ":core:datastore", ":core:designsystem", ":core:domain", ":core:model",