Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.sopt.clody.core.network

import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object NetworkConnectivityModule {

@Provides
@Singleton
fun provideNetworkConnectivityObserver(
@ApplicationContext context: Context,
): NetworkConnectivityObserver {
return NetworkConnectivityObserver(context)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.sopt.clody.core.network

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import javax.inject.Inject

/**
* 네트워크 연결 상태를 관찰하는 Observer.
*
* - `Available`: 인터넷에 연결되어 있음
* - `Unavailable`: 인터넷 연결이 끊긴 상태
*
*/
class NetworkConnectivityObserver @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

/**
* 네트워크 상태를 실시간으로 스트리밍하는 Flow.
*
* - 최초 구독 시 현재 상태를 먼저 전송 ->
* - 이후 네트워크 변경 이벤트를 수신하여 상태를 전송
* - 중복 상태 전송은 [distinctUntilChanged]로 방지 하도록 함.
*/
val networkStatus: Flow<NetworkStatus> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {

/**
* 네트워크가 변경되었을 때 호출됨.
* 유효한 인터넷 연결이 있는지 확인하여 상태를 전송.
*/
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) {
val hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
trySend(if (hasInternet) NetworkStatus.Available else NetworkStatus.Unavailable)
}

/**
* 네트워크 연결이 완전히 끊겼을 때 호출.
*/
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable)
}
}

trySend(if (isCurrentlyAvailable()) NetworkStatus.Available else NetworkStatus.Unavailable)

val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()

connectivityManager.registerNetworkCallback(request, callback)

awaitClose {
connectivityManager.unregisterNetworkCallback(callback)
}
}.distinctUntilChanged()

/**
* 현재 활성 네트워크가 인터넷에 연결되어 있는지를 반환
*
* @return 인터넷 연결 여부
*/
private fun isCurrentlyAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.sopt.clody.core.network

sealed class NetworkStatus {
data object Available : NetworkStatus()
data object Unavailable : NetworkStatus()
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.sopt.clody.data.remote.datasource

import com.sopt.clody.data.remote.dto.base.ApiResponse
import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto
import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
Expand All @@ -11,13 +10,13 @@ import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto

interface DiaryRemoteDataSource {
suspend fun writeDiary(lang: String, date: String, content: List<String>): ApiResponse<WriteDiaryResponseDto>
suspend fun deleteDailyDiary(year: Int, month: Int, date: Int): ApiResponse<DailyDiariesResponseDto>
suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): ApiResponse<DailyDiariesResponseDto>
suspend fun getDiaryTime(year: Int, month: Int, date: Int): ApiResponse<DiaryTimeResponseDto>
suspend fun getMonthlyCalendarData(year: Int, month: Int): ApiResponse<MonthlyCalendarResponseDto>
suspend fun getMonthlyDiary(year: Int, month: Int): ApiResponse<MonthlyDiaryResponseDto>
suspend fun getReplyDiary(year: Int, month: Int, date: Int): ApiResponse<ReplyDiaryResponseDto>
suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): ApiResponse<DraftDiariesResponseDto>
suspend fun saveDraftDiary(request: SaveDraftDiaryRequestDto): ApiResponse<Unit>
suspend fun writeDiary(lang: String, date: String, content: List<String>): Result<WriteDiaryResponseDto>
suspend fun deleteDailyDiary(year: Int, month: Int, date: Int): Result<DailyDiariesResponseDto>
suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): Result<DailyDiariesResponseDto>
suspend fun getDiaryTime(year: Int, month: Int, date: Int): Result<DiaryTimeResponseDto>
suspend fun getMonthlyCalendarData(year: Int, month: Int): Result<MonthlyCalendarResponseDto>
suspend fun getMonthlyDiary(year: Int, month: Int): Result<MonthlyDiaryResponseDto>
suspend fun getReplyDiary(year: Int, month: Int, date: Int): Result<ReplyDiaryResponseDto>
suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): Result<DraftDiariesResponseDto>
suspend fun saveDraftDiary(request: SaveDraftDiaryRequestDto): Result<Unit>
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,41 @@ package com.sopt.clody.data.remote.datasourceimpl

