-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 피드 데이터 레이어 모듈 분리 #780
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| package com.into.websoso.core.common.image | ||
|
|
||
| import android.content.Context | ||
| import android.graphics.Bitmap | ||
| import android.graphics.BitmapFactory | ||
| import android.net.Uri | ||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.withContext | ||
| import java.io.ByteArrayOutputStream | ||
| import java.io.File | ||
| import java.io.FileOutputStream | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| @Singleton | ||
| class ImageCompressor | ||
| @Inject | ||
| constructor( | ||
| @ApplicationContext private val context: Context, | ||
| ) { | ||
| suspend fun compressUris( | ||
| uris: List<Uri>, | ||
| size: Double = DEFAULT_MAX_IMAGE_SIZE, | ||
| ): List<Uri> = | ||
| withContext(Dispatchers.IO) { | ||
| uris.mapNotNull { uri -> | ||
| runCatching { | ||
| val inputStream = context.contentResolver.openInputStream(uri) | ||
| val bitmap = inputStream?.use { BitmapFactory.decodeStream(it) } ?: return@runCatching null | ||
|
|
||
| var quality = INITIAL_QUALITY | ||
| val outputStream = ByteArrayOutputStream() | ||
| bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) | ||
|
|
||
| while (outputStream.size() > (size * MB) && quality > QUALITY_DECREMENT_STEP) { | ||
| quality -= QUALITY_DECREMENT_STEP | ||
| outputStream.reset() | ||
| bitmap.compress(Bitmap.CompressFormat.JPEG, quality, outputStream) | ||
| } | ||
|
|
||
| val compressedFile = File.createTempFile("compressed_", ".jpg", context.cacheDir) | ||
| FileOutputStream(compressedFile).use { | ||
| it.write(outputStream.toByteArray()) | ||
| } | ||
|
|
||
| outputStream.close() | ||
| Uri.fromFile(compressedFile) | ||
| }.onFailure { | ||
| it.printStackTrace() | ||
| }.getOrNull() | ||
| } | ||
| } | ||
|
|
||
| companion object { | ||
| private const val INITIAL_QUALITY: Int = 100 | ||
| private const val QUALITY_DECREMENT_STEP: Int = 5 | ||
| private const val DEFAULT_MAX_IMAGE_SIZE: Double = 0.25 | ||
| private const val MB: Double = (1024 * 1024).toDouble() | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package com.into.websoso.core.network.common | ||
|
|
||
| import android.content.Context | ||
| import android.net.Uri | ||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.withContext | ||
| import java.io.File | ||
| import java.io.FileOutputStream | ||
| import java.net.HttpURLConnection | ||
| import java.net.URL | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| @Singleton | ||
| class ImageDownloader | ||
| @Inject | ||
| constructor( | ||
| @ApplicationContext private val context: Context, | ||
| ) { | ||
| suspend fun formatImageToUri(url: String): Result<Uri> = | ||
| withContext(Dispatchers.IO) { | ||
| val connection = (URL(url).openConnection() as? HttpURLConnection) | ||
| ?: return@withContext Result.failure(IllegalStateException("Invalid HTTP connection")) | ||
|
|
||
| runCatching { | ||
| connection.connect() | ||
| connection.inputStream.use { inputStream -> | ||
| val file = File.createTempFile("image_", ".jpg", context.cacheDir) | ||
| FileOutputStream(file).use { output -> | ||
| inputStream.copyTo(output) | ||
| } | ||
| Uri.fromFile(file) | ||
| } | ||
| }.also { | ||
| connection.disconnect() | ||
| } | ||
| } | ||
|
Comment on lines
+21
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 다운로드 파일 크기 제한 및 Content-Type 검증 권장 임의의 URL에서 제한 없이 이미지를 다운로드하면 다음 위험이 있습니다:
파일 크기 제한과 Content-Type 검증을 추가하는 것을 권장합니다. 💡 파일 크기 및 타입 검증 추가 connection.connect()
val responseCode = connection.responseCode
if (responseCode !in 200..299) {
return@withContext Result.failure(
IllegalStateException("HTTP error code: $responseCode")
)
}
+val contentLength = connection.contentLength
+val maxSize = 10 * 1024 * 1024 // 10MB
+if (contentLength > maxSize) {
+ return@withContext Result.failure(
+ IllegalStateException("File too large: $contentLength bytes")
+ )
+}
+
+val contentType = connection.contentType
+if (contentType?.startsWith("image/") != true) {
+ return@withContext Result.failure(
+ IllegalStateException("Invalid content type: $contentType")
+ )
+}
+
connection.inputStream.use { inputStream ->🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| package com.into.websoso.core.network.datasource.feed.mapper | ||
|
|
||
| import android.content.Context | ||
| import android.net.Uri | ||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||
| import kotlinx.serialization.json.Json | ||
| import okhttp3.MediaType.Companion.toMediaType | ||
| import okhttp3.MultipartBody | ||
| import okhttp3.RequestBody.Companion.toRequestBody | ||
| import javax.inject.Inject | ||
| import javax.inject.Singleton | ||
|
|
||
| @Singleton | ||
| class MultiPartMapper | ||
| @Inject | ||
| constructor( | ||
| @ApplicationContext private val context: Context, | ||
| ) { | ||
| inline fun <reified T> formatToMultipart( | ||
| target: T, | ||
| partName: String = "data", | ||
| fileName: String = "data.json", | ||
| ): MultipartBody.Part { | ||
| val json = Json.encodeToString(target) | ||
| val requestBody = json.toRequestBody("application/json".toMediaType()) | ||
| return MultipartBody.Part.createFormData(partName, fileName, requestBody) | ||
| } | ||
|
Comment on lines
+19
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제네릭 타입 제약 조건 추가 필요
🔧 타입 제약 추가 제안옵션 1: 인라인 함수이므로 컴파일러가 직렬화 가능 여부를 확인하도록 문서화하거나, 옵션 2: 직렬화 실패 시 예외 처리 추가: inline fun <reified T> formatToMultipart(
target: T,
partName: String = "data",
fileName: String = "data.json",
): MultipartBody.Part {
- val json = Json.encodeToString(target)
+ val json = try {
+ Json.encodeToString(target)
+ } catch (e: Exception) {
+ throw IllegalArgumentException("직렬화 불가능한 타입: ${T::class.simpleName}", e)
+ }
val requestBody = json.toRequestBody("application/json".toMediaType())
return MultipartBody.Part.createFormData(partName, fileName, requestBody)
}또는 KDoc에 🤖 Prompt for AI Agents |
||
|
|
||
| fun formatToMultipart( | ||
| uri: Uri, | ||
| partName: String = "images", | ||
| contentType: String = "image/*", | ||
| ): MultipartBody.Part { | ||
| val inputStream = context.contentResolver.openInputStream(uri) | ||
| ?: throw IllegalArgumentException("유효하지 않은 URI: $uri") | ||
| return inputStream.use { stream -> | ||
| val bytes = stream.readBytes() | ||
| val fileName = uri.lastPathSegment ?: "file.jpg" | ||
| val requestBody = bytes.toRequestBody(contentType.toMediaType()) | ||
| MultipartBody.Part.createFormData(partName, fileName, requestBody) | ||
| } | ||
| } | ||
|
Comment on lines
+29
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # Search for formatToMultipart usage
rg -n "formatToMultipart" --type=kt -B 2 -A 2Repository: Team-WSS/WSS-Android Length of output: 90 🏁 Script executed: # Search for ImageCompressor usage
rg -n "ImageCompressor" --type=kt -B 2 -A 2Repository: Team-WSS/WSS-Android Length of output: 90 🏁 Script executed: # Look for FeedRepository and feed upload patterns
fd "FeedRepository" --type=kt --exec cat -n {}Repository: Team-WSS/WSS-Android Length of output: 233 🏁 Script executed: # Check for any file size validation or limits
rg -n "size|Size|limit|Limit|MAX|max" --type=kt core/network/src/main/java/com/into/websoso/core/network/datasource/feed/ -B 1 -A 1Repository: Team-WSS/WSS-Android Length of output: 90 🏁 Script executed: # Search for formatToMultipart usage in .kt files
rg -n "formatToMultipart" -g '*.kt' -B 2 -A 2Repository: Team-WSS/WSS-Android Length of output: 7362 🏁 Script executed: # Search for ImageCompressor usage in .kt files
rg -n "ImageCompressor" -g '*.kt' -B 2 -A 2Repository: Team-WSS/WSS-Android Length of output: 3412 🏁 Script executed: # Find the MultiPartMapper file location to understand context better
find . -name "MultiPartMapper.kt" -type fRepository: Team-WSS/WSS-Android Length of output: 232 🏁 Script executed: # Search for FeedRepository in .kt files
rg -n "class FeedRepository|object FeedRepository" -g '*.kt' -A 10Repository: Team-WSS/WSS-Android Length of output: 2719 🏁 Script executed: # View the postFeed method in FeedRepository to see complete flow
sed -n '50,85p' data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.ktRepository: Team-WSS/WSS-Android Length of output: 1433 🏁 Script executed: # View the putFeed method in FeedRepository
sed -n '88,120p' data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.ktRepository: Team-WSS/WSS-Android Length of output: 1458 🏁 Script executed: # Check how images parameter is processed before being used
rg -n "fun postFeed|fun putFeed" -g '*.kt' data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt -A 30Repository: Team-WSS/WSS-Android Length of output: 46 🏁 Script executed: # Check if compress is called on images anywhere
rg -n "imageCompressor.compress|compress\(" -g '*.kt' data/feed/src/main/java/com/into/websoso/data/feed/repository/ -B 2 -A 2Repository: Team-WSS/WSS-Android Length of output: 733 🏁 Script executed: # Search for usages of compressImages
rg -n "compressImages" -g '*.kt' -B 2 -A 2Repository: Team-WSS/WSS-Android Length of output: 1995 🏁 Script executed: # Check for any file size validation or limits in feed API or mapper
rg -n "MAX|max|size|Size|limit" -g '*.kt' core/network/src/main/java/com/into/websoso/core/network/datasource/feed/ -B 1 -A 1Repository: Team-WSS/WSS-Android Length of output: 429 🏁 Script executed: # Check ImageCompressor implementation for context on compression
cat -n core/common/src/main/java/com/into/websoso/core/common/image/ImageCompressor.ktRepository: Team-WSS/WSS-Android Length of output: 2876 🏁 Script executed: # Check CreateFeedViewModel to see full image handling flow
rg -n "saveFeed\|editFeed" -g '*.kt' app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt -B 5 -A 5Repository: Team-WSS/WSS-Android Length of output: 46 🏁 Script executed: # Verify that attachedImages (compressed) are what's passed to saveFeed
rg -n "_attachedImages\|attachedImages" -g '*.kt' app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt -B 2 -A 2Repository: Team-WSS/WSS-Android Length of output: 46 🏁 Script executed: # Find CreateFeedViewModel file
find . -name "CreateFeedViewModel.kt" -type fRepository: Team-WSS/WSS-Android Length of output: 137 🏁 Script executed: # Search for where saveFeed is called in the codebase
rg -n "\.saveFeed\(" -g '*.kt' -B 3 -A 3Repository: Team-WSS/WSS-Android Length of output: 920 🏁 Script executed: # Check the complete flow - search for attachedImages usage more broadly
rg -n "attachedImages" -g '*.kt' -B 2 -A 2 | head -50Repository: Team-WSS/WSS-Android Length of output: 5422 파일명 기본값 개선 고려 Line 39의 기본 파일명 "file.jpg"는 참고: 🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.into.websoso.core.network.datasource.feed.model.request | ||
|
|
||
| import kotlinx.serialization.SerialName | ||
| import kotlinx.serialization.Serializable | ||
|
|
||
| @Serializable | ||
| data class FeedRequestDto( | ||
| @SerialName("relevantCategories") | ||
| val relevantCategories: List<String>, | ||
| @SerialName("feedContent") | ||
| val feedContent: String, | ||
| @SerialName("novelId") | ||
| val novelId: Long?, | ||
| @SerialName("isSpoiler") | ||
| val isSpoiler: Boolean, | ||
| @SerialName("isPublic") | ||
| val isPublic: Boolean, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,5 +11,7 @@ android { | |
| dependencies { | ||
| implementation(projects.core.common) | ||
| implementation(projects.data.account) | ||
| implementation(projects.core.network) | ||
| implementation(projects.data.library) | ||
|
Comment on lines
+14
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: git ls-files | grep -E "(core/network|data/library|data/feed)" | head -20Repository: Team-WSS/WSS-Android Length of output: 1800 🏁 Script executed: find . -name "build.gradle.kts" -type f | grep -E "(core|data)" | head -20Repository: Team-WSS/WSS-Android Length of output: 420 🏁 Script executed: cat -n data/feed/build.gradle.ktsRepository: Team-WSS/WSS-Android Length of output: 534 🏁 Script executed: cat -n core/network/build.gradle.ktsRepository: Team-WSS/WSS-Android Length of output: 1406 🏁 Script executed: cat -n data/library/build.gradle.ktsRepository: Team-WSS/WSS-Android Length of output: 439 모듈 의존성 및 순환 의존성 검증 완료 피드 데이터 레이어 분리에 필요한 다만, 아키텍처 레이어 위반 문제를 발견했습니다:
🤖 Prompt for AI Agents |
||
| implementation(libs.paging.runtime) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| package com.into.websoso.data.feed.mapper | ||
|
|
||
| import com.into.websoso.core.network.datasource.feed.model.response.CommentResponseDto | ||
| import com.into.websoso.core.network.datasource.feed.model.response.CommentsResponseDto | ||
| import com.into.websoso.core.network.datasource.feed.model.response.FeedDetailResponseDto | ||
| import com.into.websoso.core.network.datasource.feed.model.response.FeedResponseDto | ||
| import com.into.websoso.core.network.datasource.feed.model.response.FeedsResponseDto | ||
| import com.into.websoso.core.network.datasource.feed.model.response.PopularFeedsResponseDto | ||
| import com.into.websoso.core.network.datasource.feed.model.response.UserInterestFeedsResponseDto | ||
| import com.into.websoso.data.feed.model.CommentEntity | ||
| import com.into.websoso.data.feed.model.CommentsEntity | ||
| import com.into.websoso.data.feed.model.FeedEntity | ||
| import com.into.websoso.data.feed.model.FeedsEntity | ||
| import com.into.websoso.data.feed.model.PopularFeedsEntity | ||
| import com.into.websoso.data.feed.model.UserInterestFeedsEntity | ||
|
|
||
| fun FeedsResponseDto.toData(): FeedsEntity = | ||
| FeedsEntity( | ||
| category = category, | ||
| isLoadable = isLoadable, | ||
| feeds = feeds.map { it.toData() }, | ||
| ) | ||
|
|
||
| fun FeedResponseDto.toData(): FeedEntity = | ||
| FeedEntity( | ||
| user = FeedEntity.UserEntity( | ||
| id = userId, | ||
| nickname = nickname, | ||
| avatarImage = avatarImage, | ||
| ), | ||
| createdDate = createdDate, | ||
| id = feedId, | ||
| content = feedContent, | ||
| relevantCategories = relevantCategories, | ||
| likeCount = likeCount, | ||
| isLiked = isLiked, | ||
| commentCount = commentCount, | ||
| isModified = isModified, | ||
| isSpoiler = isSpoiler, | ||
| isMyFeed = isMyFeed, | ||
| isPublic = isPublic, | ||
| images = thumbnailUrl?.let { listOf(it) } ?: emptyList(), | ||
| imageCount = imageCount, | ||
| novel = FeedEntity.NovelEntity( | ||
| id = novelId, | ||
| title = title, | ||
| rating = novelRating, | ||
| ratingCount = novelRatingCount, | ||
| ), | ||
| ) | ||
|
Comment on lines
+24
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "FeedMapper.kt" -type fRepository: Team-WSS/WSS-Android Length of output: 326 🏁 Script executed: cat -n ./data/feed/src/main/java/com/into/websoso/data/feed/mapper/FeedMapper.ktRepository: Team-WSS/WSS-Android Length of output: 5558 🏁 Script executed: fd "FeedResponseDto|FeedDetailResponseDto" --type f --extension kt | head -20Repository: Team-WSS/WSS-Android Length of output: 519 🏁 Script executed: cat -n core/network/src/main/java/com/into/websoso/core/network/datasource/feed/model/response/FeedResponseDto.ktRepository: Team-WSS/WSS-Android Length of output: 1743 🏁 Script executed: cat -n core/network/src/main/java/com/into/websoso/core/network/datasource/feed/model/response/FeedDetailResponseDto.ktRepository: Team-WSS/WSS-Android Length of output: 1673
반면 images = thumbnailUrl?.let { listOf(it) } ?: emptyList(),
- imageCount = imageCount,
+ imageCount = thumbnailUrl?.let { 1 } ?: 0,🤖 Prompt for AI Agents |
||
|
|
||
| fun CommentsResponseDto.toData(): CommentsEntity = | ||
| CommentsEntity( | ||
| comments = comments.map { it.toData() }, | ||
| commentsCount = commentsCount, | ||
| ) | ||
|
|
||
| fun CommentResponseDto.toData(): CommentEntity = | ||
| CommentEntity( | ||
| user = FeedEntity.UserEntity( | ||
| id = userId, | ||
| nickname = nickname, | ||
| avatarImage = avatarImage, | ||
| ), | ||
| commentContent = commentContent, | ||
| commentId = commentId, | ||
| createdDate = createdDate, | ||
| isModified = isModified, | ||
| isMyComment = isMyComment, | ||
| isBlocked = isBlocked, | ||
| isHidden = isHidden, | ||
| isSpoiler = isSpoiler, | ||
| ) | ||
|
|
||
| fun FeedDetailResponseDto.toData(): FeedEntity = | ||
| FeedEntity( | ||
| user = FeedEntity.UserEntity( | ||
| id = userId, | ||
| nickname = nickname, | ||
| avatarImage = avatarImage, | ||
| ), | ||
| createdDate = createdDate, | ||
| id = feedId, | ||
| content = feedContent, | ||
| relevantCategories = relevantCategories, | ||
| likeCount = likeCount, | ||
| isLiked = isLiked, | ||
| commentCount = commentCount, | ||
| isModified = isModified, | ||
| isSpoiler = isSpoiler, | ||
| isMyFeed = isMyFeed, | ||
| isPublic = isPublic, | ||
| images = images, | ||
| imageCount = images.size, | ||
| novel = FeedEntity.NovelEntity( | ||
| id = novelId, | ||
| title = title, | ||
| rating = novelRating, | ||
| ratingCount = novelRatingCount, | ||
| ), | ||
| ) | ||
|
|
||
| fun PopularFeedsResponseDto.toData(): PopularFeedsEntity = | ||
| PopularFeedsEntity( | ||
| popularFeeds = popularFeeds.map { feed -> | ||
| PopularFeedsEntity.PopularFeedEntity( | ||
| feedId = feed.feedId, | ||
| feesContent = feed.feedContent, | ||
| likeCount = feed.likeCount, | ||
| commentCount = feed.commentCount, | ||
| isSpoiler = feed.isSpoiler, | ||
| ) | ||
| }, | ||
| ) | ||
|
|
||
| fun UserInterestFeedsResponseDto.toData(): UserInterestFeedsEntity = | ||
| UserInterestFeedsEntity( | ||
| userInterestFeeds = userInterestFeeds.map { feed -> | ||
| UserInterestFeedsEntity.UserInterestFeedEntity( | ||
| avatarImage = feed.avatarImage, | ||
| feedContent = feed.feedContent, | ||
| nickname = feed.nickname, | ||
| novelId = feed.novelId, | ||
| novelImage = feed.novelImage, | ||
| novelRating = feed.novelRating, | ||
| novelRatingCount = feed.novelRatingCount, | ||
| novelTitle = feed.novelTitle, | ||
| ) | ||
| }, | ||
| message = message, | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package com.into.websoso.data.feed.model | ||
|
|
||
| data class CommentEntity( | ||
| val user: FeedEntity.UserEntity, | ||
| val commentContent: String, | ||
| val commentId: Long, | ||
| val createdDate: String, | ||
| val isModified: Boolean, | ||
| val isMyComment: Boolean, | ||
| val isSpoiler: Boolean, | ||
| val isBlocked: Boolean, | ||
| val isHidden: Boolean, | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
연결 타임아웃 설정이 누락되어 네트워크 지연 시 무한 대기 가능
HttpURLConnection에 타임아웃이 설정되지 않아 네트워크 응답이 느리거나 서버가 응답하지 않을 때 스레드가 무한정 차단될 수 있습니다. 또한 HTTP 응답 코드를 확인하지 않아 오류 응답(4xx, 5xx)의 내용도 이미지로 저장될 수 있습니다.
🔧 타임아웃 및 응답 코드 검증 추가
val connection = (URL(url).openConnection() as? HttpURLConnection) ?: return@withContext Result.failure(IllegalStateException("Invalid HTTP connection")) runCatching { + connection.connectTimeout = 10_000 // 10초 + connection.readTimeout = 10_000 // 10초 connection.connect() + + val responseCode = connection.responseCode + if (responseCode !in 200..299) { + return@withContext Result.failure( + IllegalStateException("HTTP error code: $responseCode") + ) + } + connection.inputStream.use { inputStream ->🤖 Prompt for AI Agents