diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 3f479e02d..5fc6110b8 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -21,10 +21,10 @@ tools:targetApi="tiramisu"> + android:label="Runnect Developer Mode" + android:theme="@style/Theme.Material3.Light.NoActionBar"> diff --git a/app/src/debug/java/com/runnect/runnect/developer/data/dto/ResponseServerStatus.kt b/app/src/debug/java/com/runnect/runnect/developer/data/dto/ResponseServerStatus.kt new file mode 100644 index 000000000..31b71cc41 --- /dev/null +++ b/app/src/debug/java/com/runnect/runnect/developer/data/dto/ResponseServerStatus.kt @@ -0,0 +1,10 @@ +package com.runnect.runnect.developer.data.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseServerStatus( + @SerialName("status") + val status: String +) \ No newline at end of file diff --git a/app/src/debug/java/com/runnect/runnect/developer/data/repository/ServerStatusRepositoryImpl.kt b/app/src/debug/java/com/runnect/runnect/developer/data/repository/ServerStatusRepositoryImpl.kt new file mode 100644 index 000000000..244918822 --- /dev/null +++ b/app/src/debug/java/com/runnect/runnect/developer/data/repository/ServerStatusRepositoryImpl.kt @@ -0,0 +1,18 @@ +package com.runnect.runnect.developer.data.repository + +import com.runnect.runnect.data.network.FlowResult +import com.runnect.runnect.data.network.toEntityResult +import com.runnect.runnect.developer.data.source.remote.ServerStatusDataSource +import com.runnect.runnect.developer.domain.ServerStatusRepository +import javax.inject.Inject + +class ServerStatusRepositoryImpl @Inject constructor( + private val serverStatusDataSource: ServerStatusDataSource +) : ServerStatusRepository { + + override suspend fun checkServerStatus(serverUrl: String): FlowResult { + return serverStatusDataSource.checkServerStatus(serverUrl).toEntityResult { + it.status + } + } +} \ No newline at end of file diff --git a/app/src/debug/java/com/runnect/runnect/developer/data/service/ServerStatusService.kt b/app/src/debug/java/com/runnect/runnect/developer/data/service/ServerStatusService.kt new file mode 100644 index 000000000..6316f7a83 --- /dev/null +++ b/app/src/debug/java/com/runnect/runnect/developer/data/service/ServerStatusService.kt @@ -0,0 +1,14 @@ +package com.runnect.runnect.developer.data.service + +import com.runnect.runnect.developer.data.dto.ResponseServerStatus +import kotlinx.coroutines.flow.Flow +import retrofit2.http.GET +import retrofit2.http.Url + +interface ServerStatusService { + + @GET + fun checkServerStatus( + @Url url: String + ): Flow> +} \ No newline at end of file diff --git a/app/src/debug/java/com/runnect/runnect/developer/data/source/remote/ServerStatusDataSource.kt b/app/src/debug/java/com/runnect/runnect/developer/data/source/remote/ServerStatusDataSource.kt new file mode 100644 index 000000000..afa6f3cf4 --- /dev/null +++ b/app/src/debug/java/com/runnect/runnect/developer/data/source/remote/ServerStatusDataSource.kt @@ -0,0 +1,15 @@ +package com.runnect.runnect.developer.data.source.remote + +import com.runnect.runnect.data.network.FlowResult +import com.runnect.runnect.developer.data.dto.ResponseServerStatus +import com.runnect.runnect.developer.data.service.ServerStatusService +import javax.inject.Inject + +class ServerStatusDataSource @Inject constructor( + private val serverStatusService: ServerStatusService, +) { + + fun checkServerStatus(serverUrl: String): FlowResult { + return serverStatusService.checkServerStatus(serverUrl) + } +} \ No newline at end of file diff --git a/app/src/debug/java/com/runnect/runnect/developer/domain/ServerStatusRepository.kt b/app/src/debug/java/com/runnect/runnect/developer/domain/ServerStatusRepository.kt new file mode 100644 index 000000000..c5a788c1f --- /dev/null +++ b/app/src/debug/java/com/runnect/runnect/developer/domain/ServerStatusRepository.kt @@ -0,0 +1,8 @@ +package com.runnect.runnect.developer.domain + +import com.runnect.runnect.data.network.FlowResult + +interface ServerStatusRepository { + + suspend fun checkServerStatus(serverUrl: String): FlowResult +} \ No newline at end of file diff --git a/app/src/debug/java/com/runnect/runnect/developer/enum/ServerStatus.kt b/app/src/debug/java/com/runnect/runnect/developer/enum/ServerStatus.kt new file mode 100644 index 000000000..f9c547b98 --- /dev/null +++ b/app/src/debug/java/com/runnect/runnect/developer/enum/ServerStatus.kt @@ -0,0 +1,31 @@ +package com.runnect.runnect.developer.enum + +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import com.runnect.runnect.R +import com.runnect.runnect.developer.presentation.RunnectDeveloperViewModel.ServerState + +enum class ServerStatus( + @ColorRes val colorRes: Int, + @StringRes val statusRes: Int, + @StringRes val summaryRes: Int, +) { + + CHECKING(R.color.blue, R.string.developer_server_status_checking_title, R.string.developer_server_status_checking_sub), + RUNNING(R.color.green, R.string.developer_server_status_running_title, R.string.developer_server_status_running_sub), + DEGRADED(R.color.orange, R.string.developer_server_status_degraded_title, R.string.developer_server_status_degraded_sub), + ERROR(R.color.red, R.string.developer_server_status_error_title, R.string.developer_server_status_error_sub), + UNKNOWN(R.color.grey, R.string.developer_server_status_unknown_title, R.string.developer_server_status_unknown_sub); + + companion object { + fun getStatus(state: ServerState): ServerStatus { + return when (state) { + ServerState.Running -> RUNNING + ServerState.Degraded -> DEGRADED + ServerState.Error -> ERROR + ServerState.Unknown -> UNKNOWN + ServerState.Checking -> CHECKING + } + } + } +} \ No newline at end of file diff --git a/app/src/debug/java/com/runnect/runnect/developer/RunnectDeveloperActivity.kt b/app/src/debug/java/com/runnect/runnect/developer/presentation/RunnectDeveloperActivity.kt similarity index 60% rename from app/src/debug/java/com/runnect/runnect/developer/RunnectDeveloperActivity.kt rename to app/src/debug/java/com/runnect/runnect/developer/presentation/RunnectDeveloperActivity.kt index 1acb231e2..116e79db4 100644 --- a/app/src/debug/java/com/runnect/runnect/developer/RunnectDeveloperActivity.kt +++ b/app/src/debug/java/com/runnect/runnect/developer/presentation/RunnectDeveloperActivity.kt @@ -1,14 +1,17 @@ -package com.runnect.runnect.developer +package com.runnect.runnect.developer.presentation import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.util.DisplayMetrics import android.view.WindowInsets import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.preference.ListPreference import androidx.preference.Preference @@ -17,40 +20,88 @@ import com.runnect.runnect.R import com.runnect.runnect.application.ApiMode import com.runnect.runnect.application.ApplicationClass import com.runnect.runnect.application.PreferenceManager +import com.runnect.runnect.developer.enum.ServerStatus +import com.runnect.runnect.developer.presentation.custom.ServerStatusPreference import com.runnect.runnect.util.custom.toast.RunnectToast import com.runnect.runnect.util.preference.AuthUtil.getAccessToken import com.runnect.runnect.util.preference.AuthUtil.getNewToken import com.runnect.runnect.util.preference.AuthUtil.saveToken import com.runnect.runnect.util.preference.StatusType.LoginStatus +import com.runnect.runnect.util.extension.repeatOnStarted +import com.runnect.runnect.util.extension.setStatusBarColor +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.system.exitProcess +@AndroidEntryPoint class RunnectDeveloperActivity : AppCompatActivity(R.layout.activity_runnect_developer) { class RunnectDeveloperFragment : PreferenceFragmentCompat() { + private val viewModel: RunnectDeveloperViewModel by activityViewModels() + private val clipboardManager: ClipboardManager? by lazy { context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.preferences_developer_menu, rootKey) + activity?.apply { + setStatusBarColor(window = window, isLightColor = true, colorResource = R.color.white) + } initUserInfo() initApiMode() initDeviceInfo() initDisplayInfo() + initObserve() + requestApi() + } + + private fun requestApi() { + with(viewModel) { + checkProdServerStatus() + checkTestServerStatus() + } + } + + private fun initObserve() { + val prodPref = findPreference("dev_pref_prod_server_status") + val testPref = findPreference("dev_pref_test_server_status") + + repeatOnStarted( + { + viewModel.prodStatus.collect { + prodPref?.setServerStatus(ServerStatus.getStatus(it)) + } + }, + { + viewModel.testStatus.collect { + testPref?.setServerStatus(ServerStatus.getStatus(it)) + } + } + ) } private fun initUserInfo() { val ctx: Context = context ?: return val accessToken = ctx.getAccessToken() val refreshToken = ctx.getNewToken() + val combinedToken = "${ApiMode.getCurrentApiMode(ctx).name} 서버\n[Access Token]: $accessToken\n\n---\n\n[Refresh Token]: $refreshToken" setPreferenceSummary("dev_pref_key_access_token", accessToken) setPreferenceSummary("dev_pref_key_refresh_token", refreshToken) + setPreferenceClickListener("dev_pref_key_share_tokens") { + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, combinedToken) + }.let { + startActivity(Intent.createChooser(it, "Share tokens via:")) + } + } } private fun initApiMode() { @@ -66,18 +117,20 @@ class RunnectDeveloperActivity : AppCompatActivity(R.layout.activity_runnect_dev title = currentApi.name setValueIndex(selectIndex) - setOnPreferenceChangeListener { preference, newValue -> + setOnPreferenceChangeListener { _, newValue -> val selectItem = newValue.toString() this.title = selectItem - PreferenceManager.apply { - setString(ctx, ApplicationClass.API_MODE, selectItem) + with(ctx) { + PreferenceManager.setString(this, ApplicationClass.API_MODE, selectItem) + saveToken( + accessToken = LoginStatus.NONE.value, + refreshToken = LoginStatus.NONE.value + ) + + restartApplication(this) } - ctx.saveToken( - accessToken = LoginStatus.NONE.value, - refreshToken = LoginStatus.NONE.value - ) - destroyApp(ctx) + true } } @@ -96,15 +149,9 @@ class RunnectDeveloperActivity : AppCompatActivity(R.layout.activity_runnect_dev val naviBarHeight = getNaviBarHeight(windowManager) with(metrics) { - setPreferenceSummary( - "dev_pref_display_ratio", - "$widthPixels x ${heightPixels + statusBarHeight + naviBarHeight}" - ) + setPreferenceSummary("dev_pref_display_ratio", "$widthPixels x ${heightPixels + statusBarHeight + naviBarHeight}") setPreferenceSummary("dev_pref_display_density", "${densityDpi}dp") - setPreferenceSummary( - "dev_pref_display_resource_bucket", - getDeviceResourseBucket(this) - ) + setPreferenceSummary("dev_pref_display_resource_bucket", getDeviceResourseBucket(this)) } } @@ -125,8 +172,7 @@ class RunnectDeveloperActivity : AppCompatActivity(R.layout.activity_runnect_dev private fun getStatusBarHeight(windowManager: WindowManager): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val windowMetrics = windowManager.currentWindowMetrics - val insets = - windowMetrics.windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.statusBars()) + val insets = windowMetrics.windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.statusBars()) insets.top } else { 0 @@ -136,8 +182,7 @@ class RunnectDeveloperActivity : AppCompatActivity(R.layout.activity_runnect_dev private fun getNaviBarHeight(windowManager: WindowManager): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val windowMetrics = windowManager.currentWindowMetrics - val insets = - windowMetrics.windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars()) + val insets = windowMetrics.windowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.navigationBars()) insets.bottom } else { 0 @@ -153,6 +198,15 @@ class RunnectDeveloperActivity : AppCompatActivity(R.layout.activity_runnect_dev } } + private fun setPreferenceClickListener(key: String, onClick: () -> Unit) { + findPreference(key)?.let { pref -> + pref.setOnPreferenceClickListener { + onClick.invoke() + true + } + } + } + private fun copyToText(text: String): Boolean { val clipData = ClipData.newPlainText(CLIPBOARD_LABEL, text) clipboardManager?.setPrimaryClip(clipData) @@ -166,14 +220,19 @@ class RunnectDeveloperActivity : AppCompatActivity(R.layout.activity_runnect_dev return true } - private fun destroyApp(context: Context) { + private fun restartApplication(context: Context) { + val packageManager: PackageManager = context.packageManager + val packageName = packageManager.getLaunchIntentForPackage(context.packageName) + val component = packageName?.component + lifecycleScope.launch(Dispatchers.Main) { - RunnectToast.createToast(context, getString(R.string.dev_mode_require_restart)) - .show() - delay(3000) + RunnectToast.createToast(context, getString(R.string.dev_mode_require_restart)).show() + delay(2000) - activity?.finishAffinity() //루트액티비티 종료 - exitProcess(0) + Intent.makeRestartActivityTask(component).apply { + startActivity(this) + exitProcess(0) + } } } diff --git a/app/src/debug/java/com/runnect/runnect/developer/presentation/RunnectDeveloperViewModel.kt b/app/src/debug/java/com/runnect/runnect/developer/presentation/RunnectDeveloperViewModel.kt new file mode 100644 index 000000000..cdac7709a --- /dev/null +++ b/app/src/debug/java/com/runnect/runnect/developer/presentation/RunnectDeveloperViewModel.kt @@ -0,0 +1,76 @@ +package com.runnect.runnect.developer.presentation + +import com.runnect.runnect.BuildConfig +import com.runnect.runnect.developer.domain.ServerStatusRepository +import com.runnect.runnect.domain.common.getCode +import com.runnect.runnect.presentation.base.BaseViewModel +import com.runnect.runnect.util.extension.collectResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import javax.inject.Inject + +@HiltViewModel +class RunnectDeveloperViewModel @Inject constructor( + private val serverStatusRepository: ServerStatusRepository +) : BaseViewModel() { + + private val _prodStatus: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val prodStatus: SharedFlow = _prodStatus.asSharedFlow() + + private val _testStatus: MutableSharedFlow = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val testStatus: SharedFlow = _testStatus.asSharedFlow() + + fun checkProdServerStatus() { + val prodServerUrl = "${BuildConfig.RUNNECT_PROD_URL}/actuator/health" + checkServerStatus(prodServerUrl, _prodStatus) + } + + fun checkTestServerStatus() { + val testServerUrl = "${BuildConfig.RUNNECT_DEV_URL}/actuator/health" + checkServerStatus(testServerUrl, _testStatus) + } + + private fun checkServerStatus( + serverUrl: String, + state: MutableSharedFlow + ) = launchWithHandler { + serverStatusRepository.checkServerStatus(serverUrl) + .flowOn(Dispatchers.IO) + .onStart { + state.emit(ServerState.Checking) + }.catch { + state.tryEmit(ServerState.Unknown) + }.collectResult( + onSuccess = { + state.tryEmit(ServerState.Running) + }, + onFailure = { + when (it.getCode()) { + 503 -> ServerState.Degraded + else -> ServerState.Error + }.let(state::tryEmit) + } + ) + } + + sealed interface ServerState { + object Running : ServerState + object Degraded : ServerState + object Error : ServerState + object Unknown : ServerState + object Checking : ServerState + } +} \ No newline at end of file diff --git a/app/src/debug/java/com/runnect/runnect/developer/presentation/custom/ServerStatusPreference.kt b/app/src/debug/java/com/runnect/runnect/developer/presentation/custom/ServerStatusPreference.kt new file mode 100644 index 000000000..0bbfb857b --- /dev/null +++ b/app/src/debug/java/com/runnect/runnect/developer/presentation/custom/ServerStatusPreference.kt @@ -0,0 +1,43 @@ +package com.runnect.runnect.developer.presentation.custom + +import android.content.Context +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +import android.view.View +import android.widget.TextView +import androidx.preference.Preference +import androidx.preference.PreferenceViewHolder +import com.runnect.runnect.R +import com.runnect.runnect.developer.enum.ServerStatus + +class ServerStatusPreference @JvmOverloads constructor( + context: Context, + attrs: AttributeSet, + defStyleAttr: Int = androidx.preference.R.attr.preferenceStyle, + defStyleRes: Int = 0 +) : Preference(context, attrs, defStyleAttr, defStyleRes) { + + init { + widgetLayoutResource = R.layout.pref_server_status_layout + } + + private var serverStatus: ServerStatus = ServerStatus.CHECKING + + override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + val indicator: View = holder.findViewById(R.id.viewIndicator) + val statusText: TextView = holder.findViewById(R.id.tvStatus) as TextView + val background = indicator.background as? GradientDrawable + + holder.itemView.post { + background?.setColor(context.getColor(serverStatus.colorRes)) + statusText.text = context.getString(serverStatus.statusRes) + summary = context.getString(serverStatus.summaryRes) + } + } + + fun setServerStatus(status: ServerStatus) { + serverStatus = status + notifyChanged() + } +} \ No newline at end of file diff --git a/app/src/debug/res/drawable/shape_server_status_indicator.xml b/app/src/debug/res/drawable/shape_server_status_indicator.xml new file mode 100644 index 000000000..7858227bb --- /dev/null +++ b/app/src/debug/res/drawable/shape_server_status_indicator.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/debug/res/layout/pref_server_status_layout.xml b/app/src/debug/res/layout/pref_server_status_layout.xml new file mode 100644 index 000000000..e159ad4d0 --- /dev/null +++ b/app/src/debug/res/layout/pref_server_status_layout.xml @@ -0,0 +1,28 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml new file mode 100644 index 000000000..9fdc0e2a2 --- /dev/null +++ b/app/src/debug/res/values/strings.xml @@ -0,0 +1,14 @@ + + + + Checking... + Running + Degraded + Error + Unknown + 서버 상태를 확인 중입니다. + 서버가 정상적으로 작동하고 있습니다. + 작동하고 있으나 일부 기능에 문제가 있습니다. + 작동 중이지 않거나 오류가 있습니다. + 서버 상태를 체크할 수 없습니다. + \ No newline at end of file diff --git a/app/src/debug/res/xml/preferences_developer_menu.xml b/app/src/debug/res/xml/preferences_developer_menu.xml index 4684b1ecc..24c0558e3 100644 --- a/app/src/debug/res/xml/preferences_developer_menu.xml +++ b/app/src/debug/res/xml/preferences_developer_menu.xml @@ -2,6 +2,11 @@ + + @@ -15,6 +20,11 @@ android:key="dev_pref_key_refresh_token" android:title="리프레시 토큰" app:iconSpaceReserved="false" /> + + + + + + + + + @@ -39,7 +66,7 @@ + app:iconSpaceReserved="false" /> = Flow> + +@Deprecated(message = "Use toEntityResult instead.") +fun Result.mapToFlowResult( + mapper: (R) -> D +): FlowResult = flow { + val result = when { + this@mapToFlowResult.isSuccess -> Result.success( + // CallAdapter에서 body가 null인 경우도 걸러주고 있으므로 + // Result.success의 데이터가 null인 경우는 없을듯함 + mapper(this@mapToFlowResult.getOrNull()!!) + ) + + else -> Result.failure( + this@mapToFlowResult.exceptionOrNull() ?: RunnectException() + ) + } + + emit(result) +} + +fun FlowResult.toEntityResult(mapper: (R) -> D): FlowResult { + fun Result.parseResult(mapper: (R) -> D): Result = when { + this.isSuccess -> Result.success( + // CallAdapter에서 body가 null인 경우도 걸러주고 있으므로 + // Result.success의 데이터가 null인 경우는 없을듯함 + mapper(this.getOrNull()!!) + ) + else -> Result.failure( + this.exceptionOrNull() ?: RunnectException() + ) + } + + return map { + it.parseResult(mapper) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt b/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt new file mode 100644 index 000000000..4d473b167 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt @@ -0,0 +1,76 @@ +package com.runnect.runnect.data.network.calladapter.flow + +import com.google.gson.Gson +import com.runnect.runnect.data.dto.response.base.ErrorResponse +import com.runnect.runnect.domain.common.RunnectException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.suspendCancellableCoroutine +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Callback +import retrofit2.Response +import java.lang.reflect.Type +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class FlowCallAdapter( + private val responseType: Type +) : CallAdapter>> { + + private val gson = Gson() + override fun responseType() = responseType + + // Retrofit의 Call을 Result<>로 변환 + override fun adapt(call: Call): Flow> = flow { + emit(flowApiCall(call)) + } + + private suspend fun flowApiCall(call: Call): Result { + return suspendCancellableCoroutine { continuation -> + call.enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(parseResponse(response)) + } + + override fun onFailure(call: Call, t: Throwable) { + continuation.resumeWithException(t) + } + }) + + continuation.invokeOnCancellation { + call.cancel() + } + } + } + + private fun parseResponse(response: Response): Result { + val nullBodyException by lazy { + RunnectException(response.code(), ERROR_MSG_RESPONSE_IS_NULL) + } + + if (!response.isSuccessful) { + return Result.failure(parseErrorResponse(response)) + } + + return response.body()?.let { + Result.success(it) + } ?: Result.failure(nullBodyException) + } + + // Response에서 오류를 파싱하여 RunnectException 객체를 생성 + private fun parseErrorResponse(response: Response<*>): RunnectException { + val errorBodyString = response.errorBody()?.string() + val errorResponse = errorBodyString?.let { + gson.fromJson(it, ErrorResponse::class.java) + } + + val errorMessage = errorResponse?.message ?: errorResponse?.error ?: ERROR_MSG_COMMON + return RunnectException(response.code(), errorMessage) + } + + companion object { + private const val ERROR_MSG_COMMON = "알 수 없는 에러가 발생하였습니다." + private const val ERROR_MSG_RESPONSE_IS_NULL = "데이터를 불러올 수 없습니다." + } +} \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapterFactory.kt b/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapterFactory.kt new file mode 100644 index 000000000..c0a7599a8 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapterFactory.kt @@ -0,0 +1,46 @@ +package com.runnect.runnect.data.network.calladapter.flow + +import kotlinx.coroutines.flow.Flow +import retrofit2.CallAdapter +import retrofit2.Retrofit +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +class FlowCallAdapterFactory private constructor() : CallAdapter.Factory() { + + override fun get( + returnType: Type, + annotations: Array, + retrofit: Retrofit + ): CallAdapter<*, *>? { + // 최상위 타입이 Flow인지 체크 + if (getRawType(returnType) != Flow::class.java) { + return null + } + + check(returnType is ParameterizedType) { + "Flow return type must be parameterized as Flow or Flow" + } + + val responseType = getParameterUpperBound(0, returnType) + if (getRawType(responseType) != Result::class.java) { + return null + } + + check(responseType is ParameterizedType) { + "Result return type must be parameterized as Result or Result" + } + + return FlowCallAdapter( + getParameterUpperBound( + 0, + responseType + ) + ) + } + + companion object { + @JvmStatic + fun create() = FlowCallAdapterFactory() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/data/network/mapToFlowResult.kt b/app/src/main/java/com/runnect/runnect/data/network/mapToFlowResult.kt deleted file mode 100644 index 958d453f1..000000000 --- a/app/src/main/java/com/runnect/runnect/data/network/mapToFlowResult.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.runnect.runnect.data.network - -import com.runnect.runnect.domain.common.RunnectException -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - -fun Result.mapToFlowResult( - mapper: (R) -> D -): Flow> = flow { - val result = when { - this@mapToFlowResult.isSuccess -> Result.success( - // CallAdapter에서 body가 null인 경우도 걸러주고 있으므로 - // Result.success의 데이터가 null인 경우는 없을듯함 - mapper(this@mapToFlowResult.getOrNull()!!) - ) - - else -> Result.failure( - this@mapToFlowResult.exceptionOrNull() ?: RunnectException() - ) - } - - emit(result) -} \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt b/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt index 7c7c7554b..74382a1c3 100644 --- a/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt +++ b/app/src/main/java/com/runnect/runnect/di/RepositoryModule.kt @@ -3,6 +3,8 @@ package com.runnect.runnect.di import com.runnect.runnect.data.repository.* import com.runnect.runnect.data.service.* import com.runnect.runnect.data.source.remote.* +import com.runnect.runnect.developer.data.repository.ServerStatusRepositoryImpl +import com.runnect.runnect.developer.domain.ServerStatusRepository import com.runnect.runnect.domain.* import com.runnect.runnect.domain.repository.BannerRepository import com.runnect.runnect.domain.repository.CourseRepository @@ -48,4 +50,7 @@ interface RepositoryModule { @Binds fun bindBannerRepository(bannerRepositoryImpl: BannerRepositoryImpl): BannerRepository + @Singleton + @Binds + fun bindServerStatusRepository(serverStatusRepositoryImpl: ServerStatusRepositoryImpl): ServerStatusRepository } \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/di/RetrofitModule.kt b/app/src/main/java/com/runnect/runnect/di/RetrofitModule.kt index b1d61a8c5..f8704f069 100644 --- a/app/src/main/java/com/runnect/runnect/di/RetrofitModule.kt +++ b/app/src/main/java/com/runnect/runnect/di/RetrofitModule.kt @@ -5,6 +5,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact import com.runnect.runnect.BuildConfig import com.runnect.runnect.application.ApplicationClass import com.runnect.runnect.data.network.calladapter.ResultCallAdapterFactory +import com.runnect.runnect.data.network.calladapter.flow.FlowCallAdapterFactory import com.runnect.runnect.data.network.interceptor.ResponseInterceptor import com.runnect.runnect.data.repository.* import com.runnect.runnect.data.service.* @@ -38,6 +39,10 @@ object RetrofitModule { @Retention(AnnotationRetention.BINARY) annotation class RetrofitV2 + @Qualifier + @Retention(AnnotationRetention.BINARY) + annotation class RetrofitFlow + @Qualifier @Retention(AnnotationRetention.BINARY) annotation class Tmap @@ -131,6 +136,24 @@ object RetrofitModule { return retrofit ?: throw RuntimeException("Retrofit creation failed.") } + @OptIn(ExperimentalSerializationApi::class, InternalCoroutinesApi::class) + @Provides + @Singleton + @RetrofitFlow + fun provideRunnectRetrofitFlow( + @HttpClientV2 client: OkHttpClient + ): Retrofit { + val baseUrl = ApplicationClass.getBaseUrl() + val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(FlowCallAdapterFactory.create()) + .build() + + return retrofit ?: throw RuntimeException("Retrofit creation failed.") + } + @OptIn(ExperimentalSerializationApi::class, InternalCoroutinesApi::class) @Provides @Singleton diff --git a/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt b/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt index d83a93bb9..8cf9e6b37 100644 --- a/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt +++ b/app/src/main/java/com/runnect/runnect/di/ServiceModule.kt @@ -7,6 +7,7 @@ import com.runnect.runnect.data.service.* import com.runnect.runnect.data.service.LoginService import com.runnect.runnect.data.service.UserService import com.runnect.runnect.data.source.remote.* +import com.runnect.runnect.developer.data.service.ServerStatusService import com.runnect.runnect.domain.* import dagger.Module import dagger.Provides @@ -34,6 +35,11 @@ object ServiceModule { fun providePCourseService(@RetrofitModule.RetrofitV2 retrofitV2: Retrofit) = retrofitV2.create(CourseService::class.java) + @Singleton + @Provides + fun provideSeverStatusService(@RetrofitModule.RetrofitFlow retrofitV2Flow: Retrofit) = + retrofitV2Flow.create(ServerStatusService::class.java) + @Singleton @Provides fun provideKSearchService(@RetrofitModule.Tmap tmapRetrofit: Retrofit) = diff --git a/app/src/main/java/com/runnect/runnect/util/extension/ContextExt.kt b/app/src/main/java/com/runnect/runnect/util/extension/ContextExt.kt index 7caac816e..df4131940 100644 --- a/app/src/main/java/com/runnect/runnect/util/extension/ContextExt.kt +++ b/app/src/main/java/com/runnect/runnect/util/extension/ContextExt.kt @@ -7,9 +7,12 @@ import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.net.Uri +import android.os.Build import android.view.Gravity import android.view.LayoutInflater import android.view.View +import android.view.Window +import android.view.WindowInsetsController import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.annotation.ColorRes @@ -33,6 +36,9 @@ import kotlinx.android.synthetic.main.custom_dialog_delete.view.tv_dialog import kotlinx.android.synthetic.main.custom_dialog_edit_mode.layout_delete_frame import kotlinx.android.synthetic.main.custom_dialog_edit_mode.layout_edit_frame import kotlinx.android.synthetic.main.fragment_bottom_sheet.btn_delete_yes +import timber.log.Timber +import java.lang.IllegalArgumentException +import java.lang.NullPointerException fun Context.setActivityDialog( layoutInflater: LayoutInflater, @@ -188,4 +194,35 @@ fun Context.colorOf(@ColorRes resId: Int) = ContextCompat.getColor(this, resId) fun Context.drawableOf(@DrawableRes resId: Int) = ContextCompat.getDrawable(this, resId) fun Context.fontOf(@FontRes resId: Int, @StyleRes style: Int): Typeface = - Typeface.create(ResourcesCompat.getFont(this, resId), style) \ No newline at end of file + Typeface.create(ResourcesCompat.getFont(this, resId), style) + +/** + * Status Bar 색상을 변경 + * isLightColor : 변경할 status bar 색상이 밝은 색인지 (시계, 노티 아이콘 등 색상을 어둡게 변경하기 위해서) + * colorResource : 변경할 색상 리소스 + */ +fun Context.setStatusBarColor(window: Window?, isLightColor: Boolean, colorResource: Int) { + runCatching { + window?.apply { + // 조건에 따른 시스템 UI 가시성 설정 + if (isLightColor) { + val flag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + } else { + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + insetsController?.setSystemBarsAppearance(flag, flag) + } else { + decorView.systemUiVisibility = flag + } + } + + // Status bar 색상 설정 + statusBarColor = ContextCompat.getColor(context, colorResource) + } + }.onFailure { e -> + Timber.e("Failed to set status bar color: ${e.message}") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/util/extension/FlowExt.kt b/app/src/main/java/com/runnect/runnect/util/extension/FlowExt.kt index b640d4f61..d231da898 100644 --- a/app/src/main/java/com/runnect/runnect/util/extension/FlowExt.kt +++ b/app/src/main/java/com/runnect/runnect/util/extension/FlowExt.kt @@ -1,12 +1,42 @@ package com.runnect.runnect.util.extension +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch suspend fun Flow>.collectResult( - onSuccess: (T) -> Unit, - onFailure: (Throwable) -> Unit + onSuccess: ((T) -> Unit)? = null, + onFailure: ((Throwable) -> Unit)? = null ) { collect { result -> - result.fold(onSuccess, onFailure) + result.fold(onSuccess ?: {}, onFailure ?: {}) + } +} + +fun Flow>.onEachResult( + onSuccess: ((T) -> Unit)? = null, + onFailure: ((Throwable) -> Unit)? = null +): Flow> { + return onEach { result -> + result.fold(onSuccess ?: {}, onFailure ?: {}) + } +} + +fun LifecycleOwner.repeatOnStarted(block: suspend CoroutineScope.() -> Unit) { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, block) + } +} + +fun LifecycleOwner.repeatOnStarted(vararg blocks: suspend CoroutineScope.() -> Unit) { + blocks.forEach { + lifecycleScope.launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED, it) + } } } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_runnect_developer.xml b/app/src/main/res/layout/activity_runnect_developer.xml index 44d518832..f94693f2f 100644 --- a/app/src/main/res/layout/activity_runnect_developer.xml +++ b/app/src/main/res/layout/activity_runnect_developer.xml @@ -5,11 +5,11 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/white" - tools:context=".developer.RunnectDeveloperActivity"> + tools:context=".developer.presentation.RunnectDeveloperActivity"> diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index a2460a14c..c98b8ad2a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -30,4 +30,9 @@ #26593EEC #FEE500 #80313131 + #9E9E9E + #5CFF62 + #FF473A + #FFC617 + #55B4FF \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b0841567..4eb7f2b0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,7 +119,7 @@ 지도를 움직여 출발지를 설정해주세요 지도에서 선택 다음으로 - 3초 후 강제 종료됩니다. 앱을 재실행 해주세요. + 앱이 재실행됩니다. 잠시만 기다려주세요. "클립보드에 복사되었습니다."