diff --git a/app/src/main/java/com/wafflestudio/snutt2/di/NetworkModule.kt b/app/src/main/java/com/wafflestudio/snutt2/di/NetworkModule.kt index d90467694..38ea1964e 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/di/NetworkModule.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/di/NetworkModule.kt @@ -11,8 +11,8 @@ import com.wafflestudio.snutt2.R import com.wafflestudio.snutt2.data.SNUTTStorage import com.wafflestudio.snutt2.data.addNetworkLog import com.wafflestudio.snutt2.lib.data.serializer.Serializer -import com.wafflestudio.snutt2.lib.network.GlobalNetworkEventHandler -import com.wafflestudio.snutt2.lib.network.GlobalNetworkExceptionInterceptor +import com.wafflestudio.snutt2.lib.network.DisplayMessageResolver +import com.wafflestudio.snutt2.lib.network.DisplayMessageResolverImpl import com.wafflestudio.snutt2.lib.network.SNUTTRestApi import com.wafflestudio.snutt2.lib.network.call_adapter.ErrorParsingCallAdapterFactory import com.wafflestudio.snutt2.lib.network.createNewNetworkLog @@ -42,7 +42,6 @@ object NetworkModule { fun provideOkHttpClient( @ApplicationContext context: Context, snuttStorage: SNUTTStorage, - globalNetworkExceptionInterceptor: GlobalNetworkExceptionInterceptor, ): OkHttpClient { val cache = Cache(File(context.cacheDir, "http"), SIZE_OF_CACHE) return OkHttpClient.Builder() @@ -95,7 +94,6 @@ object NetworkModule { .build() chain.proceed(newRequest) } - .addInterceptor(globalNetworkExceptionInterceptor) .addInterceptor { chain -> val response = chain.proceed(chain.request()) if (BuildConfig.DEBUG) snuttStorage.addNetworkLog(chain.createNewNetworkLog(context, response)) @@ -140,12 +138,6 @@ object NetworkModule { return retrofit.create(SNUTTRestApi::class.java) } - @Provides - @Singleton - fun provideGlobalNetworkEventHandler(): GlobalNetworkEventHandler { - return GlobalNetworkEventHandler() - } - private const val SIZE_OF_CACHE = ( 10 * 1024 * 1024 // 10 MB ).toLong() @@ -158,4 +150,12 @@ object NetworkModule { ): ConnectivityManager { return (context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager) } + + @Provides + @Singleton + fun provideDisplayMessageResolver( + @ApplicationContext context: Context, + ): DisplayMessageResolver { + return DisplayMessageResolverImpl(context) + } } diff --git a/app/src/main/java/com/wafflestudio/snutt2/di/RepositoryModule.kt b/app/src/main/java/com/wafflestudio/snutt2/di/RepositoryModule.kt index 0beedfd26..b78027ba5 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/di/RepositoryModule.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/di/RepositoryModule.kt @@ -16,6 +16,8 @@ import com.wafflestudio.snutt2.data.user.UserRepository import com.wafflestudio.snutt2.data.user.UserRepositoryImpl import com.wafflestudio.snutt2.data.vacancy_noti.VacancyRepository import com.wafflestudio.snutt2.data.vacancy_noti.VacancyRepositoryImpl +import com.wafflestudio.snutt2.test.TestRepository +import com.wafflestudio.snutt2.test.TestRepositoryImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -48,4 +50,7 @@ abstract class RepositoryModule { @Binds abstract fun bindsThemeRepository(impl: ThemeRepositoryImpl): ThemeRepository + + @Binds + abstract fun bindsTestRepository(impl: TestRepositoryImpl): TestRepository } diff --git a/app/src/main/java/com/wafflestudio/snutt2/lib/network/ApiOnError.kt b/app/src/main/java/com/wafflestudio/snutt2/lib/network/ApiOnError.kt index 7305fc59e..076e59316 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/lib/network/ApiOnError.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/lib/network/ApiOnError.kt @@ -4,11 +4,18 @@ import android.content.Context import android.widget.Toast import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi import com.wafflestudio.snutt2.R +import com.wafflestudio.snutt2.data.user.UserRepository import com.wafflestudio.snutt2.lib.android.MessagingError import com.wafflestudio.snutt2.lib.android.runOnUiThread import com.wafflestudio.snutt2.lib.network.call_adapter.ErrorParsedHttpException import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.IOException import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -19,6 +26,8 @@ import javax.inject.Singleton @Singleton class ApiOnError @Inject constructor( @ApplicationContext private val context: Context, + private val moshi: Moshi, + private val userRepository: UserRepository, ) : (Throwable) -> Unit { override fun invoke(error: Throwable) { @@ -26,6 +35,13 @@ class ApiOnError @Inject constructor( Timber.e(error) when (error) { + is IOException -> { // network error + Toast.makeText( + context, + context.getString(R.string.error_no_network), + Toast.LENGTH_SHORT, + ).show() + } is MessagingError -> { Toast.makeText( context, @@ -35,14 +51,11 @@ class ApiOnError @Inject constructor( } is ErrorParsedHttpException -> { when (error.errorDTO?.code) { - // ApiOnError와 GlobalNetworkHandler 이중 동작 방지 - ErrorCode.SERVER_FAULT -> {} - ErrorCode.WRONG_API_KEY -> {} - ErrorCode.NO_USER_TOKEN -> {} - ErrorCode.WRONG_USER_TOKEN -> {} - ErrorCode.NO_ADMIN_PRIVILEGE -> {} - ErrorCode.UNKNOWN_APP -> {} - + ErrorCode.SERVER_FAULT -> Toast.makeText( + context, + context.getString(R.string.error_server_fault), + Toast.LENGTH_SHORT, + ).show() ErrorCode.INVALID_EMAIL -> Toast.makeText( context, context.getString(R.string.error_invalid_email), @@ -113,6 +126,42 @@ class ApiOnError @Inject constructor( context.getString(R.string.error_wrong_verification_code), Toast.LENGTH_SHORT, ).show() + ErrorCode.WRONG_API_KEY -> Toast.makeText( + context, + context.getString(R.string.error_wrong_api_key), + Toast.LENGTH_SHORT, + ).show() + ErrorCode.NO_USER_TOKEN -> Toast.makeText( + context, + context.getString(R.string.error_no_user_token), + Toast.LENGTH_SHORT, + ).show() + ErrorCode.WRONG_USER_TOKEN -> { + Toast.makeText( + context, + context.getString(R.string.error_wrong_user_token), + Toast.LENGTH_SHORT, + ).show() + CoroutineScope(Dispatchers.IO).launch { + try { + userRepository.performLogout() + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "로그아웃에 실패하였습니다.", + Toast.LENGTH_SHORT, + ) + .show() + } + } + } + } + ErrorCode.NO_ADMIN_PRIVILEGE -> Toast.makeText( + context, + context.getString(R.string.error_no_admin_privilege), + Toast.LENGTH_SHORT, + ).show() ErrorCode.WRONG_ID -> Toast.makeText( context, context.getString(R.string.error_wrong_id), @@ -128,6 +177,11 @@ class ApiOnError @Inject constructor( context.getString(R.string.error_wrong_fb_token), Toast.LENGTH_SHORT, ).show() + ErrorCode.UNKNOWN_APP -> Toast.makeText( + context, + context.getString(R.string.error_unknown_app), + Toast.LENGTH_SHORT, + ).show() ErrorCode.INVALID_ID -> Toast.makeText( context, context.getString(R.string.error_invalid_id), @@ -267,7 +321,14 @@ class ApiOnError @Inject constructor( ).show() } } - else -> {} + is kotlinx.coroutines.CancellationException -> {} // do nothing + else -> { + Toast.makeText( + context, + context.getString(R.string.error_unknown), + Toast.LENGTH_SHORT, + ).show() + } } } } @@ -280,6 +341,7 @@ object ErrorCode { const val INVALID_EMAIL = 0x300F const val VACANCY_PREV_SEMESTER = 0x9C45 const val VACANCY_DUPLICATE = 0x9FC4 + const val USED_EMAIL = 0x9FC5 const val INVALID_NICKNAME = 0x9C48 /* 401 - Request was invalid */ diff --git a/app/src/main/java/com/wafflestudio/snutt2/lib/network/DisplayMessageResolver.kt b/app/src/main/java/com/wafflestudio/snutt2/lib/network/DisplayMessageResolver.kt new file mode 100644 index 000000000..a0a0e8561 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/lib/network/DisplayMessageResolver.kt @@ -0,0 +1,5 @@ +package com.wafflestudio.snutt2.lib.network + +interface DisplayMessageResolver { + fun getDisplayMessage(error: DomainError): String +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/lib/network/DisplayMessageResolverImpl.kt b/app/src/main/java/com/wafflestudio/snutt2/lib/network/DisplayMessageResolverImpl.kt new file mode 100644 index 000000000..f29f5cb71 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/lib/network/DisplayMessageResolverImpl.kt @@ -0,0 +1,26 @@ +package com.wafflestudio.snutt2.lib.network + +import android.content.Context +import com.wafflestudio.snutt2.R +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +// DisplayMessageResolver는 서버 displayMessage가 없거나, 있는데 클라이언트의 displayMessage와 다른 경우를 커버한다. +// 추후 테스트를 용이하게 하기 위해 interface 분리되어 있음. +class DisplayMessageResolverImpl @Inject constructor( + @ApplicationContext private val context: Context, +) : DisplayMessageResolver { + override fun getDisplayMessage(error: DomainError): String { + return when (error) { + is NetworkDisconnect -> context.getString(R.string.error_no_network) + is ServerFault -> context.getString(R.string.error_server_fault) + is NoAdminPrivilege -> context.getString(R.string.error_no_admin_privilege) + is UnknownApp -> context.getString(R.string.error_unknown_app) + is AuthError.WrongApiKey -> context.getString(R.string.error_wrong_api_key) + is AuthError.NoUserToken -> context.getString(R.string.error_no_user_token) + is AuthError.WrongUserToken -> context.getString(R.string.error_wrong_user_token) + is Unknown -> context.getString(R.string.error_unknown) + else -> error.displayMessage + } + } +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/lib/network/DomainError.kt b/app/src/main/java/com/wafflestudio/snutt2/lib/network/DomainError.kt new file mode 100644 index 000000000..3eb3053ec --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/lib/network/DomainError.kt @@ -0,0 +1,30 @@ +package com.wafflestudio.snutt2.lib.network + +sealed interface DomainError { + val displayMessage: String +} + +// 1. Global Errors (모든 API에서 발생 가능) +// Auth 관련 오류 +sealed interface AuthError : DomainError { + data class WrongApiKey(override val displayMessage: String) : AuthError + data class NoUserToken(override val displayMessage: String) : AuthError // 사실 발생하지 않는 오류일 수 있음. empty token을 보내도 WrongUserToken 발생. + data class WrongUserToken(override val displayMessage: String) : AuthError +} + +// 기타 오류 +data class NetworkDisconnect(override val displayMessage: String) : DomainError +data class ServerFault(override val displayMessage: String) : DomainError +data class NoAdminPrivilege(override val displayMessage: String) : DomainError +data class UnknownApp(override val displayMessage: String) : DomainError +data class Unknown(override val displayMessage: String) : DomainError +data class Nothing(override val displayMessage: String) : DomainError + +// 2. Local Errors (특정 API에서만 발생) +// 회원가입 API +sealed interface SignupError : DomainError { + data class InvalidId(override val displayMessage: String) : SignupError + data class InvalidPassword(override val displayMessage: String) : SignupError + data class DuplicateId(override val displayMessage: String) : SignupError + data class UsedEmail(override val displayMessage: String) : SignupError +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/lib/network/ExceptionMapper.kt b/app/src/main/java/com/wafflestudio/snutt2/lib/network/ExceptionMapper.kt new file mode 100644 index 000000000..1890b1f8a --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/lib/network/ExceptionMapper.kt @@ -0,0 +1,30 @@ +package com.wafflestudio.snutt2.lib.network + +import com.wafflestudio.snutt2.lib.network.call_adapter.ErrorParsedHttpException +import kotlinx.coroutines.CancellationException +import okio.IOException + +fun Exception.toDomainError(): DomainError { + return when (this) { + is IOException -> NetworkDisconnect("") + is CancellationException -> Nothing("") + is ErrorParsedHttpException -> { + val displayMessage = this.errorDTO?.displayMessage ?: "" + return when (this.errorDTO?.code) { + ErrorCode.SERVER_FAULT -> ServerFault(displayMessage) + ErrorCode.NO_ADMIN_PRIVILEGE -> NoAdminPrivilege(displayMessage) + ErrorCode.UNKNOWN_APP -> UnknownApp(displayMessage) + ErrorCode.WRONG_API_KEY -> AuthError.WrongApiKey(displayMessage) + ErrorCode.NO_USER_TOKEN -> AuthError.NoUserToken(displayMessage) + ErrorCode.WRONG_USER_TOKEN -> AuthError.WrongUserToken(displayMessage) + + ErrorCode.INVALID_ID -> SignupError.InvalidId(displayMessage) + ErrorCode.INVALID_PASSWORD -> SignupError.InvalidPassword(displayMessage) + ErrorCode.DUPLICATE_ID -> SignupError.DuplicateId(displayMessage) + ErrorCode.USED_EMAIL -> SignupError.UsedEmail(displayMessage) + else -> Unknown(displayMessage) + } + } + else -> Unknown("") + } +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/lib/network/GlobalNetworkEventHandler.kt b/app/src/main/java/com/wafflestudio/snutt2/lib/network/GlobalNetworkEventHandler.kt deleted file mode 100644 index 726b0871e..000000000 --- a/app/src/main/java/com/wafflestudio/snutt2/lib/network/GlobalNetworkEventHandler.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.wafflestudio.snutt2.lib.network - -import com.wafflestudio.snutt2.lib.android.runOnUiThread -import java.lang.ref.WeakReference - -class GlobalNetworkEventHandler { - private var networkEventCallbackRef: WeakReference<(GlobalNetworkEvent) -> Unit>? = null - - fun register(handler: (GlobalNetworkEvent) -> Unit) { - networkEventCallbackRef = WeakReference(handler) - } - - fun handle(event: GlobalNetworkEvent) { - // Main thread에서 발생하도록 강제 - runOnUiThread { - networkEventCallbackRef?.get()?.invoke(event) - } - } -} - -enum class GlobalNetworkEvent { - ERROR_NETWORK, - ERROR_SERVER_FAULT, - ERROR_WRONG_API_KEY, - ERROR_NO_USER_TOKEN, - ERROR_WRONG_USER_TOKEN, - ERROR_NO_ADMIN_PRIVILEGE, - ERROR_UNKNOWN_APP, - ERROR_UNKNOWN, -} diff --git a/app/src/main/java/com/wafflestudio/snutt2/lib/network/GlobalNetworkExceptionInterceptor.kt b/app/src/main/java/com/wafflestudio/snutt2/lib/network/GlobalNetworkExceptionInterceptor.kt deleted file mode 100644 index 33aa28720..000000000 --- a/app/src/main/java/com/wafflestudio/snutt2/lib/network/GlobalNetworkExceptionInterceptor.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.wafflestudio.snutt2.lib.network - -import com.google.gson.GsonBuilder -import com.google.gson.JsonParser -import com.wafflestudio.snutt2.lib.data.serializer.Serializer -import okhttp3.Interceptor -import okhttp3.Response -import okio.IOException -import java.nio.charset.StandardCharsets -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class GlobalNetworkExceptionInterceptor @Inject constructor( - private val serializer: Serializer, - private val globalNetworkEventHandler: GlobalNetworkEventHandler, -) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - try { - val response = chain.proceed(chain.request()) - val responseBody = ( - response.body?.run { - GsonBuilder().setPrettyPrinting().create().toJson( - JsonParser.parseString( - source().buffer.clone().readString( - contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8, - ), - ), - ) - } ?: "" - ).also { - if (!it.contains("errcode")) return@intercept response - } - - val errorDTO = runCatching { - serializer.deserialize( - responseBody, - ErrorDTO::class.java, - ) - }.getOrElse { return@intercept response } - - when (errorDTO.code) { - ErrorCode.SERVER_FAULT -> globalNetworkEventHandler.handle(GlobalNetworkEvent.ERROR_SERVER_FAULT) - ErrorCode.WRONG_API_KEY -> globalNetworkEventHandler.handle(GlobalNetworkEvent.ERROR_WRONG_API_KEY) - ErrorCode.NO_USER_TOKEN -> globalNetworkEventHandler.handle(GlobalNetworkEvent.ERROR_NO_USER_TOKEN) - ErrorCode.WRONG_USER_TOKEN -> globalNetworkEventHandler.handle(GlobalNetworkEvent.ERROR_WRONG_USER_TOKEN) - ErrorCode.NO_ADMIN_PRIVILEGE -> globalNetworkEventHandler.handle(GlobalNetworkEvent.ERROR_NO_ADMIN_PRIVILEGE) - ErrorCode.UNKNOWN_APP -> globalNetworkEventHandler.handle(GlobalNetworkEvent.ERROR_UNKNOWN_APP) - else -> {} - } - - return response - } catch (e: Throwable) { - when (e) { - is IOException -> globalNetworkEventHandler.handle(GlobalNetworkEvent.ERROR_NETWORK) - is kotlinx.coroutines.CancellationException -> {} // do nothing - else -> globalNetworkEventHandler.handle(GlobalNetworkEvent.ERROR_UNKNOWN) - } - throw e - } - } -} diff --git a/app/src/main/java/com/wafflestudio/snutt2/lib/network/Result.kt b/app/src/main/java/com/wafflestudio/snutt2/lib/network/Result.kt new file mode 100644 index 000000000..936f1f6db --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/lib/network/Result.kt @@ -0,0 +1,18 @@ +package com.wafflestudio.snutt2.lib.network + +import kotlin.Nothing + +sealed class Result { + data class Success(val data: T) : Result() + data class Fail(val error: DomainError) : Result() +} + +inline fun Result.onSuccess(action: (T) -> Unit): Result { + if (this is Result.Success) action(this.data) + return this +} + +inline fun Result.onFailure(action: (DomainError) -> Unit): Result { + if (this is Result.Fail) action(this.error) + return this +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/test/TestRepository.kt b/app/src/main/java/com/wafflestudio/snutt2/test/TestRepository.kt new file mode 100644 index 000000000..4c05b243a --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/test/TestRepository.kt @@ -0,0 +1,11 @@ +package com.wafflestudio.snutt2.test + +import com.wafflestudio.snutt2.lib.network.Result + +interface TestRepository { + suspend fun registerLocal(id: String, password: String, email: String): Result + + suspend fun getNotificationCount(): Result + + suspend fun clearToken(): Result +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/test/TestRepositoryImpl.kt b/app/src/main/java/com/wafflestudio/snutt2/test/TestRepositoryImpl.kt new file mode 100644 index 000000000..2344260b7 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/test/TestRepositoryImpl.kt @@ -0,0 +1,36 @@ +package com.wafflestudio.snutt2.test + +import com.wafflestudio.snutt2.data.SNUTTStorage +import com.wafflestudio.snutt2.lib.network.Result +import com.wafflestudio.snutt2.lib.network.SNUTTRestApi +import com.wafflestudio.snutt2.lib.network.dto.PostSignUpParams +import com.wafflestudio.snutt2.lib.network.toDomainError +import javax.inject.Inject + +class TestRepositoryImpl @Inject constructor( + private val api: SNUTTRestApi, + private val storage: SNUTTStorage, +) : TestRepository { + override suspend fun registerLocal(id: String, password: String, email: String): Result { + try { + api._postSignUp(PostSignUpParams(id, password, email)) + return Result.Success(Unit) + } catch (e: Exception) { + return Result.Fail(e.toDomainError()) + } + } + + override suspend fun getNotificationCount(): Result { + try { + val result = api._getNotificationCount() + return Result.Success(result.count.toInt()) + } catch (e: Exception) { + return Result.Fail(e.toDomainError()) + } + } + + override suspend fun clearToken(): Result { + storage.accessToken.clear() + return Result.Success(Unit) + } +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/test/TestScreen.kt b/app/src/main/java/com/wafflestudio/snutt2/test/TestScreen.kt new file mode 100644 index 000000000..032d855d6 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/test/TestScreen.kt @@ -0,0 +1,201 @@ +package com.wafflestudio.snutt2.test + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.wafflestudio.snutt2.components.compose.RightArrowIcon +import com.wafflestudio.snutt2.components.compose.SimpleTopBar +import com.wafflestudio.snutt2.components.compose.clicks +import com.wafflestudio.snutt2.lib.android.toast +import com.wafflestudio.snutt2.ui.SNUTTColors +import com.wafflestudio.snutt2.ui.SNUTTTypography +import com.wafflestudio.snutt2.views.logged_in.lecture_detail.Margin + +@Composable +fun TestRoute( + modifier: Modifier = Modifier, + onNavigateBack: () -> Unit, + onNavigateOnBoard: () -> Unit, + viewModel: TestViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val uiState by viewModel.testUiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.testUiEvent.collect { uiEvent -> + when (uiEvent) { + is TestUiEvent.ShowToast -> { + val message = uiEvent.message + if (message.isNotEmpty()) { + context.toast(message) + } + } + is TestUiEvent.NavigateToOnboard -> { + onNavigateOnBoard() + } + } + } + } + + TestScreen( + modifier = modifier, + uiState = uiState, + onClickBack = onNavigateBack, + onFirstTestCase = viewModel::runApiWithoutToken, + onSecondTestCase = {}, // 구현하려 했으나 번거로워서 일단 스킵 + onThirdTestCase = viewModel::registerLocal, + onFourthTestCase = viewModel::getNotificationCount, + ) +} + +@Composable +fun TestScreen( + modifier: Modifier = Modifier, + uiState: TestUiState, + onClickBack: () -> Unit, + onFirstTestCase: () -> Unit, + onSecondTestCase: () -> Unit, + onThirdTestCase: (String, String, String) -> Unit, + onFourthTestCase: () -> Unit, +) { + val text = when (uiState) { + is TestUiState.Fail -> "실패" + is TestUiState.Initial -> "초기 상태" + is TestUiState.Loading -> "대기 중" + is TestUiState.Success -> "성공 (${uiState.data})" + } + + Column( + modifier = modifier + .fillMaxSize() + .background(SNUTTColors.SettingBackground), + ) { + SimpleTopBar( + title = "Test", + onClickNavigateBack = onClickBack, + ) + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()), + ) { + Margin(height = 10.dp) + + SettingItemForTest( + title = "현재 상태: $text", + hasNextPage = false, + onClick = {}, + ) + + Margin(height = 10.dp) + + // 누르면 WrongUserToken에 해당하는 Toast가 뜬 후 Onboard 화면으로 이동해야 한다. + SettingItemForTest( + title = "Global Exception - WrongUserToken Test", + hasNextPage = false, + onClick = onFirstTestCase, + ) + + Margin(height = 10.dp) + + SettingItemForTest( + title = "Global Exception - NoAdminPrivilege Test", + hasNextPage = false, + onClick = onSecondTestCase, + ) + + Margin(height = 10.dp) + + // 누르면 DuplicateLocalId에 해당하는 Toast가 떠야 한다. + SettingItemForTest( + title = "Local Exception - DuplicateLocalId Test", + hasNextPage = false, + onClick = { onThirdTestCase("plgafhd", "testtest1234", "plgafhdtest@snu.ac.kr") }, + ) + + Margin(height = 10.dp) + + // 누르면 현재 상태에, 알림 개수가 반영되어야 한다. + SettingItemForTest( + title = "성공하는 경우 Test", + hasNextPage = false, + onClick = onFourthTestCase, + ) + + // 리팩토링 과정에서 필요한 테스트가 있다면 지속적으로 추가 + } + } +} + +@Preview(showBackground = true) +@Composable +fun TestScreenPreview() { + TestScreen( + uiState = TestUiState.Fail, + onClickBack = {}, + onFirstTestCase = {}, + onSecondTestCase = {}, + onThirdTestCase = { _, _, _ -> + }, + onFourthTestCase = {}, + ) +} + +@Composable +fun SettingItemForTest( + title: String, + modifier: Modifier = Modifier, + titleColor: Color = MaterialTheme.colors.onSurface, + leadingIcon: @Composable () -> Unit = {}, + hasNextPage: Boolean = true, + onClick: (() -> Unit)? = null, + content: @Composable () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(45.dp) + .background(MaterialTheme.colors.surface) + .clicks { if (onClick != null) onClick() } + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + leadingIcon() + Text( + text = title, + style = SNUTTTypography.body1.copy( + color = titleColor, + ), + ) + Spacer(modifier = Modifier.weight(1f)) + content() + if (hasNextPage) { + RightArrowIcon( + modifier = Modifier.size(22.dp), + colorFilter = ColorFilter.tint(SNUTTColors.Black500), + ) + } + } +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/test/TestViewModel.kt b/app/src/main/java/com/wafflestudio/snutt2/test/TestViewModel.kt new file mode 100644 index 000000000..2c079b379 --- /dev/null +++ b/app/src/main/java/com/wafflestudio/snutt2/test/TestViewModel.kt @@ -0,0 +1,107 @@ +package com.wafflestudio.snutt2.test + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wafflestudio.snutt2.lib.network.AuthError +import com.wafflestudio.snutt2.lib.network.DisplayMessageResolver +import com.wafflestudio.snutt2.lib.network.DomainError +import com.wafflestudio.snutt2.lib.network.SignupError +import com.wafflestudio.snutt2.lib.network.onFailure +import com.wafflestudio.snutt2.lib.network.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TestViewModel @Inject constructor( + private val testRepository: TestRepository, + private val displayMessageResolver: DisplayMessageResolver, +) : ViewModel() { + private val _testUiState = MutableStateFlow(TestUiState.Initial) + val testUiState = _testUiState.asStateFlow() + + private val _testUiEvent: MutableSharedFlow = MutableSharedFlow(replay = 1) + val testUiEvent = _testUiEvent.asSharedFlow() + + fun registerLocal(id: String, password: String, email: String) { + viewModelScope.launch { + _testUiState.emit(TestUiState.Loading) + testRepository.registerLocal(id, password, email) + .onSuccess { + _testUiState.emit(TestUiState.Success(-1)) + } + .onFailure { error -> + _testUiState.emit(TestUiState.Fail) + handleTestError(error) + } + } + } + + fun runApiWithoutToken() { + viewModelScope.launch { + _testUiState.emit(TestUiState.Loading) + testRepository.clearToken() + + testRepository.getNotificationCount() + .onSuccess { data -> + _testUiState.emit(TestUiState.Success(data)) + } + .onFailure { error -> + _testUiState.emit(TestUiState.Fail) + handleTestError(error) + } + } + } + + fun getNotificationCount() { + viewModelScope.launch { + _testUiState.emit(TestUiState.Loading) + + testRepository.getNotificationCount() + .onSuccess { data -> + _testUiState.emit(TestUiState.Success(data)) + } + .onFailure { error -> + _testUiState.emit(TestUiState.Fail) + handleTestError(error) + } + } + } + + private suspend fun handleTestError(error: DomainError) { + val displayMessage = displayMessageResolver.getDisplayMessage(error) + when (error) { + // Local Exception 중 특수한 경우가 있다면 여기에서 처리 (여기에서는 없음, 순서도 상관없음) + // AuthError는 Global Exception 중 특수한 경우 + is AuthError -> { + _testUiEvent.emit(TestUiEvent.ShowToast(displayMessage)) + testRepository.clearToken() + _testUiEvent.emit(TestUiEvent.NavigateToOnboard) + } + // 특수하지 않은 Local Exception + is SignupError -> { + _testUiEvent.emit(TestUiEvent.ShowToast(displayMessage)) + } + // 특수하지 않은 Global Exception + else -> { + _testUiEvent.emit(TestUiEvent.ShowToast(displayMessage)) + } + } + } +} + +sealed interface TestUiState { + data object Initial : TestUiState + data object Loading : TestUiState + data object Fail : TestUiState + data class Success(val data: Int) : TestUiState +} + +sealed interface TestUiEvent { + data class ShowToast(val message: String) : TestUiEvent + data object NavigateToOnboard : TestUiEvent +} diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/GlobalNetworkUiEvent.kt b/app/src/main/java/com/wafflestudio/snutt2/views/GlobalNetworkUiEvent.kt deleted file mode 100644 index faa3012cf..000000000 --- a/app/src/main/java/com/wafflestudio/snutt2/views/GlobalNetworkUiEvent.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.wafflestudio.snutt2.views - -sealed interface GlobalNetworkUiEvent { - data object NetworkError : GlobalNetworkUiEvent - data object ServerFault : GlobalNetworkUiEvent - data object WrongApiKey : GlobalNetworkUiEvent - data object NoUserToken : GlobalNetworkUiEvent - data object WrongUserToken : GlobalNetworkUiEvent - data object NoAdminPrivilege : GlobalNetworkUiEvent - data object UnknownApp : GlobalNetworkUiEvent - data object UnknownError : GlobalNetworkUiEvent -} diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/NavigationDestination.kt b/app/src/main/java/com/wafflestudio/snutt2/views/NavigationDestination.kt index ec7bed651..e477b70b3 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/NavigationDestination.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/NavigationDestination.kt @@ -104,6 +104,10 @@ sealed interface NavigationDestination { @DeepLinkPath("network_log") data object NetworkLog : NavigationDestination + @Serializable + @DeepLinkPath("test") + data object Test : NavigationDestination + @Serializable @DeepLinkPath("vacancy") data object VacancyNotification : NavigationDestination diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/RootActivity.kt b/app/src/main/java/com/wafflestudio/snutt2/views/RootActivity.kt index c2e97b237..661bad7fa 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/RootActivity.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/RootActivity.kt @@ -50,15 +50,13 @@ import com.wafflestudio.snutt2.components.compose.* import com.wafflestudio.snutt2.deeplink.InstallInAppDeeplinkExecutor import com.wafflestudio.snutt2.lib.logging.AnalyticsLogger import com.wafflestudio.snutt2.lib.logging.DetailScreenReferrer -import com.wafflestudio.snutt2.lib.android.toast import com.wafflestudio.snutt2.lib.network.ApiOnError import com.wafflestudio.snutt2.lib.network.ApiOnProgress -import com.wafflestudio.snutt2.lib.network.GlobalNetworkEvent -import com.wafflestudio.snutt2.lib.network.GlobalNetworkEventHandler import com.wafflestudio.snutt2.model.BuiltInTheme import com.wafflestudio.snutt2.model.CustomTheme import com.wafflestudio.snutt2.navigation.getDeepLinkPath import com.wafflestudio.snutt2.react_native.ReactNativeBundleManager +import com.wafflestudio.snutt2.test.TestRoute import com.wafflestudio.snutt2.ui.SNUTTColors import com.wafflestudio.snutt2.ui.SNUTTTheme import com.wafflestudio.snutt2.ui.isDarkMode @@ -96,13 +94,6 @@ class RootActivity : AppCompatActivity() { private val homeViewModel: HomeViewModel by viewModels() - private val rootActivityViewModel: RootActivityViewModel by viewModels() - - // 반드시 RootActivity의 멤버 변수로 두어야 하는데, 그렇지 않으면 Garbage Collector가 수거해버려서 나중에 null이 register 된다. - private val onGlobalNetworkEvent: (GlobalNetworkEvent) -> Unit = { event -> - rootActivityViewModel.onGlobalNetworkEvent(event) - } - @Inject lateinit var popupState: PopupState @@ -115,9 +106,6 @@ class RootActivity : AppCompatActivity() { @Inject lateinit var friendBundleManager: ReactNativeBundleManager - @Inject - lateinit var globalNetworkEventHandler: GlobalNetworkEventHandler - @Inject lateinit var analyticsLogger: AnalyticsLogger @@ -140,9 +128,6 @@ class RootActivity : AppCompatActivity() { } isInitialRefreshFinished = true } - - globalNetworkEventHandler.register(onGlobalNetworkEvent) - setUpContents( if (token.isEmpty()) { NavigationDestination.Onboard @@ -256,10 +241,27 @@ class RootActivity : AppCompatActivity() { LocalNavBottomSheetState provides navBottomSheetState, LocalAnalyticsLogger provides analyticsLogger, ) { - ObserveGlobalEvents( - navigateToImportantNotice = { navController.navigate(NavigationDestination.ImportantNotice) }, - navigateToOnboard = { navController.navigateAsOrigin(NavigationDestination.Onboard) }, - ) + LaunchedEffect(Unit) { + lifecycleScope.launch { + remoteConfig.noticeConfig.collect { + if (it.visible) { + navController.navigateAsOrigin(NavigationDestination.ImportantNotice) + } + } + } + + // ApiOnError에서 WRONG_USER_TOKEN 시 로그아웃 하고 Onboard로 내비게이션하기 위한 코드. + // ApiOnError에서 UI 단 접근이 불가능하기 때문에, token.isEmpty()를 트리거로 하여 RootActivity에서 내비게이션한다. + // 다만 앱 켰을 때 Onboard로 두 번 연속 내비게이션하지 않기 위해 hasRoute를 검사한다. + // FIXME: 궁극적으로는 ApiOnError를 제거해야 한다. + lifecycleScope.launch { + userViewModel.accessToken.collect { token -> + if (token.isEmpty() && navController.currentDestination?.hasRoute(NavigationDestination.Tutorial::class) == false) { + navController.navigateAsOrigin(NavigationDestination.Onboard) + } + } + } + } InstallInAppDeeplinkExecutor() ModalBottomSheetLayout( @@ -334,54 +336,6 @@ class RootActivity : AppCompatActivity() { } } - @Composable - fun ObserveGlobalEvents( - navigateToImportantNotice: () -> Unit, - navigateToOnboard: () -> Unit, - ) { - LaunchedEffect(Unit) { - lifecycleScope.launch { - remoteConfig.noticeConfig.collect { - if (it.visible) { - navigateToImportantNotice() - } - } - } - - lifecycleScope.launch { - // 훗날 토스트 문구가 displayMessage로 통일되고 나면 개션의 여지 있음 - rootActivityViewModel.globalNetworkUiEvent.collect { state -> - when (state) { - GlobalNetworkUiEvent.NetworkError -> toast(getString(R.string.error_no_network)) - GlobalNetworkUiEvent.NoAdminPrivilege -> toast(getString(R.string.error_no_admin_privilege)) - GlobalNetworkUiEvent.NoUserToken -> { - toast(getString(R.string.error_no_user_token)) - try { - userViewModel.performLogout() - } catch (e: Exception) { - toast(getString(R.string.error_signout_failed)) - } - navigateToOnboard() - } - GlobalNetworkUiEvent.WrongUserToken -> { - toast(getString(R.string.error_wrong_user_token)) - try { - userViewModel.performLogout() - } catch (e: Exception) { - toast(getString(R.string.error_signout_failed)) - } - navigateToOnboard() - } - GlobalNetworkUiEvent.ServerFault -> toast(getString(R.string.error_server_fault)) - GlobalNetworkUiEvent.UnknownApp -> toast(getString(R.string.error_unknown_app)) - GlobalNetworkUiEvent.UnknownError -> toast(getString(R.string.error_unknown)) - GlobalNetworkUiEvent.WrongApiKey -> toast(getString(R.string.error_wrong_api_key)) - } - } - } - } - } - private fun NavGraphBuilder.onboardGraph() { navigation( startDestination = NavigationDestination.Tutorial, @@ -505,6 +459,15 @@ class RootActivity : AppCompatActivity() { ) } if (BuildConfig.DEBUG) composableAnimated { NetworkLogPage() } + + if (BuildConfig.DEBUG) { + composableAnimated { + TestRoute( + onNavigateBack = { navController.popBackStack() }, + onNavigateOnBoard = { navController.navigateAsOrigin(NavigationDestination.Onboard) }, + ) + } + } } // 안드 13 대응 diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/RootActivityViewModel.kt b/app/src/main/java/com/wafflestudio/snutt2/views/RootActivityViewModel.kt deleted file mode 100644 index debcbf874..000000000 --- a/app/src/main/java/com/wafflestudio/snutt2/views/RootActivityViewModel.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.wafflestudio.snutt2.views - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.wafflestudio.snutt2.lib.network.GlobalNetworkEvent -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class RootActivityViewModel @Inject constructor() : ViewModel() { - private val _globalNetworkUiEvent: MutableSharedFlow = MutableSharedFlow() - val globalNetworkUiEvent = _globalNetworkUiEvent.asSharedFlow() - - fun onGlobalNetworkEvent(event: GlobalNetworkEvent) { - viewModelScope.launch { - when (event) { - GlobalNetworkEvent.ERROR_NETWORK -> _globalNetworkUiEvent.emit(GlobalNetworkUiEvent.NetworkError) - GlobalNetworkEvent.ERROR_SERVER_FAULT -> _globalNetworkUiEvent.emit(GlobalNetworkUiEvent.ServerFault) - GlobalNetworkEvent.ERROR_WRONG_API_KEY -> _globalNetworkUiEvent.emit(GlobalNetworkUiEvent.WrongApiKey) - GlobalNetworkEvent.ERROR_NO_USER_TOKEN -> _globalNetworkUiEvent.emit(GlobalNetworkUiEvent.NoUserToken) - GlobalNetworkEvent.ERROR_WRONG_USER_TOKEN -> _globalNetworkUiEvent.emit(GlobalNetworkUiEvent.WrongUserToken) - GlobalNetworkEvent.ERROR_NO_ADMIN_PRIVILEGE -> _globalNetworkUiEvent.emit(GlobalNetworkUiEvent.NoAdminPrivilege) - GlobalNetworkEvent.ERROR_UNKNOWN_APP -> _globalNetworkUiEvent.emit(GlobalNetworkUiEvent.UnknownApp) - GlobalNetworkEvent.ERROR_UNKNOWN -> _globalNetworkUiEvent.emit(GlobalNetworkUiEvent.UnknownError) - } - } - } -} diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/HomePage.kt b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/HomePage.kt index a9dfaa09a..2cc31e54d 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/HomePage.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/HomePage.kt @@ -202,6 +202,9 @@ fun HomePage() { onNavigateNetworkLog = { navController.navigate(NavigationDestination.NetworkLog) }, + onNavigateTest = { + navController.navigate(NavigationDestination.Test) + }, onNavigateOnboardAsOrigin = { navController.navigateAsOrigin(NavigationDestination.Onboard) }, diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/settings/SettingsPage.kt b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/settings/SettingsPage.kt index 5d103e517..8b9a67b92 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/settings/SettingsPage.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/settings/SettingsPage.kt @@ -65,6 +65,7 @@ fun SettingsRoute( onNavigateServiceInfo: () -> Unit, onNavigatePersonalInformationPolicy: () -> Unit, onNavigateNetworkLog: () -> Unit, + onNavigateTest: () -> Unit, onNavigateOnboardAsOrigin: () -> Unit, ) { val uiState by viewModel.settingsUiState.collectAsState() @@ -91,6 +92,7 @@ fun SettingsRoute( onClickServiceInfo = onNavigateServiceInfo, onClickPersonalInformationPolicy = onNavigatePersonalInformationPolicy, onClickNetworkLog = onNavigateNetworkLog, + onClickTest = onNavigateTest, onClickLogout = viewModel::showLogoutDialog, onConfirmLogout = viewModel::performLogout, onDismissLogout = viewModel::hideLogoutDialog, @@ -114,6 +116,7 @@ fun SettingsScreen( onClickServiceInfo: () -> Unit, onClickPersonalInformationPolicy: () -> Unit, onClickNetworkLog: () -> Unit, + onClickTest: () -> Unit, onClickLogout: () -> Unit, onConfirmLogout: () -> Unit, onDismissLogout: () -> Unit, @@ -275,6 +278,14 @@ fun SettingsScreen( onClick = onClickNetworkLog, ) } + + if (BuildConfig.DEBUG) { + Margin(height = 10.dp) + SettingItem( + title = "리팩토링 테스트", + onClick = onClickTest, + ) + } Margin(height = 10.dp) } } @@ -387,6 +398,6 @@ fun NewBadge( @Composable fun SettingsPagePreview() { SettingsScreen( - uiState = SettingsUiState("양주현", "다크", false, listOf("빈자리 알림")), onClickUserConfig = {}, onClickThemeModeSelect = {}, onClickTimeTableConfig = {}, onClickThemeConfig = {}, onClickVacancyNotification = {}, onClickThemeMarket = {}, onClickPushPreference = {}, onClickLectureDiary = {}, onClickTeamInfo = {}, onClickAppReport = {}, onClickOpenLicenses = {}, onClickServiceInfo = {}, onClickPersonalInformationPolicy = {}, onClickNetworkLog = {}, onClickLogout = {}, onConfirmLogout = {}, onDismissLogout = {}, + uiState = SettingsUiState("양주현", "다크", false, listOf("빈자리 알림")), onClickUserConfig = {}, onClickThemeModeSelect = {}, onClickTimeTableConfig = {}, onClickThemeConfig = {}, onClickVacancyNotification = {}, onClickThemeMarket = {}, onClickPushPreference = {}, onClickLectureDiary = {}, onClickTeamInfo = {}, onClickAppReport = {}, onClickOpenLicenses = {}, onClickServiceInfo = {}, onClickPersonalInformationPolicy = {}, onClickNetworkLog = {}, onClickTest = {}, onClickLogout = {}, onConfirmLogout = {}, onDismissLogout = {}, ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 35d4ed976..e6b4d51ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -66,7 +66,6 @@ 이미 빈자리 알림을 받고 있는 강의입니다. 사용할 수 없는 닉네임입니다. 알수없는 에러입니다 - 로그아웃에 실패하였습니다. 회원가입 아이디