From 16c21d1bb7cf545e5434052a588b8b4e71bad02b Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 15:22:27 +0300 Subject: [PATCH] android: unify api error mapping across data repositories --- android/CHANGELOG.md | 5 +++ .../auth/repository/NetworkAuthRepository.kt | 27 ++++-------- .../chat/repository/NetworkChatRepository.kt | 16 +------ .../messenger/data/common/ApiErrorMapper.kt | 31 +++++++++++++ .../repository/NetworkMediaRepository.kt | 15 +------ .../repository/NetworkMessageRepository.kt | 16 +------ .../data/common/ApiErrorMapperTest.kt | 43 +++++++++++++++++++ docs/android-checklist.md | 2 +- 8 files changed, 91 insertions(+), 64 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/common/ApiErrorMapper.kt create mode 100644 android/app/src/test/java/ru/daemonlord/messenger/data/common/ApiErrorMapperTest.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index d1d3115..a6579f3 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -333,3 +333,8 @@ ### Step 55 - Chat multi-select action cleanup - Removed duplicate forward action in multi-select mode (`Forward selected`), leaving a single clear forward action button. + +### Step 56 - Unified API error handling +- Added shared API error mapper (`ApiErrorMapper`) with mode-aware mapping (`DEFAULT`, `LOGIN`). +- Switched auth/chat/message/media repositories to a single `Throwable -> AppError` mapping source. +- Kept login-specific invalid-credentials mapping while standardizing unauthorized/server/network handling for other API calls. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt index 2eef809..9251662 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt @@ -2,7 +2,6 @@ package ru.daemonlord.messenger.data.auth.repository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext -import retrofit2.HttpException import ru.daemonlord.messenger.core.token.TokenBundle import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.data.auth.api.AuthApiService @@ -10,13 +9,14 @@ import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto +import ru.daemonlord.messenger.data.common.ApiErrorMode +import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult -import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -44,7 +44,7 @@ class NetworkAuthRepository @Inject constructor( ) getMe() } catch (error: Throwable) { - AppResult.Error(error.toAppError(forLogin = true)) + AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN)) } } @@ -65,7 +65,7 @@ class NetworkAuthRepository @Inject constructor( AppResult.Success(Unit) } catch (error: Throwable) { tokenRepository.clearTokens() - AppResult.Error(error.toAppError(forLogin = false)) + AppResult.Error(error.toAppError()) } } @@ -74,7 +74,7 @@ class NetworkAuthRepository @Inject constructor( val user = authApiService.me().toDomain() AppResult.Success(user) } catch (error: Throwable) { - AppResult.Error(error.toAppError(forLogin = false)) + AppResult.Error(error.toAppError()) } } @@ -102,7 +102,7 @@ class NetworkAuthRepository @Inject constructor( try { AppResult.Success(authApiService.sessions().map { it.toDomain() }) } catch (error: Throwable) { - AppResult.Error(error.toAppError(forLogin = false)) + AppResult.Error(error.toAppError()) } } @@ -111,7 +111,7 @@ class NetworkAuthRepository @Inject constructor( authApiService.revokeSession(jti = jti) AppResult.Success(Unit) } catch (error: Throwable) { - AppResult.Error(error.toAppError(forLogin = false)) + AppResult.Error(error.toAppError()) } } @@ -120,7 +120,7 @@ class NetworkAuthRepository @Inject constructor( authApiService.revokeAllSessions() AppResult.Success(Unit) } catch (error: Throwable) { - AppResult.Error(error.toAppError(forLogin = false)) + AppResult.Error(error.toAppError()) } } @@ -150,15 +150,4 @@ class NetworkAuthRepository @Inject constructor( ) } - private fun Throwable.toAppError(forLogin: Boolean): AppError { - return when (this) { - is IOException -> AppError.Network - is HttpException -> when (code()) { - 400 -> if (forLogin) AppError.InvalidCredentials else AppError.Server(message = message()) - 401, 403 -> if (forLogin) AppError.InvalidCredentials else AppError.Unauthorized - else -> AppError.Server(message = message()) - } - else -> AppError.Unknown(cause = this) - } - } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt index cec550a..70741f8 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/NetworkChatRepository.kt @@ -8,20 +8,18 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.coroutines.launch import kotlinx.coroutines.channels.awaitClose -import retrofit2.HttpException import ru.daemonlord.messenger.data.chat.api.ChatApiService import ru.daemonlord.messenger.data.chat.dto.ChatJoinByInviteRequestDto import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.chat.mapper.toChatEntity import ru.daemonlord.messenger.data.chat.mapper.toDomain import ru.daemonlord.messenger.data.chat.mapper.toUserShortEntityOrNull +import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.domain.chat.model.ChatInviteLink import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.repository.ChatRepository -import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult -import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -102,16 +100,4 @@ class NetworkChatRepository @Inject constructor( chatDao.deleteChat(chatId = chatId) } } - - private fun Throwable.toAppError(): AppError { - return when (this) { - is IOException -> AppError.Network - is HttpException -> if (code() == 401 || code() == 403) { - AppError.Unauthorized - } else { - AppError.Server(message = message()) - } - else -> AppError.Unknown(cause = this) - } - } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/common/ApiErrorMapper.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/common/ApiErrorMapper.kt new file mode 100644 index 0000000..e41ec99 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/common/ApiErrorMapper.kt @@ -0,0 +1,31 @@ +package ru.daemonlord.messenger.data.common + +import retrofit2.HttpException +import ru.daemonlord.messenger.domain.common.AppError +import java.io.IOException + +enum class ApiErrorMode { + DEFAULT, + LOGIN, +} + +fun Throwable.toAppError(mode: ApiErrorMode = ApiErrorMode.DEFAULT): AppError { + return when (this) { + is IOException -> AppError.Network + is HttpException -> when (mode) { + ApiErrorMode.LOGIN -> when (code()) { + 400, 401, 403 -> AppError.InvalidCredentials + else -> AppError.Server(message = message()) + } + + ApiErrorMode.DEFAULT -> if (code() == 401 || code() == 403) { + AppError.Unauthorized + } else { + AppError.Server(message = message()) + } + } + + else -> AppError.Unknown(cause = this) + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt index 2ebee52..8e2c52b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/media/repository/NetworkMediaRepository.kt @@ -6,7 +6,7 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody -import retrofit2.HttpException +import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.media.dto.AttachmentCreateRequestDto import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto @@ -15,7 +15,6 @@ import ru.daemonlord.messenger.di.RefreshClient import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.media.repository.MediaRepository -import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -71,16 +70,4 @@ class NetworkMediaRepository @Inject constructor( AppResult.Error(error.toAppError()) } } - - private fun Throwable.toAppError(): AppError { - return when (this) { - is IOException -> AppError.Network - is HttpException -> if (code() == 401 || code() == 403) { - AppError.Unauthorized - } else { - AppError.Server(message = message()) - } - else -> AppError.Unknown(cause = this) - } - } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 39b0a84..8b04a24 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -8,17 +8,16 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.withContext import kotlinx.coroutines.launch -import retrofit2.HttpException import ru.daemonlord.messenger.data.chat.local.dao.ChatDao import ru.daemonlord.messenger.data.message.api.MessageApiService import ru.daemonlord.messenger.core.token.TokenRepository +import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.message.local.dao.MessageDao import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.mapper.toDomain import ru.daemonlord.messenger.data.message.mapper.toEntity import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.di.IoDispatcher -import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.message.model.MessageItem @@ -30,7 +29,6 @@ import ru.daemonlord.messenger.data.message.dto.MessageForwardBulkRequestDto import ru.daemonlord.messenger.data.message.dto.MessageReactionToggleRequestDto import ru.daemonlord.messenger.data.message.dto.MessageStatusUpdateRequestDto import ru.daemonlord.messenger.data.message.dto.MessageUpdateRequestDto -import java.io.IOException import java.util.Base64 import java.util.UUID import javax.inject.Inject @@ -382,18 +380,6 @@ class NetworkMessageRepository @Inject constructor( } } - private fun Throwable.toAppError(): AppError { - return when (this) { - is IOException -> AppError.Network - is HttpException -> if (code() == 401 || code() == 403) { - AppError.Unauthorized - } else { - AppError.Server(message = message()) - } - else -> AppError.Unknown(cause = this) - } - } - private fun String.extractUserIdFromJwt(): Long? { val payloadPart = split('.').getOrNull(1) ?: return null val normalized = payloadPart diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/common/ApiErrorMapperTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/common/ApiErrorMapperTest.kt new file mode 100644 index 0000000..8cd0cae --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/common/ApiErrorMapperTest.kt @@ -0,0 +1,43 @@ +package ru.daemonlord.messenger.data.common + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import ru.daemonlord.messenger.domain.common.AppError +import java.io.IOException + +class ApiErrorMapperTest { + + @Test + fun ioException_mapsToNetwork() { + val error = IOException("offline").toAppError() + assertTrue(error is AppError.Network) + } + + @Test + fun loginMode_maps401ToInvalidCredentials() { + val error = httpException(401).toAppError(mode = ApiErrorMode.LOGIN) + assertTrue(error is AppError.InvalidCredentials) + } + + @Test + fun defaultMode_maps401ToUnauthorized() { + val error = httpException(401).toAppError(mode = ApiErrorMode.DEFAULT) + assertTrue(error is AppError.Unauthorized) + } + + @Test + fun defaultMode_maps500ToServer() { + val error = httpException(500).toAppError(mode = ApiErrorMode.DEFAULT) + assertTrue(error is AppError.Server) + } + + private fun httpException(code: Int): HttpException { + val body = "{}".toResponseBody("application/json".toMediaType()) + return HttpException(Response.error(code, body)) + } +} + diff --git a/docs/android-checklist.md b/docs/android-checklist.md index c95fe11..d069cc4 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -12,7 +12,7 @@ ## 2. Сеть и API - [x] Retrofit/OkHttp + auth interceptor - [x] Авто-refresh JWT -- [ ] Единая обработка ошибок API +- [x] Единая обработка ошибок API - [x] Realtime WebSocket слой (reconnect/backoff) - [x] Маппинг DTO -> Domain -> UI models - [ ] Версионирование API и feature flags