import com.sopt.clody.data.remote.api.DiaryService
import com.sopt.clody.data.remote.datasource.DiaryRemoteDataSource
import com.sopt.clody.data.remote.dto.base.ApiResponse
import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto
import com.sopt.clody.data.remote.dto.request.WriteDiaryRequestDto
import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
import com.sopt.clody.data.remote.dto.response.DraftDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto
import com.sopt.clody.data.remote.util.safeApiCall
import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import javax.inject.Inject

class DiaryRemoteDataSourceImpl @Inject constructor(
private val diaryService: DiaryService,
private val errorMessageProvider: ErrorMessageProvider,
) : DiaryRemoteDataSource {
override suspend fun writeDiary(lang: String, date: String, content: List<String>): ApiResponse<WriteDiaryResponseDto> =
diaryService.writeDiary(lang, WriteDiaryRequestDto(date, content))

override suspend fun deleteDailyDiary(year: Int, month: Int, date: Int): ApiResponse<DailyDiariesResponseDto> =
diaryService.deleteDailyDiary(year = year, month = month, date = date)
override suspend fun writeDiary(lang: String, date: String, content: List<String>) =
safeApiCall(errorMessageProvider) { diaryService.writeDiary(lang, WriteDiaryRequestDto(date, content)) }

override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): ApiResponse<DailyDiariesResponseDto> =
diaryService.getDailyDiariesData(year = year, month = month, date = date)
override suspend fun deleteDailyDiary(year: Int, month: Int, date: Int) =
safeApiCall(errorMessageProvider) { diaryService.deleteDailyDiary(year, month, date) }

override suspend fun getDiaryTime(year: Int, month: Int, date: Int): ApiResponse<DiaryTimeResponseDto> =
diaryService.getDiaryTime(year = year, month = month, date = date)
override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int) =
safeApiCall(errorMessageProvider) { diaryService.getDailyDiariesData(year, month, date) }

override suspend fun getMonthlyCalendarData(year: Int, month: Int): ApiResponse<MonthlyCalendarResponseDto> =
diaryService.getMonthlyCalendarData(year = year, month = month)
override suspend fun getDiaryTime(year: Int, month: Int, date: Int) =
safeApiCall(errorMessageProvider) { diaryService.getDiaryTime(year, month, date) }

override suspend fun getMonthlyDiary(year: Int, month: Int): ApiResponse<MonthlyDiaryResponseDto> =
diaryService.getMonthlyDiary(year = year, month = month)
override suspend fun getMonthlyCalendarData(year: Int, month: Int) =
safeApiCall(errorMessageProvider) { diaryService.getMonthlyCalendarData(year, month) }

override suspend fun getReplyDiary(year: Int, month: Int, date: Int): ApiResponse<ReplyDiaryResponseDto> =
diaryService.getReplyDiary(year = year, month = month, date = date)
override suspend fun getMonthlyDiary(year: Int, month: Int) =
safeApiCall(errorMessageProvider) { diaryService.getMonthlyDiary(year, month) }

override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): ApiResponse<DraftDiariesResponseDto> =
diaryService.fetchDraftDiary(year = year, month = month, date = date)
override suspend fun getReplyDiary(year: Int, month: Int, date: Int) =
safeApiCall(errorMessageProvider) { diaryService.getReplyDiary(year, month, date) }

override suspend fun saveDraftDiary(request: SaveDraftDiaryRequestDto): ApiResponse<Unit> =
diaryService.saveDraftDiary(request)
override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int) =
safeApiCall(errorMessageProvider) { diaryService.fetchDraftDiary(year, month, date) }

override suspend fun saveDraftDiary(request: SaveDraftDiaryRequestDto) =
safeApiCall(errorMessageProvider) { diaryService.saveDraftDiary(request) }
}
5 changes: 5 additions & 0 deletions app/src/main/java/com/sopt/clody/data/remote/util/ApiError.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.sopt.clody.data.remote.util

data class ApiError(
override val message: String,
) : Exception()
Comment on lines +3 to +5
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

ApiError won’t compile – illegal override of nullable message

Exception.message is declared as String?. Overriding it with a non-nullable String violates the Liskov rule and the Kotlin compiler rejects it.

-data class ApiError(
-    override val message: String,
-) : Exception()
+data class ApiError(
+    val errorMessage: String,
+) : Exception(errorMessage)

