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,26 @@
package com.texthip.thip.data.model.feed.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class FeedDetailResponse(
@SerialName("feedId") val feedId: Int,
@SerialName("creatorId") val creatorId: Int,
@SerialName("creatorNickname") val creatorNickname: String,
@SerialName("creatorProfileImageUrl") val creatorProfileImageUrl: String?,
@SerialName("aliasName") val aliasName: String,
@SerialName("aliasColor") val aliasColor: String,
@SerialName("postDate") val postDate: String,
@SerialName("bookTitle") val bookTitle: String,
@SerialName("isbn") val isbn: String,
@SerialName("bookAuthor") val bookAuthor: String,
@SerialName("contentBody") val contentBody: String,
@SerialName("contentUrls") val contentUrls: List<String>,
@SerialName("likeCount") val likeCount: Int,
@SerialName("commentCount") val commentCount: Int,
@SerialName("isSaved") val isSaved: Boolean,
@SerialName("isLiked") val isLiked: Boolean,
@SerialName("isWriter") val isWriter: Boolean,
@SerialName("tagList") val tagList: List<String>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.texthip.thip.data.model.feeds.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable


@Serializable
data class AllFeedResponse(
@SerialName("feedList") val feedList: List<AllFeedItem>,
@SerialName("nextCursor") val nextCursor: String?,
@SerialName("isLast") val isLast: Boolean
)

@Serializable
data class AllFeedItem(
@SerialName("feedId") val feedId: Int,
@SerialName("creatorId") val creatorId: Int,
@SerialName("creatorNickname") val creatorNickname: String,
@SerialName("creatorProfileImageUrl") val creatorProfileImageUrl: String?,
@SerialName("aliasName") val aliasName: String,
@SerialName("aliasColor") val aliasColor: String,
@SerialName("postDate") val postDate: String,
@SerialName("isbn") val isbn: String,
@SerialName("bookTitle") val bookTitle: String,
@SerialName("bookAuthor") val bookAuthor: String,
@SerialName("contentBody") val contentBody: String,
@SerialName("contentUrls") val contentUrls: List<String>,
@SerialName("likeCount") val likeCount: Int,
@SerialName("commentCount") val commentCount: Int,
@SerialName("isSaved") val isSaved: Boolean,
@SerialName("isLiked") val isLiked: Boolean,
@SerialName("isWriter") val isWriter: Boolean
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.texthip.thip.data.model.feeds.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable


@Serializable
data class MyFeedResponse(
@SerialName("feedList") val feedList: List<MyFeedItem>,
@SerialName("nextCursor") val nextCursor: String?,
@SerialName("isLast") val isLast: Boolean
)

@Serializable
data class MyFeedItem(
@SerialName("feedId") val feedId: Int,
@SerialName("postDate") val postDate: String,
@SerialName("isbn") val isbn: String,
@SerialName("bookTitle") val bookTitle: String,
@SerialName("bookAuthor") val bookAuthor: String,
@SerialName("contentBody") val contentBody: String,
@SerialName("contentUrls") val contentUrls: List<String>,
@SerialName("likeCount") val likeCount: Int,
@SerialName("commentCount") val commentCount: Int,
@SerialName("isPublic") val isPublic: Boolean,
@SerialName("isWriter") val isWriter: Boolean
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import android.net.Uri
import com.texthip.thip.data.model.base.handleBaseResponse
import com.texthip.thip.data.model.feed.request.CreateFeedRequest
import com.texthip.thip.data.model.feed.response.CreateFeedResponse
import com.texthip.thip.data.model.feed.response.FeedDetailResponse
import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse
import com.texthip.thip.data.model.feeds.response.AllFeedResponse
import com.texthip.thip.data.model.feeds.response.MyFeedResponse
import com.texthip.thip.data.service.FeedService
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -70,22 +73,20 @@ class FeedRepository @Inject constructor(
// 임시 파일 목록 추적
val tempFiles = mutableListOf<File>()

try {
// 이미지 파일들을 MultipartBody.Part로 변환
val imageParts = if (imageUris.isNotEmpty()) {
withContext(Dispatchers.IO) {
imageUris.mapNotNull { uri ->
try {
uriToMultipartBodyPart(uri, "images", tempFiles)
} catch (e: Exception) {
null
}
}
// 이미지 파일들을 MultipartBody.Part로 변환
val imageParts = if (imageUris.isNotEmpty()) {
withContext(Dispatchers.IO) {
imageUris.mapNotNull { uri ->
runCatching {
uriToMultipartBodyPart(uri, "images", tempFiles)
}.getOrNull()
}
} else {
null
}
} else {
null
}

Comment on lines +76 to 88

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

이미지 파트 생성: 실패를 조용히 무시 + 이중 runCatching + null/empty 의미 모호성

  • 현재 mapNotNull + getOrNull로 이미지 변환 실패가 조용히 무시됩니다. 일부 이미지만 업로드되는 상황을 UI가 인지하지 못해 사용자 관점에서 “이미지 일부 유실”로 보일 수 있습니다.
  • uriToMultipartBodyPart 내부와 외부에서 runCatching을 중복 사용하고 있어 에러 처리 흐름이 분산됩니다.
  • imageUris.isNotEmpty()인데 변환에 모두 실패해 빈 리스트가 생성돼도, 네트워크 호출은 진행됩니다(침묵 실패). 의도라면 주석/로그로 명확히, 아니라면 실패로 처리하는 것이 안전합니다.

아래와 같이 “부분 실패 시 전체 업로드 실패”로 명확화하고(권장), 예외 로깅은 외부에서 단일 책임으로 처리해 주세요. 이 변경은 아래 uriToMultipartBodyPart 개선 제안(예외는 던지고 상위에서 처리)과 함께 적용되는 것을 전제합니다.

적용 제안(해당 범위 내 diff):

-        // 이미지 파일들을 MultipartBody.Part로 변환
-        val imageParts = if (imageUris.isNotEmpty()) {
-            withContext(Dispatchers.IO) {
-                imageUris.mapNotNull { uri ->
-                    runCatching {
-                        uriToMultipartBodyPart(uri, "images", tempFiles)
-                    }.getOrNull()
-                }
-            }
-        } else {
-            null
-        }
+        // 이미지 파일들을 MultipartBody.Part로 변환
+        val imageParts: List<MultipartBody.Part>? = if (imageUris.isNotEmpty()) {
+            val parts = withContext(Dispatchers.IO) {
+                imageUris.mapNotNull { uri ->
+                    runCatching {
+                        uriToMultipartBodyPart(uri, "images", tempFiles)
+                    }.onFailure { e ->
+                        android.util.Log.e("FeedRepository", "이미지 파트 생성 실패", e) // 프로젝트에 Timber 사용 시 Timber.e로 교체 권장
+                    }.getOrNull()
+                }
+            }
+            // 부분 실패 방지: 입력 개수와 생성된 파트 개수가 다르면 실패 처리
+            if (parts.size != imageUris.size) {
+                throw IllegalStateException("일부 이미지 처리에 실패했습니다. 다시 시도해 주세요.")
+            }
+            parts
+        } else {
+            null
+        }

검증 필요사항:

  • feedService.createFeed의 이미지 파라미터가 null 허용인지 확인 바랍니다(허용되지 않으면 빈 리스트 전달 또는 오버로드/DTO 조정 필요).

검증 스크립트:


🏁 Script executed:

#!/bin/bash
# FeedService 시그니처와 Timber 사용 여부 확인
set -euo pipefail

# FeedService 정의 및 관련 메서드 확인
rg -n -C3 -g '!**/build/**' --type=kt -P '(interface|object)\s+FeedService\b|@Multipart|@POST\(|createFeed\s*\(|getAllFeeds\s*\(|getMyFeeds\s*\('

# handleBaseResponse 시그니처 확인
rg -n -C2 -g '!**/build/**' --type=kt 'fun\s+handleBaseResponse\('

# Timber 사용 여부 확인
rg -n -C1 -g '!**/build/**' --type=kt '\bTimber\b'

Length of output: 194


rg가 --type=kt를 인식하지 못해 파일 패턴을 수정하여 재확인합니다. 아래 스크립트를 실행해 주세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# FeedService 정의 및 createFeed 시그니처 확인
rg -n -C3 -g '!**/build/**' -g '*.kt' -P '(interface|object)\s+FeedService\b|@Multipart|@POST\(|createFeed\s*\('

# handleBaseResponse 시그니처 확인
rg -n -C2 -g '!**/build/**' -g '*.kt' -P 'fun\s+handleBaseResponse\('

# Timber 사용 여부 확인
rg -n -C1 -g '!**/build/**' -g '*.kt' -P '\bTimber\b'

Length of output: 14184


handleBaseResponse 확장 함수 정의를 찾지 못했습니다. 아래 스크립트를 실행해 위치를 확인해 주세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# handleBaseResponse 확장 함수 검색
rg -n -C2 -g '!**/build/**' -g '*.kt' 'handleBaseResponse'

Length of output: 25793


uriToMultipartBodyPart 함수 정의 내에서 runCatching이 중복 사용되는지 확인합니다. 아래 스크립트를 실행해 주세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# uriToMultipartBodyPart 정의 및 내부 예외 처리 흐름 확인
rg -n -C5 -g '!**/build/**' -g '*.kt' 'fun\s+uriToMultipartBodyPart'

Length of output: 1307


uriToMultipartBodyPart 함수 내부에서 예외 처리 흐름(중복 runCatching 여부)을 전체 확인하기 위해 아래 스크립트를 실행해 주세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# uriToMultipartBodyPart 전체 구현 내용 출력 (90~140라인)
sed -n '90,140p' app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt

Length of output: 2041


부분 실패 시 전체 업로드 실패 처리 및 로깅 중앙화 필요

검증 결과:

  • FeedService.createFeed의 images: List<MultipartBody.Part>? 파라미터는 null 허용이 확인되었습니다.
  • uriToMultipartBodyPart 내부에서 이미 runCatching { … }.onFailure { e.printStackTrace() }로 예외를 흘려보내고, 외부 호출부에서도 runCatching { … }.getOrNull()을 사용해 중복 처리되고 있습니다.
  • 코드베이스 내 Timber는 사용되지 않고 있어, 로깅은 android.util.Log.e 또는 프로젝트 표준 로거로 일원화하는 것이 좋습니다.

아래와 같이 “부분 실패 시 전체 실패 처리” 로직을 적용해 주세요:

-        // 이미지 파일들을 MultipartBody.Part로 변환
-        val imageParts = if (imageUris.isNotEmpty()) {
-            withContext(Dispatchers.IO) {
-                imageUris.mapNotNull { uri ->
-                    runCatching {
-                        uriToMultipartBodyPart(uri, "images", tempFiles)
-                    }.getOrNull()
-                }
-            }
-        } else {
-            null
-        }
+        // 이미지 파일들을 MultipartBody.Part로 변환
+        val imageParts: List<MultipartBody.Part>? = if (imageUris.isNotEmpty()) {
+            val parts = withContext(Dispatchers.IO) {
+                imageUris.mapNotNull { uri ->
+                    runCatching {
+                        uriToMultipartBodyPart(uri, "images", tempFiles)
+                    }.onFailure { e ->
+                        android.util.Log.e(
+                            "FeedRepository",
+                            "이미지 파트 생성 실패 for URI=$uri",
+                            e
+                        )
+                    }.getOrNull()
+                }
+            }
+            // 변환 개수와 URI 개수가 다를 경우 전체 업로드 실패 처리
+            if (parts.size != imageUris.size) {
+                throw IllegalStateException("일부 이미지 처리에 실패했습니다. 다시 시도해 주세요.")
+            }
+            parts
+        } else {
+            null
+        }
  • uriToMultipartBodyPart 내부의 runCatching은 제거하고, 예외는 상위(위 코드)에서만 처리하도록 단일 책임을 부여합니다.
  • 로깅은 android.util.Log.e 또는 Timber 사용 시 Timber.e로 교체해 주세요.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 이미지 파일들을 MultipartBody.Part로 변환
val imageParts = if (imageUris.isNotEmpty()) {
withContext(Dispatchers.IO) {
imageUris.mapNotNull { uri ->
runCatching {
uriToMultipartBodyPart(uri, "images", tempFiles)
}.getOrNull()
}
} else {
null
}
} else {
null
}
// 이미지 파일들을 MultipartBody.Part로 변환
val imageParts: List<MultipartBody.Part>? = if (imageUris.isNotEmpty()) {
val parts = withContext(Dispatchers.IO) {
imageUris.mapNotNull { uri ->
runCatching {
uriToMultipartBodyPart(uri, "images", tempFiles)
}.onFailure { e ->
android.util.Log.e(
"FeedRepository",
"이미지 파트 생성 실패 for URI=$uri",
e
)
}.getOrNull()
}
}
// 변환 개수와 URI 개수가 다를 경우 전체 업로드 실패 처리
if (parts.size != imageUris.size) {
throw IllegalStateException("일부 이미지 처리에 실패했습니다. 다시 시도해 주세요.")
}
parts
} else {
null
}
🤖 Prompt for AI Agents
In app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt around
lines 75 to 87, remove the per-item runCatching/getOrNull pattern and instead
let uriToMultipartBodyPart throw on error (remove its internal runCatching),
then perform the entire imageUris.map transformation inside a single try/catch
in this scope so any item failure aborts the whole upload; in the catch log the
exception centrally using android.util.Log.e (or Timber.e if project uses
Timber) with a clear message and return null/propagate as appropriate so partial
failures are treated as full failures and logging is centralized.

try {
feedService.createFeed(requestBody, imageParts)
.handleBaseResponse()
.getOrThrow()
Expand All @@ -100,7 +101,7 @@ class FeedRepository @Inject constructor(
paramName: String,
tempFiles: MutableList<File>
): MultipartBody.Part? {
return try {
return runCatching {
// MIME 타입 확인
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) {
Expand All @@ -122,25 +123,45 @@ class FeedRepository @Inject constructor(
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: return null
} ?: throw IllegalStateException("Failed to open input stream for URI: $uri")

Copilot AI Aug 17, 2025

Copy link

Choose a reason for hiding this comment

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

The error message should be more descriptive. Consider including more context about what operation failed and potential causes for debugging purposes.

Suggested change
} ?: throw IllegalStateException("Failed to open input stream for URI: $uri")
} ?: throw IllegalStateException(
"Failed to open input stream for URI: $uri (paramName: $paramName, mimeType: $mimeType). " +
"Possible causes: file not found, permission denied, or invalid URI."
)

Copilot uses AI. Check for mistakes.

// MultipartBody.Part 생성
val requestFile = tempFile.asRequestBody(mimeType.toMediaType())
MultipartBody.Part.createFormData(paramName, fileName, requestFile)
} catch (e: Exception) {
}.onFailure { e ->
e.printStackTrace()
null
}
}.getOrNull()
}

/** 전체 피드 목록 조회 */
suspend fun getAllFeeds(cursor: String? = null): Result<AllFeedResponse?> = runCatching {
feedService.getAllFeeds(cursor)
.handleBaseResponse()
.getOrThrow()
}

/** 내 피드 목록 조회 */
suspend fun getMyFeeds(cursor: String? = null): Result<MyFeedResponse?> = runCatching {
feedService.getMyFeeds(cursor)
.handleBaseResponse()
.getOrThrow()
}

/** 피드 상세 조회 */
suspend fun getFeedDetail(feedId: Int): Result<FeedDetailResponse?> = runCatching {
feedService.getFeedDetail(feedId)
.handleBaseResponse()
.getOrThrow()
}

/** 임시 파일들을 정리하는 함수 */
private fun cleanupTempFiles(tempFiles: List<File>) {
tempFiles.forEach { file ->
try {
runCatching {
if (file.exists()) {
file.delete()
}
} catch (e: Exception) {
}.onFailure { e ->
e.printStackTrace()
}
}
Expand Down
23 changes: 23 additions & 0 deletions app/src/main/java/com/texthip/thip/data/service/FeedService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,21 @@ package com.texthip.thip.data.service

import com.texthip.thip.data.model.base.BaseResponse
import com.texthip.thip.data.model.feed.response.CreateFeedResponse
import com.texthip.thip.data.model.feed.response.FeedDetailResponse
import com.texthip.thip.data.model.feed.response.FeedUsersInfoResponse
import com.texthip.thip.data.model.feed.response.FeedUsersResponse
import com.texthip.thip.data.model.feed.response.FeedWriteInfoResponse
import com.texthip.thip.data.model.feeds.response.AllFeedResponse
import com.texthip.thip.data.model.feeds.response.MyFeedResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query


interface FeedService {

Expand All @@ -27,6 +32,24 @@ interface FeedService {
@Part images: List<MultipartBody.Part>?
): BaseResponse<CreateFeedResponse>

/** 전체 피드 목록 조회 */
@GET("feeds")
suspend fun getAllFeeds(
@Query("cursor") cursor: String? = null
): BaseResponse<AllFeedResponse>

/** 내 피드 목록 조회 */
@GET("feeds/mine")
suspend fun getMyFeeds(
@Query("cursor") cursor: String? = null
): BaseResponse<MyFeedResponse>

/** 피드 상세 조회 */
@GET("feeds/{feedId}")
suspend fun getFeedDetail(
@Path("feedId") feedId: Int
): BaseResponse<FeedDetailResponse>

@GET("feeds/users/{userId}/info")
suspend fun getFeedUsersInfo(
@Path("userId") userId: Long
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.texthip.thip.ui.feed.component

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
Expand All @@ -18,33 +17,32 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import coil.compose.AsyncImage
import com.texthip.thip.R
import com.texthip.thip.ui.theme.ThipTheme
import com.texthip.thip.ui.theme.ThipTheme.colors
import com.texthip.thip.ui.theme.ThipTheme.typography

@Composable
fun ImageViewerModal(
images: List<Painter>,
imageUrls: List<String>,
initialIndex: Int = 0,
onDismiss: () -> Unit
) {
val pagerState = rememberPagerState(
initialPage = initialIndex,
pageCount = { images.size }
pageCount = { imageUrls.size }
)

Box(
modifier = Modifier
.fillMaxSize()
.background(colors.Black)
.clickable { onDismiss() }
) {
// 닫기 버튼
Expand All @@ -71,8 +69,8 @@ fun ImageViewerModal(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Image(
painter = images[page],
AsyncImage(
model = imageUrls[page],
contentDescription = null,
contentScale = ContentScale.Fit, // 원본 비율 유지하면서 화면에 맞춤
modifier = Modifier.fillMaxSize()
Expand All @@ -81,14 +79,14 @@ fun ImageViewerModal(
}

// 페이지 인디케이터 (이미지가 2개 이상일 때만 표시)
if (images.size > 1) {
if (imageUrls.size > 1) {
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(20.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(images.size) { index ->
repeat(imageUrls.size) { index ->
Box(
modifier = Modifier
.size(8.dp)
Expand All @@ -103,9 +101,9 @@ fun ImageViewerModal(
}

// 이미지 카운터 (예: 1/3)
if (images.size > 1) {
if (imageUrls.size > 1) {
Text(
text = stringResource(id = R.string.tag_count, images.size, 3),
text = stringResource(id = R.string.tag_count, pagerState.currentPage + 1, imageUrls.size),

Copilot AI Aug 17, 2025

Copy link

Choose a reason for hiding this comment

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

The string resource R.string.tag_count appears to be incorrectly used for image counter display. This should likely use a dedicated image counter string resource instead of a tag-related resource.

Suggested change
text = stringResource(id = R.string.tag_count, pagerState.currentPage + 1, imageUrls.size),
text = stringResource(id = R.string.image_counter, pagerState.currentPage + 1, imageUrls.size),

Copilot uses AI. Check for mistakes.
style = typography.copy_r400_s14,
color = colors.White,
modifier = Modifier
Expand All @@ -126,12 +124,12 @@ fun ImageViewerModal(
@Composable
private fun ImageViewerModalSingleImagePreview() {
ThipTheme {
val mockImages = listOf(
painterResource(R.drawable.img_book_cover_sample)
val mockImageUrls = listOf(
"https://example.com/image1.jpg"
)

ImageViewerModal(
images = mockImages,
imageUrls = mockImageUrls,
initialIndex = 0,
onDismiss = {}
)
Expand All @@ -142,14 +140,14 @@ private fun ImageViewerModalSingleImagePreview() {
@Composable
private fun ImageViewerModalMultipleImagesPreview() {
ThipTheme {
val mockImages = listOf(
painterResource(R.drawable.character_art),
painterResource(R.drawable.character_literature),
painterResource(R.drawable.character_sociology)
val mockImageUrls = listOf(
"https://example.com/image1.jpg",
"https://example.com/image2.jpg",
"https://example.com/image3.jpg"
)

ImageViewerModal(
images = mockImages,
imageUrls = mockImageUrls,
initialIndex = 1,
onDismiss = { }
)
Expand Down
Loading