This preserves the non-null guarantee for callers via errorMessage while delegating to the base Exception correctly.

🤖 Prompt for AI Agents
In app/src/main/java/com/sopt/clody/data/remote/util/ApiError.kt around lines 3
to 5, the override of the Exception.message property is declared as non-nullable
String, but Exception.message is nullable String?, causing a compilation error.
Change the override to be nullable String? to match the base class signature,
and if a non-nullable message is needed, add a separate property like
errorMessage that guarantees non-nullability while delegating the nullable
message to the base Exception.

12 changes: 0 additions & 12 deletions app/src/main/java/com/sopt/clody/data/remote/util/NetworkUtil.kt

This file was deleted.

25 changes: 25 additions & 0 deletions app/src/main/java/com/sopt/clody/data/remote/util/SafeApiCall.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.sopt.clody.data.remote.util

import com.sopt.clody.data.remote.dto.base.ApiResponse
import com.sopt.clody.presentation.utils.network.ErrorMessageProvider
import java.io.IOException
import kotlin.coroutines.cancellation.CancellationException

suspend fun <T> safeApiCall(
errorMessageProvider: ErrorMessageProvider,
action: suspend () -> ApiResponse<T>,
): Result<T> {
return try {
val response = action()
response.data?.let { Result.success(it) }
?: Result.failure(ApiError(errorMessageProvider.getTemporaryError()))
} catch (exception: Throwable) {
if (exception is CancellationException) throw exception

val error = when (exception) {
is IOException -> ApiError(errorMessageProvider.getNetworkError())
else -> ApiError(errorMessageProvider.getTemporaryError())
}
Result.failure(error)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,101 +2,39 @@ package com.sopt.clody.data.repositoryimpl

import com.sopt.clody.data.remote.datasource.DiaryRemoteDataSource
import com.sopt.clody.data.remote.dto.request.SaveDraftDiaryRequestDto
import com.sopt.clody.data.remote.dto.response.DailyDiariesResponseDto
import com.sopt.clody.data.remote.dto.response.DiaryTimeResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyCalendarResponseDto
import com.sopt.clody.data.remote.dto.response.MonthlyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.ReplyDiaryResponseDto
import com.sopt.clody.data.remote.dto.response.WriteDiaryResponseDto
import com.sopt.clody.data.remote.util.handleApiResponse
import com.sopt.clody.domain.model.DraftDiaryContents
import com.sopt.clody.domain.repository.DiaryRepository
import com.sopt.clody.presentation.utils.network.ErrorMessages
import com.sopt.clody.presentation.utils.network.ErrorMessages.FAILURE_TEMPORARY_MESSAGE
import retrofit2.HttpException
import javax.inject.Inject

class DiaryRepositoryImpl @Inject constructor(
private val diaryRemoteDataSource: DiaryRemoteDataSource,
) : DiaryRepository {
override suspend fun writeDiary(lang: String, date: String, content: List<String>): Result<WriteDiaryResponseDto> =
runCatching {
diaryRemoteDataSource.writeDiary(lang, date, content).handleApiResponse().getOrThrow()
}

override suspend fun deleteDailyDiary(year: Int, month: Int, day: Int): Result<DailyDiariesResponseDto> =
runCatching {
diaryRemoteDataSource.deleteDailyDiary(year, month, day).handleApiResponse().getOrThrow()
}
override suspend fun writeDiary(lang: String, date: String, content: List<String>) =
diaryRemoteDataSource.writeDiary(lang, date, content)

override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int): Result<DailyDiariesResponseDto> =
runCatching {
diaryRemoteDataSource.getDailyDiariesData(year, month, date).handleApiResponse()
}.getOrElse { exception ->
val errorMessage = when (exception) {
is HttpException -> {
when (exception.code()) {
in 400..499 -> ErrorMessages.FAILURE_TEMPORARY_MESSAGE
in 500..599 -> ErrorMessages.FAILURE_SERVER_MESSAGE
else -> ErrorMessages.UNKNOWN_ERROR
}
}
else -> exception.message ?: ErrorMessages.UNKNOWN_ERROR
}
Result.failure(Exception(errorMessage))
}
override suspend fun deleteDailyDiary(year: Int, month: Int, day: Int) =
diaryRemoteDataSource.deleteDailyDiary(year, month, day)

override suspend fun getDiaryTime(year: Int, month: Int, date: Int): Result<DiaryTimeResponseDto> =
runCatching {
diaryRemoteDataSource.getDiaryTime(year, month, date).data
}
override suspend fun getDailyDiariesData(year: Int, month: Int, date: Int) =
diaryRemoteDataSource.getDailyDiariesData(year, month, date)

override suspend fun getMonthlyCalendarData(year: Int, month: Int): Result<MonthlyCalendarResponseDto> =
runCatching {
diaryRemoteDataSource.getMonthlyCalendarData(year, month).handleApiResponse()
}.getOrElse { exception ->
val errorMessage = when (exception) {
is HttpException -> {
when (exception.code()) {
in 400..499 -> ErrorMessages.FAILURE_TEMPORARY_MESSAGE
in 500..599 -> ErrorMessages.FAILURE_SERVER_MESSAGE
else -> ErrorMessages.UNKNOWN_ERROR
}
}
override suspend fun getDiaryTime(year: Int, month: Int, date: Int) =
diaryRemoteDataSource.getDiaryTime(year, month, date)

else -> exception.message ?: ErrorMessages.UNKNOWN_ERROR
}
Result.failure(Exception(errorMessage))
}
override suspend fun getMonthlyCalendarData(year: Int, month: Int) =
diaryRemoteDataSource.getMonthlyCalendarData(year, month)

override suspend fun getMonthlyDiary(year: Int, month: Int): Result<MonthlyDiaryResponseDto> =
runCatching {
diaryRemoteDataSource.getMonthlyDiary(year, month).handleApiResponse().getOrThrow()
}
override suspend fun getMonthlyDiary(year: Int, month: Int) =
diaryRemoteDataSource.getMonthlyDiary(year, month)

override suspend fun getReplyDiary(year: Int, month: Int, date: Int): Result<ReplyDiaryResponseDto> =
runCatching {
val response = diaryRemoteDataSource.getReplyDiary(year, month, date).data
if (response.content == null) {
throw IllegalStateException(FAILURE_TEMPORARY_MESSAGE)
}
response
}
override suspend fun getReplyDiary(year: Int, month: Int, date: Int) =
diaryRemoteDataSource.getReplyDiary(year, month, date)

override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int): Result<DraftDiaryContents> =
runCatching {
diaryRemoteDataSource
.fetchDraftDiary(year, month, date)
.handleApiResponse()
.getOrThrow()
.toDomain()
}
override suspend fun fetchDraftDiary(year: Int, month: Int, date: Int) =
diaryRemoteDataSource.fetchDraftDiary(year, month, date).map { it.toDomain() }

override suspend fun saveDraftDiary(date: String, contents: List<String>): Result<Unit> =
runCatching {
diaryRemoteDataSource
.saveDraftDiary(SaveDraftDiaryRequestDto(date = date, draftDiaries = contents))
.handleApiResponse()
.getOrThrow()
}
override suspend fun saveDraftDiary(date: String, contents: List<String>): Result<Unit> {
val request = SaveDraftDiaryRequestDto(date, contents)
return diaryRemoteDataSource.saveDraftDiary(request)
}
}
9 changes: 0 additions & 9 deletions app/src/main/java/com/sopt/clody/di/NetworkModule.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package com.sopt.clody.di

import android.content.Context
import android.net.ConnectivityManager
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.sopt.clody.BuildConfig
import com.sopt.clody.data.datastore.TokenDataStore
import com.sopt.clody.data.remote.util.AuthInterceptor
import com.sopt.clody.data.remote.util.NetworkUtil
import com.sopt.clody.data.remote.util.TimeZoneInterceptor
import com.sopt.clody.domain.repository.TokenReissueRepository
import dagger.Module
Expand Down Expand Up @@ -79,11 +77,4 @@ object NetworkModule {
.client(okHttpClient)
.build()
}

@Provides
@Singleton
fun provideNetworkUtil(@ApplicationContext context: Context): NetworkUtil {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return NetworkUtil(connectivityManager)
}
}
Loading