Conversation
기존의 복잡한 분기문으로 처리하던 피드 아이템의 장르 및 색상 결정 로직을 `firstOrNull`을 사용하여 간소화했습니다.
- 산군이 요 쪽 봐주세욤
feat: 작품 피드 장르별 색상 반영
fix: 피드 QA 반영
fix: 장르별 색상 리소스 수정
fix: 내 피드 필터 설정 시 새로고침
fix: 피드 2차 QA 반영
Walkthrough로컬 core/database 모듈 제거와 피드 기능 대규모 재설계: UpdatedFeedRepository/UpdatedFeedDetailViewModel 등 신규 저장소·뷰모델 및 FeedApi 확장, feedWriterNovelRating 필드 전파, 라이브러리 서버 중심 페이징 도입, UI/레이아웃·바인딩 어댑터 변경을 포함한 대규모 리팩터링. Changes
Sequence Diagram(s)sequenceDiagram
participant UI as Client(UI / Compose)
participant VM as UpdatedFeedDetailViewModel
participant Repo as UpdatedFeedRepository
participant API as FeedApi
participant UserRepo as UserRepository
participant NotiRepo as NotificationRepository
rect rgba(50,115,220,0.5)
UI->>VM: updateFeedDetail(feedId, from)
end
VM->>Repo: fetchFeed(feedId) %% fetchSingleFeed
Repo->>API: GET /feeds/{feedId}
API-->>Repo: FeedDetailResponseDto
Repo->>Repo: to FeedDetailEntity (map novel + feedWriterNovelRating)
Repo-->>VM: FeedDetailEntity
VM->>UserRepo: fetchMyProfile()
UserRepo-->>VM: MyProfileEntity
VM->>VM: combine feed + profile + comments, update feedDetailUiState
VM->>NotiRepo: updateNotificationRead(notificationId) (opt)
VM-->>UI: feedDetailUiState updated
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
✨ Finishing Touches
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
app/build.gradle.kts (1)
145-145: 🛠️ Refactor suggestion | 🟠 Major버전 카탈로그에 이미 정의된 paging 라이브러리를 사용하도록 변경하세요.
버전 카탈로그(
gradle/libs.versions.toml)에는 paging 라이브러리가 이미 정의되어 있지만, 현재 코드에서는 하드코딩된 버전3.3.2를 사용하고 있습니다. 더 큰 문제는 카탈로그의 버전은3.3.6으로, 버전 불일치가 발생하고 있다는 점입니다. 다른 모든 의존성은libs.xxx패턴을 따르고 있으므로 이에 맞춰 변경해야 합니다.♻️ 수정 방법
build.gradle.kts에서 다음과 같이 변경하세요:
- implementation("androidx.paging:paging-runtime:3.3.2") + implementation(libs.paging.runtime)버전은 카탈로그의
paging = "3.3.6"에서 관리됩니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/build.gradle.kts` at line 145, The dependency is hardcoded as implementation("androidx.paging:paging-runtime:3.3.2") which conflicts with the version catalog (paging = "3.3.6"); replace that hardcoded dependency with the version-catalog entry (the libs.paging alias used by other deps) so the module uses libs.paging from gradle/libs.versions.toml instead of a literal 3.3.2 string, ensuring consistency with the catalog-managed version.feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt (1)
88-115:⚠️ Potential issue | 🟠 Major비동기 응답이 현재 선택된 목록에 잘못 반영될 수 있습니다.
성공 블록에서
currentData를 응답 시점에 다시 읽고 있어서, 요청 후 탭/카테고리를 바꾸면 이전 요청 결과가 현재 탭 목록에 append되거나 삭제/신고가 원래 리스트에 반영되지 않을 수 있습니다. 요청 시작 시점의selectedTab/sosoCategory를 캡처해서 해당 source만 갱신하도록 고정해 주세요.Also applies to: 156-179, 198-201, 221-246
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.kt` around lines 88 - 115, The success handler in FeedViewModel's viewModelScope.launch reads currentData at response time, which lets stale async responses update the wrong tab/category; capture the request's context (e.g., val requestedTab = state.selectedTab and val requestedCategory = state.sosoCategory) immediately before calling getMyFeedsUseCase/getFeedsUseCase and, in onSuccess, only update the source when the current UI state still matches those captured values (or locate the target source by an id/tag and update that specific source via updateCurrentSource), ensuring FeedViewModel's updateCurrentSource call only mutates the list belonging to the originally requested tab/category.app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt (2)
327-327:⚠️ Potential issue | 🟡 Minor메서드 이름에 오타가 있습니다.
setupBackgorundView→setupBackgroundView로 수정이 필요합니다.🔧 수정 제안
- private fun setupBackgorundView() { + private fun setupBackgroundView() {Line 328의 호출부도 함께 수정하세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt` at line 327, Rename the misspelled method setupBackgorundView to setupBackgroundView in the NovelFeedFragment class and update all call sites (including the invocation currently calling setupBackgorundView) to the corrected name; ensure the method signature and any references (e.g., setupBackgorundView in NovelFeedFragment) are consistently renamed to setupBackgroundView to avoid compilation errors.
254-256:⚠️ Potential issue | 🟡 Minor예외에 메시지를 추가하세요.
디버깅을 위해 의미 있는 메시지를 포함시키세요.
🔧 수정 제안
- } ?: throw IllegalArgumentException() + } ?: throw IllegalArgumentException("Feed with id $feedId not found for editing")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedFragment.kt` around lines 254 - 256, The IllegalArgumentException thrown at the end of the expression (currently `?: throw IllegalArgumentException()`) has no message; replace it with a descriptive message that includes context (e.g., mention the operation and relevant identifiers such as `feed`, `feed.id`, or current position) so the throw in NovelFeedFragment (where `imageUrls = feed.imageUrls` is used) becomes `?: throw IllegalArgumentException("...")` with a clear, actionable message for debugging.
🟠 Major comments (24)
feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt-73-73 (1)
73-73:⚠️ Potential issue | 🟠 Major
onWriteFeedClick를 빈 람다로 넘기면 작성 진입이 끊깁니다.Line 73에서 새로 추가된 작성 액션이 아무 동작도 하지 않아서,
FeedScreen내부의 작성 CTA를 눌러도 이동이 발생하지 않습니다. 기존 동선을 유지하려는 의도라면onWriteClick을 그대로 전달하는 쪽이 맞습니다.수정 예시
- onWriteFeedClick = { }, + onWriteFeedClick = onWriteClick,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt` at line 73, The new FeedRoute is passing an empty lambda to the FeedScreen's onWriteFeedClick, which prevents navigation; replace the empty lambda with the existing onWriteClick handler so FeedScreen's CTA triggers the original write flow (update the use of onWriteFeedClick to forward onWriteClick where it is passed into FeedScreen).feature/signin/src/main/java/com/into/websoso/feature/signin/component/SignInButtons.kt-35-40 (1)
35-40:⚠️ Potential issue | 🟠 Major클릭 가능한 로그인 이미지에 접근성 이름을 넣어주세요.
contentDescription = null인 상태로 로그인 액션을Image에 연결하면 TalkBack 사용자는 이 버튼의 의미를 알 수 없습니다. 최소한 로컬라이즈된contentDescription을 제공하고, 가능하면 버튼 role도 함께 지정해 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/signin/src/main/java/com/into/websoso/feature/signin/component/SignInButtons.kt` around lines 35 - 40, The Image currently sets contentDescription = null making the clickable login icon inaccessible; update the Image in SignInButtons.kt to provide a localized contentDescription (e.g., use stringResource(R.string.signin_kakao) or a passed-in localized label) and add button semantics (e.g., attach Role.Button via Modifier.semantics or using a clickable with role) while keeping the existing modifier.debouncedClickable { onClick(AuthPlatform.KAKAO) } so TalkBack will announce it as a tappable "Kakao login" control.app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt-24-24 (1)
24-24: 🛠️ Refactor suggestion | 🟠 Major사용되지 않는 의존성
libraryRepository가 주입되어 있습니다.
libraryRepository가 생성자에 주입되어 있지만 클래스 내에서 사용되지 않습니다. AI 요약에 따르면deleteAllNovels()호출이 제거되었으므로, 이 의존성도 함께 제거해야 합니다.♻️ 제안된 수정
`@HiltViewModel` class WithdrawSecondViewModel `@Inject` constructor( private val accountRepository: AccountRepository, private val pushMessageRepository: PushMessageRepository, private val userRepository: UserRepository, - private val libraryRepository: MyLibraryRepository, private val filterRepository: MyLibraryFilterRepository, ) : ViewModel() {또한 사용되지 않는 import도 제거하세요:
-import com.into.websoso.data.library.repository.MyLibraryRepository🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt` at line 24, Remove the unused constructor dependency libraryRepository: MyLibraryRepository from the WithdrawSecondViewModel class and delete its related import—locate the primary constructor of WithdrawSecondViewModel and remove the libraryRepository parameter and any unused import or references (e.g., MyLibraryRepository) left after deleting deleteAllNovels(); ensure the class compiles and no other methods reference libraryRepository before committing.app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt-63-67 (1)
63-67:⚠️ Potential issue | 🟠 Major
onResume()에서 매번refresh()호출로 인한 불필요한 데이터 재로딩매 resume마다 (탭 전환, 상세 화면 복귀 등)
libraryViewModel.refresh()를 호출하면loadLibrary()가 실행되어 paging job을 취소하고 새로운 데이터 흐름을 시작합니다. 이는 매번 새로운 네트워크 요청을 트리거하므로 불필요한 재로딩과 UX 중단으로 이어질 수 있습니다.
resetScrollPosition()처럼 명시적인 신호가 필요한 경우에만 갱신하도록 분리하거나, 데이터 변경이 감지되었을 때만 자동 갱신하는 구조로 변경하는 것을 권장합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt` around lines 63 - 67, The onResume() currently calls libraryViewModel.refresh() unconditionally causing loadLibrary() to cancel and restart paging on every resume; change this to only trigger refresh when explicitly required (e.g., on resetScrollPosition() or when a ViewModel flag indicates data is stale) by removing the unconditional call in onResume() and instead expose a method/LiveData in LibraryViewModel (e.g., needsRefresh or refreshIfStale) that onResume() can check or observe, or call libraryViewModel.refresh() only from the explicit UI actions that require reload; ensure loadLibrary() remains lifecycle-safe and only restarts when that flag/action demands it.app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt-29-48 (1)
29-48:⚠️ Potential issue | 🟠 Major피드 캐시가 카테고리/새로고침 경계를 모르고 계속 누적됩니다.
_cachedFeeds가 단일 리스트라서 카테고리 전환이나 첫 페이지 재조회에서도 이전 결과가 남습니다. 지금처럼 호출부가 항상clearCachedFeeds()를 기억해야 맞는 구조면 누락 시 바로 데이터가 오염되니, repository 내부에서category변경이나lastFeedId == 0을 기준으로 직접 초기화하는 편이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt` around lines 29 - 48, The cache _cachedFeeds is shared across categories so fetchFeeds can return mixed results; update fetchFeeds to clear _cachedFeeds when the requested category differs from the previously-used category or when lastFeedId == 0 (indicating a refresh/first page); track the last requested category in a private var (e.g. private var lastCategory: String? within the repository), call _cachedFeeds.clear() and set lastCategory = category when category != lastCategory or lastFeedId == 0 before adding new feeds, and keep existing clearCachedFeeds() for external use.data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt-65-74 (1)
65-74:⚠️ Potential issue | 🟠 Major삭제 실패를 삼키면
FeedViewModel이 실패를 감지할 수 없습니다.이 메서드가 예외를 먹고 정상 복귀하면
FeedViewModel.updateRemovedFeed()는 서버 삭제 실패여도 리스트에서 항목을 제거하게 됩니다. 캐시 정리는 API 성공 뒤에만 하고, 실패는 그대로 호출부까지 전달해 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt` around lines 65 - 74, The saveRemovedFeed function currently swallows API failures via runCatching, causing FeedViewModel.updateRemovedFeed to act as if deletion succeeded; change saveRemovedFeed so it only performs cache removals (cachedFeeds.removeIf and cachedRecommendedFeeds.removeIf) after a successful feedApi.deleteFeed call and allow failures to propagate to the caller (i.e., remove the runCatching swallow or rethrow the caught exception after logging) so callers can detect deletion errors; reference saveRemovedFeed, feedApi.deleteFeed, cachedFeeds, cachedRecommendedFeeds and FeedViewModel.updateRemovedFeed when making this change.app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt-20-30 (1)
20-30:⚠️ Potential issue | 🟠 Major검색/필터 캐시가 요청 조건과 연결되어 있지 않습니다.
지금 구조는
searchWord나 필터 조합이 바뀌어도 같은 캐시에 계속addAll해서, 호출부가clearCached…()를 놓치면 이전 결과와isLoadable상태가 그대로 섞입니다. 최소한page == 0/1또는 조건 변경 시점에는 repository 내부에서 캐시를 초기화해 주세요.Also applies to: 50-67, 79-105
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt` around lines 20 - 30, The current cache fields (cachedNormalExploreIsLoadable, _cachedNormalExploreResult, cachedDetailExploreResult and cachedDetailExploreIsLoadable) are not tied to request parameters, causing stale mixes when searchWord/filters change; update the repository to reset the corresponding cache and isLoadable flag automatically whenever the request's identity changes (e.g., when page == 0 or 1, or when searchWord/filters differ from the last request) by storing the last request key and clearing _cachedNormalExploreResult/_cachedDetailExploreResult and resetting cachedNormalExploreIsLoadable/cachedDetailExploreIsLoadable inside the fetch methods (the explore/fetch functions that currently call addAll) before adding results; ensure this logic is applied symmetrically for both "normal" and "detail" flows so callers don't have to call clearCached…() manually.app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt-20-27 (1)
20-27:⚠️ Potential issue | 🟠 Major수정 경로에서
novelId!!로 바로 크래시할 수 있습니다.
isAlreadyRated == true인데novelRatingEntity.novelId가 비어 있으면 API 호출 전에 NPE가 납니다.requireNotNull로 계약을 명시하거나, 수정 경로에 쓰는 모델 자체를 non-null로 올려 주세요.수정 예시
suspend fun saveNovelRating( novelRatingEntity: NovelRatingEntity, isAlreadyRated: Boolean, ) { if (isAlreadyRated) { - userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData()) + val novelId = requireNotNull(novelRatingEntity.novelId) { + "isAlreadyRated=true 일 때 novelId는 필수입니다." + } + userNovelApi.putNovelRating(novelId, novelRatingEntity.toData()) } else { userNovelApi.postNovelRating(novelRatingEntity.toData()) } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt` around lines 20 - 27, In saveNovelRating, calling userNovelApi.putNovelRating(novelRatingEntity.novelId!!, ...) can NPE if novelId is null; change to explicitly requireNotNull(novelRatingEntity.novelId) (or validate and handle the null case) before calling userNovelApi.putNovelRating so the contract is clear and you avoid a silent crash; update the code around saveNovelRating, novelRatingEntity.novelId, and the userNovelApi.putNovelRating invocation to either throw a descriptive error via requireNotNull or route the null case to an appropriate fallback/validation path.app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt-107-113 (1)
107-113:⚠️ Potential issue | 🟠 Major삭제 실패를 삼키면 상위가 성공으로 오인합니다.
여기서 예외를 소비하면 호출부의
runCatching { saveRemovedFeed(...) }는 항상 성공으로 끝납니다. 서버 삭제가 실패해도 화면에서는 항목이 제거될 수 있으니, 예외를 다시 던지거나Result를 반환해서 상위가 실패를 분기할 수 있게 해 주세요.수정 예시
suspend fun saveRemovedFeed(feedId: Long) { - runCatching { - feedApi.deleteFeed(feedId) - }.onSuccess { - _cachedFeeds.removeIf { it.id == feedId } - } + feedApi.deleteFeed(feedId) + _cachedFeeds.removeIf { it.id == feedId } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt` around lines 107 - 113, The current saveRemovedFeed function swallows exceptions by using runCatching and never propagating failures, so callers cannot detect deletion failures; update saveRemovedFeed (and its callers) to either propagate the exception (remove runCatching or rethrow in onFailure) or change its signature to return a Result<Unit> and return the runCatching result; in either case ensure _cachedFeeds.removeIf { it.id == feedId } only runs on success (i.e., after a successful feedApi.deleteFeed(feedId)) and that callers check the propagated exception or Result to handle failure.feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt-234-248 (1)
234-248:⚠️ Potential issue | 🟠 Major탭 접근성 시맨틱 누락
Box + debouncedClickable만으로는Role.Tab및 선택 상태 시맨틱이 노출되지 않아 TalkBack이 현재 탭과 탭 전환 컨텍스트를 제대로 읽지 못합니다.selectable(role = Role.Tab, selected = selected)로 접근성 시맨틱을 추가하고, TalkBack/Accessibility Scanner로 선택 상태가 읽히는지 확인해 주세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt` around lines 234 - 248, The tab items built in FeedTab.entries currently use Box with debouncedClickable which lacks accessibility semantics; update the Box modifier chain where FeedTab.entries is iterated (the block using selectedTab, onTabClick and debouncedClickable) to include selectable(role = Role.Tab, selected = selected) (keeping debouncedClickable for click handling) so the TalkBack/Accessibility Scanner can read the tab role and selection state; after adding selectable, verify accessibility reads correctly.app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt-218-248 (1)
218-248:⚠️ Potential issue | 🟠 Major검색 응답 순서가 뒤집히면 최신 결과가 덮어써집니다.
이 두 메서드는 이전 요청을 취소하거나 요청 식별자를 검증하지 않습니다. 사용자가 빠르게 입력하거나 페이지 로딩 중 검색어를 바꾸면, 늦게 도착한 이전 응답이
_searchNovelUiState를 다시 써서 다른 검색어 결과가 노출될 수 있습니다.Also applies to: 255-285
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt` around lines 218 - 248, The updateSearchedNovels flow can write stale responses into _searchNovelUiState because concurrent launches are not cancelled or validated; change updateSearchedNovels to either cancel any previous search coroutine (keep a Job/MutableStateFlow for the active search launched via viewModelScope.launch and cancel it before starting a new one) or attach a unique requestId/typingText token captured before calling getSearchedNovelsUseCase and verify that the token still matches searchedText/typingText before updating _searchNovelUiState in onSuccess/onFailure; reference updateSearchedNovels, _searchNovelUiState, searchedText, viewModelScope.launch and getSearchedNovelsUseCase when applying the fix.feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt-144-157 (1)
144-157:⚠️ Potential issue | 🟠 Major삭제/신고 실패가 예외로 새고, 삭제는 로딩 상태에 갇힙니다.
여기서는
saveRemovedFeed/saveSpoilerFeed/saveImpertinenceFeed를 예외 처리 없이 호출합니다. 특히updateRemovedFeed()는 실패 시loading을 내리지 못해서 화면이 계속 로딩으로 남고, 나머지 두 메서드도 저장 실패가 그대로 코루틴 예외로 빠집니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt` around lines 144 - 157, updateRemovedFeed/updateReportedSpoilerFeed/updateReportedImpertinenceFeed call repository methods without error handling; wrap the repository calls in a try/catch (and a finally for updateRemovedFeed) so exceptions don't crash the coroutine and so loading is always set back to false: in updateRemovedFeed set loading=true before launch, then in the coroutine use try { feedRepository.saveRemovedFeed(feedId) } catch (e: Exception) { /* log/update _uiState with error */ } finally { _uiState.update { it.copy(loading = false) } }; apply similar try/catch around feedRepository.saveSpoilerFeed and feedRepository.saveImpertinenceFeed to log errors and update UI state instead of letting exceptions escape.app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt-330-358 (1)
330-358:⚠️ Potential issue | 🟠 Major비동기 압축 중 중복 추가하면 최대 이미지 수를 넘길 수 있습니다.
개수 검사는 압축 시작 전에 한 번만 수행되는데, 압축이 끝나기 전에
addImages()가 다시 호출되면 두 요청이 모두 통과합니다. 이후 성공 콜백에서 현재 개수를 다시 확인하지 않고 append해서MAX_IMAGE_COUNT를 초과한 첨부 상태가 만들어질 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt` around lines 330 - 358, The current pre-check in addImages plus async compression in addCompressedImages can race and exceed MAX_IMAGE_COUNT; after compression completes, re-check and update _attachedImages atomically instead of blindly appending. In addCompressedImages (onSuccess), use an atomic/state update (e.g., _attachedImages.update { current -> ... }) to verify current.size + compressedImages.size <= MAX_IMAGE_COUNT and only then return current + compressedImages; otherwise emit _exceedingImageCountEvent.tryEmit(Unit) and do not append. Keep retry behavior same but ensure the atomic check happens inside addCompressedImages before modifying _attachedImages.app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt-149-163 (1)
149-163:⚠️ Potential issue | 🟠 Major수정 대상 피드를
myFeedData에서만 찾으면 SOSO 탭의 내 글 편집이 깨집니다.
FeedItem은feed.isMyFeed를 넘기므로 SOSO/추천 탭의 내 글에서도 편집 플로우로 들어올 수 있습니다. 그런데 여기서는myFeedData.feeds만 조회해서, MY_FEED가 아직 로드되지 않았거나 현재 필터에서 빠진 경우IllegalArgumentException으로 바로 종료됩니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt` around lines 149 - 163, navigateToFeedEdit currently searches only updatedFeedViewModel.uiState.value.myFeedData.feeds and throws IllegalArgumentException if not found; change the lookup to search both myFeedData.feeds and the general feed list (e.g., updatedFeedViewModel.uiState.value.feedData.feeds or whatever holds SOSO/recommend feeds) before failing, then build the EditFeedModel from the found Feed; only throw IllegalArgumentException if the feed cannot be found in any source.app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt-178-190 (1)
178-190:⚠️ Potential issue | 🟠 Major신고 완료 콜백이 오래된
feedUiState로 최신 UI를 되돌릴 수 있습니다.두 메서드 모두 코루틴 시작 전에 캡처한
feedUiState를 성공/실패 시 다시 대입합니다. 요청 중에 좋아요 변경이나 목록 갱신이 일어나면 그 변경이 통째로 덮어써져서, 사용자가 방금 본 화면 상태가 롤백될 수 있습니다.Also applies to: 196-208
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt` around lines 178 - 190, Summary: updateReportedSpoilerFeed captures a stale feedUiState before launching the coroutine and then reassigns that old snapshot on success/failure, which can overwrite concurrent UI updates; the same pattern exists in the other method around lines 196-208. Fix: inside updateReportedSpoilerFeed (and the analogous method), do not reuse the pre-launch feedUiState variable—after the network call completes read the latest _uiState.value (or use _uiState.update { ... }) and apply only the field changes (set isLoading=false and isError as needed) so you merge with any intermediate updates instead of overwriting them; reference functions/fields: updateReportedSpoilerFeed, _uiState, viewModelScope, feedRepository.saveSpoilerFeed (and the similar method at 196-208) to locate and update the code.app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt-317-324 (1)
317-324:⚠️ Potential issue | 🟠 Major소설 선택 해제 시
novelId도 같이 초기화해야 합니다.지금은 UI 선택 상태와 제목만 비우고 실제 저장값인
novelId는 유지합니다. 이 상태로 저장하면 방금 해제한 소설이 그대로 생성/수정 요청에 실립니다.수정 예시
fun updateSelectedNovelClear() { searchNovelUiState.value?.let { searchNovelUiState -> val novels = searchNovelUiState.novels.map { novel -> novel.copy(isSelected = false) } _searchNovelUiState.value = searchNovelUiState.copy(novels = novels) - _selectedNovelTitle.value = "" } + novelId = null + _selectedNovelTitle.value = "" }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt` around lines 317 - 324, updateSelectedNovelClear currently clears only the UI selection flags and _selectedNovelTitle but leaves the stored novel identifier unchanged; modify updateSelectedNovelClear() to also reset the stored selected novel id (e.g., set _selectedNovelId or selectedNovelId to null/empty depending on its type) after clearing novels and title so the cleared novel won't be included in subsequent create/update requests; locate this in the same function alongside _searchNovelUiState and _selectedNovelTitle updates and ensure the reset matches the existing selected novel id property name and type.feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt-88-126 (1)
88-126:⚠️ Potential issue | 🟠 Major탭/카테고리 전환 중 응답이 돌아오면 잘못된 소스의 페이징 상태를 갱신합니다.
fetchNextPage()는 요청 시점의 탭/카테고리로 데이터를 가져오지만, 성공 시에는currentState.currentData에lastId와isLoadable을 기록합니다. 요청 중에 탭이나 소소 카테고리를 바꾸면 이전 응답이 새로 선택된 소스의 페이징 상태를 덮어써서 다음 페이지 로딩이 꼬일 수 있습니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt` around lines 88 - 126, fetchNextPage currently updates uiState.currentData for whatever is the current UI when the network returns, so a slow response from a previous tab/category overwrites the newly selected source; to fix, capture the request's identity before launching (e.g., val requestedTab = state.selectedTab and val requestedCategory = state.sosoCategory and any filter/sort key used) and after onSuccess compare the live currentState to those captured values (check currentState.selectedTab and currentState.sosoCategory / filter/sort match requestedTab/requestedCategory) and only then call currentState.updateCurrentSource(updatedSource). If they don't match, discard the result or merge into the correct source by locating/updating only the matching data source instead of the global currentData.app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt-271-285 (1)
271-285:⚠️ Potential issue | 🟠 Major키워드 편집 중 ‘전체 해제’가 저장 전 원본까지 지웁니다.
saveSelectedKeywords()가 커밋 지점인데,clearEditingKeyword()에서 이미novelRatingModel.userKeywords를 비우고 있습니다. 그래서 사용자가 해제 후 취소하면cancelEditingKeyword()가 복원할 원본이 없어집니다.수정 예시
fun clearEditingKeyword() { uiState.value?.let { uiState -> _uiState.value = uiState.copy( - novelRatingModel = uiState.novelRatingModel.copy(userKeywords = setOf()), keywordsModel = uiState.keywordsModel.copy( categories = uiState.keywordsModel.categories.map { category -> category.copy( keywords = category.keywords.map { keyword -> keyword.copy(isSelected = false)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt` around lines 271 - 285, clearEditingKeyword() currently clears novelRatingModel.userKeywords immediately which destroys the pre-edit original before saveSelectedKeywords() can commit; change clearEditingKeyword() to only reset keywordsModel (set each keyword.isSelected = false and keywordsModel.currentSelectedKeywords = setOf()) and avoid mutating novelRatingModel.userKeywords so cancelEditingKeyword() can still restore the original value; ensure saveSelectedKeywords() remains the single place that writes to novelRatingModel.userKeywords and that cancelEditingKeyword() reads the preserved original to revert UI state.app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt-224-235 (1)
224-235:⚠️ Potential issue | 🟠 MajorTODO 주석과 하드코딩된 값, 그리고 에러 핸들링 문제가 있습니다.
isLogin값을 받아오지만 무시하고 항상true로 설정합니다.onFailure에서 예외를 직접 throw하면 앱이 크래시될 수 있습니다.🐛 수정 제안
}.onSuccess { isLogin -> - // TODO: _isLogin.value = isLogin - _isLogin.value = true + _isLogin.value = isLogin }.onFailure { - throw it + _isLogin.value = false }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt` around lines 224 - 235, In updateLoginStatus(): stop ignoring the fetched value—assign the actual isLogin from runCatching.onSuccess to _isLogin (replace the hardcoded true) and remove the throw in onFailure; instead log the exception (or post it to an error state) and set a safe fallback like _isLogin.value = false so the app won't crash; reference userRepository.fetchIsLogin(), the runCatching block, onSuccess/onFailure handlers, and the _isLogin mutable state when making the change.data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt-278-294 (1)
278-294:⚠️ Potential issue | 🟠 Major동기화 실패 시 데이터 손실 가능성이 있습니다.
syncDirtyFeeds()에서 API 호출 전에dirtyFeedStates와originalFeedStates를 clear하고 있습니다. API 호출이 실패하면 변경사항이 손실됩니다.🔧 수정 제안
fun syncDirtyFeeds() { if (dirtyFeedStates.isEmpty()) return val syncMap = dirtyFeedStates.toMap() - dirtyFeedStates.clear() - originalFeedStates.clear() scope.launch { syncMap.forEach { (id, isLiked) -> runCatching { if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) + }.onSuccess { + dirtyFeedStates.remove(id) + originalFeedStates.remove(id) }.onFailure { Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) + // 실패한 항목은 dirtyFeedStates에 유지됨 } } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt` around lines 278 - 294, The current syncDirtyFeeds() clears dirtyFeedStates and originalFeedStates before performing network calls which can cause data loss if feedApi.postLikes/deleteLikes fail; change the logic to first copy dirtyFeedStates into syncMap (keep the originals intact), perform the API calls (using scope.launch and runCatching over syncMap entries), and only after each call succeeds remove the corresponding id from dirtyFeedStates and originalFeedStates (or remove them all after all calls succeed); ensure failures do not clear state so retries or rollback are possible—use the existing symbols syncDirtyFeeds, dirtyFeedStates, originalFeedStates, syncMap, scope.launch, feedApi.postLikes and feedApi.deleteLikes to locate and implement this change.app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt-226-241 (1)
226-241:⚠️ Potential issue | 🟠 Major오래된 상태 스냅샷을 기준으로 댓글 결과를 반영해서 다른 변경을 롤백할 수 있습니다.
Line 227에서 잡아 둔
feedDetailUiState를 네트워크 완료 후 Lines 234-240에서 그대로 사용하고 있습니다. 댓글 갱신 대기 중에updateLike()같은 다른 액션이 먼저 성공하면, 여기서 예전 스냅샷을 다시 써서 최신 값이 되돌아갈 수 있습니다. 완료 시점의 최신 상태를 읽어서 merge 하도록 바꾸는 편이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt` around lines 226 - 241, updateComments에서 네트워크 호출 시작 시 캡처한 feedDetailUiState 스냅샷을 그대로 사용해 결과를 병합하면(updateComments, feedDetailUiState) 다른 동시 작업(updateLike 등)으로 인한 최신 상태를 덮어쓸 수 있으니, 네트워크 성공 시점에 최신 _feedDetailUiState.value를 읽어 그 값과 comments 결과를 병합하도록 변경하세요; 구체적으로 onSuccess 블록에서 현재 _feedDetailUiState.value 또는 feedDetailUiState.value?.feedDetail을 다시 조회한 뒤 commentCount와 comments만 교체해 copy하여 _feedDetailUiState.value에 할당하도록 수정하면 됩니다.app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt-45-49 (1)
45-49:⚠️ Potential issue | 🟠 Major
isLiked = false기본값 때문에 이미 좋아요한 피드가 잘못 언라이크될 수 있습니다.Line 48의 기본값으로 호출부가 세 번째 인자를 넘기지 않으면, 서버 응답이
feedDetail.isLiked == true인 경우에도 Lines 72-76 분기가 실행되어likeCount를 1 줄인 UI가 만들어집니다. 값이 없을 때는 서버 상태를 그대로 쓰도록 분리하는 편이 안전합니다.🩹 제안된 수정
fun updateFeedDetail( feedId: Long, from: ResultFrom, - isLiked: Boolean = false, + isLiked: Boolean? = null, ) { this.feedId = feedId feedDetailUiState.value?.let { feedDetailUiState -> viewModelScope.launch { delay(300) @@ - val updatedFeed = if (feedDetail.isLiked == isLiked) { - uiFeed - } else if (!isLiked && feedDetail.isLiked) { - uiFeed?.copy( - isLiked = false, - likeCount = feedDetail.likeCount - 1, - ) - } else { - uiFeed?.copy( - isLiked = true, - likeCount = feedDetail.likeCount + 1, - ) - } + val updatedFeed = when { + isLiked == null || feedDetail.isLiked == isLiked -> uiFeed + isLiked == false && feedDetail.isLiked -> uiFeed?.copy( + isLiked = false, + likeCount = (feedDetail.likeCount - 1).coerceAtLeast(0), + ) + else -> uiFeed?.copy( + isLiked = true, + likeCount = feedDetail.likeCount + 1, + ) + }Also applies to: 69-81
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.kt` around lines 45 - 49, The updateFeedDetail function's isLiked default of false causes existing liked feeds to be erroneously unliked when callers omit the third argument; change the parameter to a nullable Boolean (isLiked: Boolean? = null) on updateFeedDetail and adjust the logic that computes feedDetail.likeCount / feedDetail.isLiked (the branch around the current conditional at/near the handling of likeCount and isLiked between the existing feedDetail retrieval and the UI state emission) to only modify likeCount and overwrite isLiked when isLiked != null, otherwise preserve the server-provided feedDetail values; apply the same nullable-check pattern to the same branch referenced in lines ~69-81.app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt-53-64 (1)
53-64:⚠️ Potential issue | 🟠 Major초기 로드 전체를 취소하지 않아 이전 요청이 현재 상세 상태를 덮어쓸 수 있습니다.
지금은
feedFlowJob만 취소되고fetchSingleFeed/fetchMyProfile/fetchComments는 계속 살아 있습니다. 같은 ViewModel에서updateFeedDetail()가 다시 호출되면, 이전 요청 결과가 늦게 도착해 현재feedId의 상태를 덮어쓸 수 있습니다. 초기 로드를 별도Job으로 묶어 같이 취소하거나, 결과 반영 전에feedId를 다시 검증해 주세요.🛠️ 수정 예시
private var feedId: Long = -1 var commentId: Long = -1 private set private var feedFlowJob: Job? = null + private var initialLoadJob: Job? = null ... fun updateFeedDetail( feedId: Long, from: ResultFrom, ) { this.feedId = feedId + initialLoadJob?.cancel() feedFlowJob?.cancel() ... - viewModelScope.launch { + initialLoadJob = viewModelScope.launch { launch { fetchSingleFeed(feedId) } launch { fetchMyProfile() } launch { fetchComments(feedId) } observeFeedStream(feedId) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt` around lines 53 - 64, The three initial-load coroutines (fetchSingleFeed, fetchMyProfile, fetchComments) can outlive feedFlowJob and overwrite state for a new feedId; wrap them in a single parent Job (e.g., initialLoadJob) or a coroutineScope/child Job started from viewModelScope, cancel that Job at the start of updateFeedDetail (alongside feedFlowJob), then launch the three fetches as children of that Job; alternatively, add a guard in the result-handling paths of fetchSingleFeed/fetchMyProfile/fetchComments to verify the current feedId before applying updates. Ensure you reference and update the existing symbols feedFlowJob, updateFeedDetail, fetchSingleFeed, fetchMyProfile, fetchComments, and observeFeedStream when implementing the change.app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt-102-103 (1)
102-103:⚠️ Potential issue | 🟠 Major
runCatching으로 코루틴 취소를 실패로 잘못 처리하고 있습니다.
runCatching은suspend호출을 포함한 모든Throwable을 잡으므로,CancellationException도 함께 캡처되어 실패로 처리됩니다. Kotlin 공식 문서는CancellationException을 다시 던지도록 명확히 권장하고 있습니다. 현재 코드는 취소된 작업을 일반 오류로 취급하여isServerError,error등의 실패 상태를 잘못 설정하거나(예: 라인 187, 201, 243), 일부는 취소를 무시하고 있습니다(라인 259, 268, 277, 286, 296).🛠️ 수정 패턴
+import kotlinx.coroutines.CancellationException ... - runCatching { feedRepository.fetchFeed(targetFeedId) } - .onSuccess { result -> - // 성공 처리 - } - .onFailure { - // 실패 처리 - } + try { + val result = feedRepository.fetchFeed(targetFeedId) + // 성공 처리 + } catch (e: CancellationException) { + throw e + } catch (_: Throwable) { + // 실패 처리 + }이 패턴이 파일 전반에 반복됩니다(라인 102–103, 154, 169, 187, 201, 216, 243, 259, 268, 277, 286, 296).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt` around lines 102 - 103, runCatching around suspend calls (e.g., the call to feedRepository.fetchFeed(targetFeedId) in UpdatedFeedDetailViewModel) is incorrectly treating CancellationException as a failure; change each runCatching block to rethrow CancellationException instead of handling it as an error: after catching/inspecting the Throwable from runCatching (or replace runCatching with a try/catch), if the exception is a kotlin.coroutines.cancellation.CancellationException rethrow it, otherwise proceed to set isServerError/error and other failure state as before; update every occurrence in UpdatedFeedDetailViewModel that wraps suspend calls (the blocks around feedRepository.fetchFeed, and the other runCatching usages noted) to follow this pattern so cancellations are not converted into normal failures.
🟡 Minor comments (9)
feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt-57-57 (1)
57-57:⚠️ Potential issue | 🟡 MinorColumn과 Text의 너비 불일치 문제
Column의 너비가
180.dp로 변경되었지만, 내부 Text의 너비는 여전히192.dp로 설정되어 있습니다. 부모보다 큰 자식 너비는 Compose의 제약 시스템에 의해 잘리지만, 코드의 의도가 불명확하고 불일치합니다.Text의 너비를 Column과 동일하게 맞추거나,
fillMaxWidth()를 사용하는 것이 적절합니다.🔧 수정 제안
modifier = Modifier - .width(width = 192.dp) + .width(width = 180.dp) .debouncedClickable {또는 고정 너비 대신
fillMaxWidth()사용:modifier = Modifier - .width(width = 192.dp) + .fillMaxWidth() .debouncedClickable {Also applies to: 66-66
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt` at line 57, Column의 너비가 .width(width = 180.dp)로 설정되어 있는데 내부 Text가 .width(192.dp)로 더 크게 설정되어 있어 불일치하므로 FeedMoreMenu.kt에서 Column과 해당 Text의 Modifier 크기를 일치시키세요; 수정 방법은 내부 Text의 Modifier.width(...)를 Column과 같은 180.dp로 변경하거나, 고정 너비 대신 Column의 자식에 Modifier.fillMaxWidth()를 사용하여 부모 너비에 맞추는 방식 중 하나를 선택하면 됩니다(참조: Column 및 해당 Text의 Modifier 설정).app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt-44-48 (1)
44-48:⚠️ Potential issue | 🟡 Minor사용자 정보 조회 실패를 그대로 삼키고 있습니다.
Line 44-48에서는 실패 시 아무 상태도 남기지 않아 화면에 빈 이메일만 표시됩니다. 최소한 에러 상태나 재시도 트리거는 내려주는 편이 안전합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt` around lines 44 - 48, The current runCatching around userRepository.fetchUserInfoDetail() in AccountInfoViewModel swallows failures and only updates _userEmail on success; add an onFailure handler to propagate an error state or retry trigger instead of leaving the UI blank. Modify the runCatching chain (around fetchUserInfoDetail()) to call onFailure and update a dedicated observable (e.g., _userError, _uiState, or _shouldRetry) with the exception or a retry flag so the UI can show an error message and allow retry; keep the existing _userEmail.update in onSuccess. Ensure the new observable is exposed as a public LiveData/StateFlow so views can react.feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetHeader.kt-39-43 (1)
39-43:⚠️ Potential issue | 🟡 Minor닫기 버튼 터치 영역이 padding을 포함하지 않습니다.
현재 순서에서는
vertical padding이 클릭 영역이 아니라 바깥 여백으로 적용돼서 실제 hit area가 아이콘 크기 수준으로 유지됩니다. 닫기 버튼은 자주 쓰는 액션이라debouncedClickable을padding보다 앞에 두거나 최소 터치 영역을 명시하는 편이 안전합니다.제안 수정안
Box( modifier = Modifier - .padding(vertical = 20.dp) - .debouncedClickable { + .debouncedClickable { onDismissRequest() - }, + } + .padding(vertical = 20.dp), ) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetHeader.kt` around lines 39 - 43, The close button's touch area currently excludes the vertical padding because .debouncedClickable is applied after .padding, so only the icon bounds are clickable; update the Modifier in LibraryFilterBottomSheetHeader (the composable using modifier = Modifier .padding(vertical = 20.dp) .debouncedClickable { onDismissRequest() }) to ensure the padding is inside the clickable area by moving .debouncedClickable before .padding or by adding an explicit minimum hit target (e.g., sizeIn or requiredSize with a sensible min touch target) so the debouncedClickable covers the padded area while still calling onDismissRequest().feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt-93-107 (1)
93-107:⚠️ Potential issue | 🟡 MinorLazyColumn의 itemsIndexed에 key가 누락되어 메뉴 상태가 잘못된 항목으로 이동할 수 있습니다.
FeedItem에서 라인 125의
remember { mutableStateOf(false) }로isMenuExpanded상태를 관리하고 있는데, LazyColumn의itemsIndexed(라인 94)에 key 파라미터가 없습니다. 피드 항목이 삭제되거나 삽입될 때 Compose가 인덱스 기반으로 상태를 재사용하여, 열려있던 메뉴가 다른 피드 항목에 붙는 문제가 발생할 수 있습니다.수정 예시
- LazyColumn(modifier = Modifier.padding(horizontal = 20.dp)) { - itemsIndexed(items = feeds) { index, feed -> + LazyColumn(modifier = Modifier.padding(horizontal = 20.dp)) { + itemsIndexed( + items = feeds, + key = { _, feed -> feed.id }, + ) { index, feed -> FeedItem( feed = feed, currentTab = currentTab,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedSection.kt` around lines 93 - 107, The LazyColumn's itemsIndexed call lacks a stable key, causing Compose to reuse index-based state (so FeedItem's remember { mutableStateOf(false) } for isMenuExpanded at line ~125 can attach to the wrong item); fix by passing a key param to itemsIndexed (use a stable unique identifier from each feed, e.g., feed.id or feed.uniqueId) so itemsIndexed(items = feeds, key = { _, feed -> feed.id }) ensures menu state in FeedItem binds to the correct feed when items are inserted or removed.app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt-128-132 (1)
128-132:⚠️ Potential issue | 🟡 Minor예외에 메시지를 추가하세요.
IllegalArgumentException을 던질 때 디버깅을 위한 메시지가 없습니다. Static analysis 도구에서도 경고하고 있습니다.🔧 수정 제안
- } ?: throw IllegalArgumentException() + } ?: throw IllegalArgumentException("Feed with id $selectedFeedId not found")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt` around lines 128 - 132, The throw of IllegalArgumentException in NovelFeedViewModel when no feed matches selectedFeedId lacks a message; update the code that computes selectedFeed (searching feedUiState.feeds for feedModel.id == selectedFeedId) to throw an IllegalArgumentException with a descriptive message including the missing selectedFeedId and/or a brief context (e.g., "No feed found for selectedFeedId=... in NovelFeedViewModel") so static analysis warnings are resolved and debugging is easier.app/src/main/java/com/into/websoso/ui/BindingAdapters.kt-36-39 (1)
36-39:⚠️ Potential issue | 🟡 MinorETC 보더 두께가 저밀도 기기에서 0px가 될 수 있습니다.
Line 37의
(1 * density).toInt()는 density 가 1 미만이면 0으로 잘립니다. 그 경우기타보더가 아예 그려지지 않으니 최소 1px은 보장하는 쪽이 안전합니다.🩹 제안된 수정
+import kotlin.math.roundToInt + ... - val strokeWidth = (1 * context.resources.displayMetrics.density).toInt() + val strokeWidth = + context.resources.displayMetrics.density.roundToInt().coerceAtLeast(1) val strokeColor = ContextCompat.getColor(context, R.color.gray_70_DFDFE3) drawable.setStroke(strokeWidth, strokeColor)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/BindingAdapters.kt` around lines 36 - 39, The ETC border width calculation in BindingAdapters.kt uses (1 * context.resources.displayMetrics.density).toInt() which can truncate to 0 on low-density devices; update the isEtc branch to compute strokeWidth with a minimum of 1 pixel (e.g., take the max between 1 and the density-scaled value or use ceil) before calling drawable.setStroke so the "기타" border is never zero-width.app/src/main/java/com/into/websoso/ui/novelDetail/model/Category.kt-19-19 (1)
19-19:⚠️ Potential issue | 🟡 Minor
bl_021A6FF색상 리소스 명명 규칙 재검토 권장.색상 리소스가 올바르게 정의되어 있으나 (
#21A6FF), 리소스 이름이 다른 색상들과 명명 규칙이 다릅니다. 대부분의 색상은prefix_HEXvalue형식(예:romance_E586CA→#E586CA,fantasy_6457FC→#6457FC)을 따르지만,bl_021A6FF는 이름의021A6FF와 실제 hex 값#21A6FF가 일치하지 않습니다. 명확성과 일관성을 위해bl_21A6FF로 변경하거나, 이 명명 방식의 의도를 코드에 문서화하는 것을 권장합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/novelDetail/model/Category.kt` at line 19, The color resource name for BOYS_LOVE in Category.kt is inconsistent (R.color.bl_021A6FF refers to hex `#21A6FF`); rename the color resource to match the pattern (e.g., bl_21A6FF) and update the reference in the enum constant BOYS_LOVE (and any other usages) accordingly, or alternatively add a brief comment in Category.kt documenting why the current atypical name is intentional if you decide not to rename; ensure the drawable/resource file name and R.color reference stay in sync.data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt-49-52 (1)
49-52:⚠️ Potential issue | 🟡 Minor실패 응답에서 전체 개수를 0으로 덮어쓰지 마세요.
여기서는 페이지 로드가 실패해도
novelTotalCount를 바로0으로 바꿔서, 이미 화면에 남아 있는 목록과 상단 개수가 쉽게 어긋납니다. 성공 응답에서만 갱신하고, 필터 변경 시 초기화가 필요하면 그 시점에 별도로 리셋하는 편이 안전합니다.🛠️ 수정 예시
getUserNovels(lastUserNovelId, currentFilter).also { result -> - _novelTotalCount.update { - result.getOrNull()?.userNovelCount ?: 0 - } + result.getOrNull()?.userNovelCount?.let { totalCount -> + _novelTotalCount.update { totalCount } + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt` around lines 49 - 52, The code currently overwrites _novelTotalCount to 0 on failed page loads because you use result.getOrNull()?.userNovelCount ?: 0; change the logic so you only update _novelTotalCount when the call to getUserNovels(lastUserNovelId, currentFilter) succeeds (e.g., check result.isSuccess or result.getOrNull() != null) and update with result.getOrNull()!!.userNovelCount; do not write 0 on failure—if you need to reset the total when the filter changes, do that explicitly at the filter-change handler rather than here; refer to getUserNovels, _novelTotalCount and currentFilter to locate the code to change.data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt-55-58 (1)
55-58:⚠️ Potential issue | 🟡 Minor실패 응답에서 전체 개수를 0으로 덮어쓰지 마세요.
여기도 페이지 로드 실패 시
novelTotalCount가 즉시0으로 바뀌어서, 이미 표시 중인 목록과 카운트가 불일치할 수 있습니다. 성공 응답에서만 갱신하고, 필터 전환 시 초기화가 필요하면 그 시점에 명시적으로 처리하는 편이 안전합니다.🛠️ 수정 예시
getUserNovels(lastUserNovelId, currentFilter).also { result -> - _novelTotalCount.update { - result.getOrNull()?.userNovelCount ?: 0 - } + result.getOrNull()?.userNovelCount?.let { totalCount -> + _novelTotalCount.update { totalCount } + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt` around lines 55 - 58, The code unconditionally writes 0 into _novelTotalCount when getUserNovels fails; change the update so that _novelTotalCount is only modified on a successful response from getUserNovels (e.g., check result.isSuccess or result.getOrNull() != null and then update with result.getOrNull()!!.userNovelCount), and do not set it to 0 on failure—handle filter-reset initialization of novelTotalCount explicitly elsewhere (on filter change) rather than in this failure path; locate this logic around the getUserNovels(...) call and the _novelTotalCount.update usage to apply the conditional update.
There was a problem hiding this comment.
Remaining comments which cannot be posted as a review comment to avoid GitHub Rate Limit
ktlint
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (4) (should be 8)
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (5) (should be 9)
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (5) (should be 9)
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (4) (should be 8)
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (12) (should be 16)
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (12) (should be 16)
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (12) (should be 16)
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (8) (should be 12)
🚫 [ktlint] standard:indent reported by reviewdog 🐶
Unexpected indentation (12) (should be 16)
🚫 [ktlint] standard:annotation reported by reviewdog 🐶
Expected newline before annotation
🚫 [ktlint] standard:annotation reported by reviewdog 🐶
Expected newline after last annotation
🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Unexpected newline before '.'
🚫 [ktlint] standard:chain-method-continuation reported by reviewdog 🐶
Unexpected newline before '.'
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (6)
feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt (1)
234-259:⚠️ Potential issue | 🟠 Major탭과 쓰기 액션에 접근성 정보를 추가해야 합니다.
Box + debouncedClickable으로 구현된 탭은debouncedClickable이 기본Modifier.clickable()을 사용하기 때문에Role.Tab과 선택 상태 의미론(semantics)이 전달되지 않습니다. 선택 상태는 텍스트 색상(Black/Gray100)으로만 표시되어 스크린리더에는 전달되지 않으며, 쓰기 액션 아이콘의contentDescription = null로 인해 스크린리더가 제공할 레이블이 없습니다.Material3의
Tabcomposable을 사용하거나, 현재 구조를 유지하려면 다음 중 하나를 적용하세요:
Modifier.selectable(selected = ..., role = Role.Tab)를 탭에 추가Modifier.semantics { contentDescription = "..." }또는Modifier.semantics { selected = ... }명시- 쓰기 액션 아이콘에 의미 있는
contentDescription값 제공 (예: "글 작성")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt` around lines 234 - 259, Tabs rendered in the FeedTab.entries loop use Box + debouncedClickable and only indicate selection via color, and the write Icon has contentDescription = null; update accessibility by adding semantics/role: for each tab Box (inside the FeedTab.entries loop where selected is computed from selectedTab and onTabClick is invoked via debouncedClickable) add Modifier.selectable(selected = selected, role = Role.Tab) or Modifier.semantics { selected = selected } so screen readers get the selection state and role; and for the write action (where debouncedClickable calls onWriteClick and Icon currently has contentDescription = null) provide a meaningful contentDescription like "글 작성" (or use Modifier.semantics { contentDescription = "글 작성" }) so the write button is announced.data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt (1)
60-80:⚠️ Potential issue | 🟠 Major
runCatching으로 취소를 삼키면 Paging invalidation이 에러로 바뀝니다.필터 변경이나 새로고침 시
flatMapLatest가 기존 PagingSource를 무효화하고 coroutine을 취소하는데, 여기서CancellationException까지runCatching으로 잡으면 LibraryPagingSource의onFailure에서 모든 예외가LoadResult.Error로 변환됩니다(8-31줄). 결과적으로 취소된 요청도 로드 에러 상태로 노출됩니다. 취소는 재던지고, 나머지 예외만Result.failure로 감싸세요.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt` around lines 60 - 80, The current getUserNovels uses runCatching which swallows CancellationException causing canceled PagingSource loads to become LoadResult.Error; change the error handling so CancellationException is rethrown and only non-cancellation exceptions are wrapped as failures: replace or augment the runCatching around libraryRemoteDataSource.getUserNovels in getUserNovels to catch Throwable, if it is a CancellationException rethrow it, otherwise return Result.failure(exception); keep the successful return path unchanged so LibraryPagingSource.onFailure only sees genuine errors (non-cancellation).data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt (1)
66-86:⚠️ Potential issue | 🟠 Major
runCatching이CancellationException을 감싸면 페이징 취소가 정상적으로 작동하지 않습니다.
runCatching은CancellationException을 포함한 모든Throwable을 잡으므로, 취소된 요청이Result.failure로 변환되고LibraryPagingSource에서LoadResult.Error로 처리됩니다. 필터 변경이나 새로고침 시 이전 요청의 취소를 방해합니다.
CancellationException은 다시 던지고, 나머지 예외만Result.failure로 변환하세요:suspend inline fun <T> runCatchingCancellable(crossinline block: suspend () -> T): Result<T> = try { Result.success(block()) } catch (e: CancellationException) { throw e } catch (t: Throwable) { Result.failure(t) }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt` around lines 66 - 86, The getUserNovels helper currently wraps libraryRemoteDataSource.getUserNovels in runCatching which swallows CancellationException and breaks paging cancellation; replace runCatching with a cancellable wrapper (e.g., implement runCatchingCancellable as described) and call that from getUserNovels (referencing getUserNovels and runCatching usage) so CancellationException is rethrown while other Throwables are converted to Result.failure; ensure the new helper is suspend inline and catches CancellationException separately before returning Result.success/failure.app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt (3)
234-241:⚠️ Potential issue | 🟡 Minor선택 상태 토글 로직이 의도와 다를 수 있습니다.
novel.id == novelId인 경우!it.isSelected로 토글하면, 검색 결과에서 기존 선택된 소설이 선택 해제될 수 있습니다. 편집 모드에서 기존 선택된 소설은 항상 선택 상태로 표시되어야 합니다.🐛 제안된 수정
novels = result.novels.map { novel -> if (novel.id == novelId) { novel .toUi() - .let { it.copy(isSelected = !it.isSelected) } + .copy(isSelected = true) } else { novel.toUi() } },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt` around lines 234 - 241, The toggle logic currently flips isSelected when novel.id == novelId, which can accidentally deselect the pre-selected novel; in CreateFeedViewModel change the branch that handles novel.id == novelId to set isSelected = true (use novel.toUi().copy(isSelected = true)) instead of toggling with !it.isSelected so the existing selected novel remains selected in edit mode while other novels continue to use novel.toUi().
269-277:⚠️ Potential issue | 🟡 Minor페이지네이션 메서드에서도 동일한 토글 로직 문제가 있습니다.
위와 동일하게
isSelected = true로 수정해야 합니다.🐛 제안된 수정
novels = result.novels.map { novel -> if (novel.id == novelId) { novel .toUi() - .let { it.copy(isSelected = !it.isSelected) } + .copy(isSelected = true) } else { novel.toUi() } },🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt` around lines 269 - 277, 페이지네이션 처리 로직에서 현재 novel.id == novelId 인 항목을 토글하는 대신 항상 선택 상태로 설정해야 합니다; CreateFeedViewModel의 해당 매핑 블록(uses result.novels.map { novel -> ... }, 호출되는 메서드명/변수 novelId, toUi(), isSelected)을 찾아 novel.id == novelId일 때 .toUi().let { it.copy(isSelected = true) }로 변경하고 나머지 항목은 그대로 novel.toUi()를 반환하도록 수정하세요.
167-172:⚠️ Potential issue | 🟠 Major피드 저장 실패 시 사용자 피드백이 없습니다.
onFailure블록에서_isUploading만 false로 설정하고 에러 이벤트를 발행하지 않습니다. 사용자가 저장 실패 여부를 알 수 없습니다.🐛 에러 이벤트 추가 제안
+private val _updateFeedErrorEvent: MutableSharedFlow<Unit> = MutableSharedFlow() +val updateFeedErrorEvent: SharedFlow<Unit> get() = _updateFeedErrorEvent.asSharedFlow() // createFeed와 editFeed의 onFailure 블록에서: }.onFailure { _isUploading.value = false + _updateFeedErrorEvent.emit(Unit) }Also applies to: 195-200
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt` around lines 167 - 172, The onFailure branch currently only sets _isUploading.value = false and provides no user feedback; add and emit an error event (e.g., create or use _updateFeedErrorEvent) inside the onFailure handlers where _isUploading is set to false so the UI can display a failure message or retry option; update both occurrences (the block around lines with onSuccess/onFailure and the similar block at 195-200) to emit the error (or a mapped user-friendly message) along with stopping the uploading state so the View can observe _updateFeedErrorEvent and show feedback.
🧹 Nitpick comments (3)
feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt (1)
53-53:novels를 읽기 전용으로 노출하세요.지금 타입이면 화면 코드도
PagingData를 직접 덮어쓸 수 있어서 ViewModel 상태 경계가 깨집니다._novels는private으로 두고StateFlow<PagingData<NovelUiModel>>만 공개하는 편이 안전합니다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt` at line 53, 현재 public으로 선언된 `novels: MutableStateFlow<PagingData<NovelUiModel>>` 때문에 외부에서 상태를 덮어쓸 수 있으므로 `_novels`를 private MutableStateFlow로 변경하고 외부에는 읽기 전용 `StateFlow<PagingData<NovelUiModel>>` 타입의 `novels`만 노출하도록 수정하세요; 즉 클래스 내부에 `private val _novels = MutableStateFlow(PagingData.empty<NovelUiModel>())`를 두고 기존 `novels`를 `val novels: StateFlow<PagingData<NovelUiModel>> = _novels`로 바꿔 상태 캡슐화를 강제하세요.app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt (1)
346-361: 재시도 로직에 지연(delay)이 없습니다.현재 구현은 실패 시 즉시 재시도하여 시스템에 부담을 줄 수 있습니다. 지수 백오프(exponential backoff) 또는 최소 지연을 추가하는 것을 권장합니다.
♻️ 백오프 지연 추가 제안
+import kotlinx.coroutines.delay private fun addCompressedImages( newImages: List<Uri>, retryCount: Int = 0, ) { if (retryCount > MAX_RETRY_COUNT) return viewModelScope.launch { runCatching { feedRepository.compressImages(newImages) }.onSuccess { compressedImages -> _attachedImages.value = attachedImages.value + compressedImages }.onFailure { + delay(1000L * (retryCount + 1)) // 점진적 지연 addCompressedImages(newImages, retryCount + 1) } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt` around lines 346 - 361, The addCompressedImages retry path currently retries immediately; introduce a suspend delay with exponential backoff before retrying to avoid tight retry loops: inside the viewModelScope.launch that calls feedRepository.compressImages, compute a backoff delay (e.g., BASE_DELAY_MS * 2.pow(retryCount) capped by MAX_DELAY_MS), call kotlinx.coroutines.delay(backoff) before invoking addCompressedImages(newImages, retryCount + 1) (or refactor the recursion to a loop that delays between attempts), keeping the existing MAX_RETRY_COUNT check and ensuring the delay runs inside the coroutine so cancellation works; update constants or add BASE_DELAY_MS and MAX_DELAY_MS as needed and reference addCompressedImages, retryCount, MAX_RETRY_COUNT, viewModelScope.launch, feedRepository.compressImages, and _attachedImages in your change.app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt (1)
253-287: 신고 API 호출 결과 처리가 없습니다.모든 신고 메서드가
runCatching결과를 무시하고 있어 사용자에게 성공/실패 피드백이 전달되지 않습니다. 최소한 에러 로깅 또는 UI 피드백을 고려해주세요.♻️ 에러 처리 추가 제안
fun updateReportedSpoilerFeed() { viewModelScope.launch { runCatching { feedRepository.saveSpoilerFeed(feedId) } + .onFailure { + _feedDetailUiState.value = + _feedDetailUiState.value?.copy(isServerError = true) + } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt` around lines 253 - 287, Each report method (updateReportedSpoilerFeed, updateReportedImpertinenceFeed, updateReportedSpoilerComment, updateReportedImpertinenceComment) currently calls runCatching and drops the result; update each to handle success and failure by chaining .onSuccess { ... } and .onFailure { ... } on the runCatching result: onSuccess emit a UI event or update a StateFlow/LiveData indicating report succeeded (e.g., post a toast/snackbar or set a _reportResult/_uiEvent), and onFailure log the throwable and emit an error UI event so the user sees failure; use the existing viewModelScope and feedRepository.save* methods and ensure logging includes the exception.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt`:
- Around line 23-24: The code uses a forced non-null (!!) on
novelRatingEntity.novelId in the block guarded by isAlreadyRated when calling
userNovelApi.putNovelRating; remove the unsafe !! and instead ensure a safe
non-null value by handling the nullable: e.g., check novelRatingEntity.novelId
with an if/let or use the elvis operator to provide a clear fallback or throw a
descriptive IllegalStateException before calling userNovelApi.putNovelRating
(update the logic in the same method in UserNovelRepository.kt where
isAlreadyRated is evaluated and the call to userNovelApi.putNovelRating occurs).
In `@app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt`:
- Around line 149-164: navigateToFeedEdit currently looks up the feed only in
updatedFeedViewModel.uiState.value.myFeedData and throws
IllegalArgumentException if not found, causing a crash when an editable item
exists in the visible list but not in myFeedData; change the lookup to first try
the current visible feed collection (e.g., the fragment's displayed feed list or
uiState.value.feedList/currentTabFeeds) and only then fall back to myFeedData,
and if still not found avoid throwing—either return silently or trigger a
refresh/reload (e.g., call the existing refresh method) instead of throwing;
update navigateToFeedEdit to handle a nullable result safely and proceed to
EditFeedModel creation only when the feed is found.
In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`:
- Around line 314-347: The three suspend functions saveRemovedFeed,
saveSpoilerFeed, and saveImpertinenceFeed currently swallow errors by using
runCatching without propagating failures; update them so callers can detect
failures by either rethrowing the caught exception or returning a Result: call
feedApi within runCatching, onSuccess keep the existing cache updates
(removeFromFlow/markAsSpoilerInFlow on
_sosoAllFeeds/_sosoRecommendedFeeds/_myFeeds) and in onFailure either rethrow
the Throwable (so the suspend function throws) or return the failure as
Result.failure(...) (and change the function signature to return Result<Unit>)
so the caller (e.g., UpdatedFeedViewModel) can observe errors and retry.
- Around line 96-127: saveEditedFeed currently updates local cache via
updateFeedInLocalCache then launches a background sync to feedApi.putFeed and
swallows failures with Log.e, leaving callers unaware and the UI inconsistent on
server rollback; change saveEditedFeed to surface failures (e.g., return a
Result/Boolean or suspend and throw) or accept a callback so callers can handle
errors, and on failure undo the optimistic local change by calling
updateFeedInLocalCache with the original data (capture pre-edit state before
calling updateFeedInLocalCache) or enqueue a retry mechanism; ensure
feedApi.putFeed failures from the scope.launch/runCatching block are propagated
to the caller or trigger a rollback/retry path and keep the Log.e for
diagnostics.
- Around line 289-304: syncDirtyFeeds currently copies dirtyFeedStates to
syncMap and immediately clears dirtyFeedStates and originalFeedStates, which
loses pending changes on network failure and can cause duplicate sends on
concurrent calls; fix by making the snapshot-and-clear atomic (use a mutex or
synchronized block) and only clear/remove entries after they have been
successfully synced (e.g., iterate over the snapshot and on success remove the
id from dirtyFeedStates and originalFeedStates, or collect failures and put them
back), ensure scope.launch runs outside the lock so network IO doesn't hold the
mutex, and update syncDirtyFeeds, dirtyFeedStates, originalFeedStates, and the
scope.launch usage accordingly to prevent lost or duplicated like-syncs.
In
`@data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt`:
- Around line 49-52: 현재 getUserNovels(...) 결과가 실패일 때도 _novelTotalCount.update {
... }로 0을 덮어써 이전 카운트를 잃고 있으므로, _novelTotalCount는 성공 응답에서만 갱신하도록 변경하세요:
getUserNovels(...) 호출 후 result를 검사하여 성공일 때만 result.getOrNull()?.userNovelCount로
_novelTotalCount를 업데이트하고, 실패인 경우에는 기존 값을 유지하도록 처리하도록 변경(예: result.isSuccess 확인
또는 fold/onSuccess 사용). 참조 심볼: getUserNovels, result, _novelTotalCount.
In
`@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt`:
- Around line 55-58: The current code unconditionally updates _novelTotalCount
with result.getOrNull()?.userNovelCount ?: 0 which overwrites the previous total
on any failure; change the update so it only sets _novelTotalCount when
getUserNovels(...) returns a successful value (e.g., check result.isSuccess or
result.getOrNull() != null or use result.onSuccess) and otherwise leave the
existing _novelTotalCount untouched; locate the getUserNovels(...) call and the
_novelTotalCount.update block and wrap the update so it runs only for successful
results (use result.getOrNull()?.userNovelCount to guard the update).
In `@domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.kt`:
- Around line 48-60: The returned Feeds object currently uses feeds =
emptyList() which breaks pagination; change the return to include the converted
feeds (use convertedFeeds from myFeedsEntity.feeds.map) so
UpdatedGetMyFeedsUseCase returns the actual list used by the consumer to compute
lastId; ensure you still call feedRepository.updateMyFeedsCache(feeds =
convertedFeeds, ...) and return Feeds(category = "내 활동", isLoadable =
myFeedsEntity.isLoadable, feeds = convertedFeeds) so the viewmodel can compute
next lastId correctly.
In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt`:
- Line 69: FeedSection's write callback is being dropped because FeedRoute
passes onWriteFeedClick = { } while only UpdateFeedRoute forwards the real
onWriteFeedClick; update FeedRoute (or remove the deprecated route) so it
forwards the provided onWriteFeedClick into FeedSection/UpdateFeedRoute instead
of a no-op, ensuring the same handler is invoked for the write entry point
(reference FeedRoute, UpdateFeedRoute, FeedSection, onWriteFeedClick). Also fix
accessibility in FeedTabRow by giving the pencil icon a meaningful
contentDescription (use an appropriate string resource or passed label rather
than null) so screen readers announce the action (reference FeedTabRow and
contentDescription).
In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt`:
- Around line 90-130: The fetchNextPage flow updates currentData
(lastId/isLoadable) based on the response but doesn't guard against the user
changing selectedTab/sosoCategory while the request is in-flight, causing
cross-source clobbering; modify fetchNextPage to capture a request-specific
source key (e.g., selectedTab and sosoCategory or a derived sourceId) before
launching the network call, pass/use that key inside the runCatching/onSuccess
block, and when applying the response only update the matching source via
updateCurrentSource (or skip applying if
currentState.selectedTab/currentState.sosoCategory no longer equals the captured
key) so only the source that initiated the request gets its lastId/isLoadable
changed.
In
`@feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt`:
- Around line 69-80: loadLibrary currently recreates a new
cachedIn(viewModelScope) chain each time (canceling only the collector via
pagingJob), causing accumulated caches; instead create a single cached flow once
in the ViewModel (e.g. val cachedLibraryFlow =
libraryRepository.getLibraryFlow().map(...).cachedIn(viewModelScope)) and have
loadLibrary/collector subscribe to that cachedLibraryFlow, cancelling/
restarting only the collector (pagingJob) as needed; for refresh, do not
recreate cachedIn—either expose a pager.invalidate() /
repository.invalidatePagingSource() call or add a separate refresh trigger (e.g.
MutableSharedFlow<Unit> consumed via flatMapLatest/switchMapLatest upstream of
getLibraryFlow()) so upstream paging source is re-created without creating new
cachedIn chains (adjust references: loadLibrary, pagingJob,
libraryRepository.getLibraryFlow(), cachedIn(viewModelScope), novels.update).
---
Outside diff comments:
In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`:
- Around line 234-241: The toggle logic currently flips isSelected when novel.id
== novelId, which can accidentally deselect the pre-selected novel; in
CreateFeedViewModel change the branch that handles novel.id == novelId to set
isSelected = true (use novel.toUi().copy(isSelected = true)) instead of toggling
with !it.isSelected so the existing selected novel remains selected in edit mode
while other novels continue to use novel.toUi().
- Around line 269-277: 페이지네이션 처리 로직에서 현재 novel.id == novelId 인 항목을 토글하는 대신 항상 선택
상태로 설정해야 합니다; CreateFeedViewModel의 해당 매핑 블록(uses result.novels.map { novel ->
... }, 호출되는 메서드명/변수 novelId, toUi(), isSelected)을 찾아 novel.id == novelId일 때
.toUi().let { it.copy(isSelected = true) }로 변경하고 나머지 항목은 그대로 novel.toUi()를 반환하도록
수정하세요.
- Around line 167-172: The onFailure branch currently only sets
_isUploading.value = false and provides no user feedback; add and emit an error
event (e.g., create or use _updateFeedErrorEvent) inside the onFailure handlers
where _isUploading is set to false so the UI can display a failure message or
retry option; update both occurrences (the block around lines with
onSuccess/onFailure and the similar block at 195-200) to emit the error (or a
mapped user-friendly message) along with stopping the uploading state so the
View can observe _updateFeedErrorEvent and show feedback.
In
`@data/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.kt`:
- Around line 60-80: The current getUserNovels uses runCatching which swallows
CancellationException causing canceled PagingSource loads to become
LoadResult.Error; change the error handling so CancellationException is rethrown
and only non-cancellation exceptions are wrapped as failures: replace or augment
the runCatching around libraryRemoteDataSource.getUserNovels in getUserNovels to
catch Throwable, if it is a CancellationException rethrow it, otherwise return
Result.failure(exception); keep the successful return path unchanged so
LibraryPagingSource.onFailure only sees genuine errors (non-cancellation).
In
`@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt`:
- Around line 66-86: The getUserNovels helper currently wraps
libraryRemoteDataSource.getUserNovels in runCatching which swallows
CancellationException and breaks paging cancellation; replace runCatching with a
cancellable wrapper (e.g., implement runCatchingCancellable as described) and
call that from getUserNovels (referencing getUserNovels and runCatching usage)
so CancellationException is rethrown while other Throwables are converted to
Result.failure; ensure the new helper is suspend inline and catches
CancellationException separately before returning Result.success/failure.
In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt`:
- Around line 234-259: Tabs rendered in the FeedTab.entries loop use Box +
debouncedClickable and only indicate selection via color, and the write Icon has
contentDescription = null; update accessibility by adding semantics/role: for
each tab Box (inside the FeedTab.entries loop where selected is computed from
selectedTab and onTabClick is invoked via debouncedClickable) add
Modifier.selectable(selected = selected, role = Role.Tab) or Modifier.semantics
{ selected = selected } so screen readers get the selection state and role; and
for the write action (where debouncedClickable calls onWriteClick and Icon
currently has contentDescription = null) provide a meaningful contentDescription
like "글 작성" (or use Modifier.semantics { contentDescription = "글 작성" }) so the
write button is announced.
---
Nitpick comments:
In `@app/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.kt`:
- Around line 346-361: The addCompressedImages retry path currently retries
immediately; introduce a suspend delay with exponential backoff before retrying
to avoid tight retry loops: inside the viewModelScope.launch that calls
feedRepository.compressImages, compute a backoff delay (e.g., BASE_DELAY_MS *
2.pow(retryCount) capped by MAX_DELAY_MS), call
kotlinx.coroutines.delay(backoff) before invoking addCompressedImages(newImages,
retryCount + 1) (or refactor the recursion to a loop that delays between
attempts), keeping the existing MAX_RETRY_COUNT check and ensuring the delay
runs inside the coroutine so cancellation works; update constants or add
BASE_DELAY_MS and MAX_DELAY_MS as needed and reference addCompressedImages,
retryCount, MAX_RETRY_COUNT, viewModelScope.launch,
feedRepository.compressImages, and _attachedImages in your change.
In
`@app/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.kt`:
- Around line 253-287: Each report method (updateReportedSpoilerFeed,
updateReportedImpertinenceFeed, updateReportedSpoilerComment,
updateReportedImpertinenceComment) currently calls runCatching and drops the
result; update each to handle success and failure by chaining .onSuccess { ... }
and .onFailure { ... } on the runCatching result: onSuccess emit a UI event or
update a StateFlow/LiveData indicating report succeeded (e.g., post a
toast/snackbar or set a _reportResult/_uiEvent), and onFailure log the throwable
and emit an error UI event so the user sees failure; use the existing
viewModelScope and feedRepository.save* methods and ensure logging includes the
exception.
In
`@feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt`:
- Line 53: 현재 public으로 선언된 `novels: MutableStateFlow<PagingData<NovelUiModel>>`
때문에 외부에서 상태를 덮어쓸 수 있으므로 `_novels`를 private MutableStateFlow로 변경하고 외부에는 읽기 전용
`StateFlow<PagingData<NovelUiModel>>` 타입의 `novels`만 노출하도록 수정하세요; 즉 클래스 내부에
`private val _novels = MutableStateFlow(PagingData.empty<NovelUiModel>())`를 두고
기존 `novels`를 `val novels: StateFlow<PagingData<NovelUiModel>> = _novels`로 바꿔 상태
캡슐화를 강제하세요.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 013e27da-83f9-4b22-ac19-3c4b029cb67a
📒 Files selected for processing (29)
app/src/main/java/com/into/websoso/data/repository/FeedRepository.ktapp/src/main/java/com/into/websoso/data/repository/NovelRepository.ktapp/src/main/java/com/into/websoso/data/repository/UserNovelRepository.ktapp/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.ktapp/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.ktapp/src/main/java/com/into/websoso/ui/createFeed/CreateFeedViewModel.ktapp/src/main/java/com/into/websoso/ui/feedDetail/FeedDetailViewModel.ktapp/src/main/java/com/into/websoso/ui/feedDetail/UpdatedFeedDetailViewModel.ktapp/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.ktapp/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.ktapp/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.ktapp/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.ktdata/feed/src/main/java/com/into/websoso/data/feed/mapper/FeedMapper.ktdata/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.ktdata/library/src/main/java/com/into/websoso/data/library/LibraryRepository.ktdata/library/src/main/java/com/into/websoso/data/library/paging/LibraryPagingSource.ktdata/library/src/main/java/com/into/websoso/data/library/repository/MyLibraryRepository.ktdata/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.ktdomain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.ktfeature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.ktfeature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.ktfeature/feed/src/main/java/com/into/websoso/feature/feed/FeedViewModel.ktfeature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.ktfeature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.ktfeature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.ktfeature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.ktfeature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.ktfeature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetNovelRatingGrid.ktfeature/library/src/main/java/com/into/websoso/feature/library/model/LibraryUiState.kt
💤 Files with no reviewable changes (3)
- app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt
- app/src/main/java/com/into/websoso/ui/accountInfo/AccountInfoViewModel.kt
- app/src/main/java/com/into/websoso/ui/withdraw/second/WithdrawSecondViewModel.kt
🚧 Files skipped from review as they are similar to previous changes (10)
- feature/library/src/main/java/com/into/websoso/feature/library/component/LibraryListItem.kt
- feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt
- feature/library/src/main/java/com/into/websoso/feature/library/filter/component/LibraryFilterBottomSheetNovelRatingGrid.kt
- feature/library/src/main/java/com/into/websoso/feature/library/LibraryScreen.kt
- data/library/src/main/java/com/into/websoso/data/library/paging/LibraryPagingSource.kt
- app/src/main/java/com/into/websoso/ui/activityDetail/ActivityDetailViewModel.kt
- feature/library/src/main/java/com/into/websoso/feature/library/model/LibraryUiState.kt
- feature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.kt
- app/src/main/java/com/into/websoso/data/repository/NovelRepository.kt
- app/src/main/java/com/into/websoso/ui/novelFeed/NovelFeedViewModel.kt
| if (isAlreadyRated) { | ||
| userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData()) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Repository structure =="
find . -type f -name "*.kt" | grep -i "repository\|entity" | head -20
echo
echo "== NovelRatingEntity definition =="
fd 'NovelRatingEntity\.kt$' --type f || rg -l 'class NovelRatingEntity' --type=kotlin || echo "Not found via fd, searching with rg..."
rg 'data class NovelRatingEntity|class NovelRatingEntity' -A 15 --type=kotlin
echo
echo "== UserNovelRepository.kt content (lines 1-50) =="
fd 'UserNovelRepository\.kt$' --type f -exec head -60 {} \;
echo
echo "== saveNovelRating function in UserNovelRepository =="
rg -n -B5 -A15 'fun saveNovelRating' --type=kotlinRepository: Team-WSS/WSS-Android
Length of output: 1460
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Finding NovelRatingEntity =="
find . -name "*NovelRatingEntity*" -type f
echo
echo "== Finding UserNovelRepository =="
find . -name "UserNovelRepository*" -type fRepository: Team-WSS/WSS-Android
Length of output: 275
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== NovelRatingEntity.kt =="
cat -n ./app/src/main/java/com/into/websoso/data/model/NovelRatingEntity.kt
echo
echo "== UserNovelRepository.kt (full file) =="
wc -l ./app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt
echo
echo "== UserNovelRepository.kt content =="
cat -n ./app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.ktRepository: Team-WSS/WSS-Android
Length of output: 2049
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Call sites of saveNovelRating =="
rg -n -B3 -A3 'saveNovelRating' --type=kotlin app
echo
echo "== Check if novelId is always set before saveNovelRating call =="
rg -n -B10 'saveNovelRating' --type=kotlin app | head -80Repository: Team-WSS/WSS-Android
Length of output: 4368
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== NovelRatingViewModel.kt around saveNovelRating (lines 290-330) =="
sed -n '290,330p' ./app/src/main/java/com/into/websoso/ui/novelRating/NovelRatingViewModel.kt
echo
echo "== All saveNovelRating calls with more context =="
rg -n -B15 -A5 'saveNovelRating' --type=kotlin app | grep -A20 'saveNovelRating'Repository: Team-WSS/WSS-Android
Length of output: 5375
24번 줄의 !! 사용은 불필요한 크래시 경로를 남깁니다.
novelId는 Long?로 선언된 nullable 타입이지만, !! 연산자로 강제 unbox되고 있습니다. 현재 호출 지점에서는 novel?.novel?.novelId ?: 0으로 기본값을 제공하지만, 타입 시스템이 isAlreadyRated == true일 때 novelId의 non-null을 강제하지 않으므로, 향후 다른 호출처나 변경 시 crash 가능성이 있습니다.
🔧 제안 수정
if (isAlreadyRated) {
- userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData())
+ val novelId = requireNotNull(novelRatingEntity.novelId) {
+ "novelId is required when updating an existing rating"
+ }
+ userNovelApi.putNovelRating(novelId, novelRatingEntity.toData())
} else {
userNovelApi.postNovelRating(novelRatingEntity.toData())
}📝 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.
| if (isAlreadyRated) { | |
| userNovelApi.putNovelRating(novelRatingEntity.novelId!!, novelRatingEntity.toData()) | |
| if (isAlreadyRated) { | |
| val novelId = requireNotNull(novelRatingEntity.novelId) { | |
| "novelId is required when updating an existing rating" | |
| } | |
| userNovelApi.putNovelRating(novelId, novelRatingEntity.toData()) | |
| } else { | |
| userNovelApi.postNovelRating(novelRatingEntity.toData()) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/main/java/com/into/websoso/data/repository/UserNovelRepository.kt`
around lines 23 - 24, The code uses a forced non-null (!!) on
novelRatingEntity.novelId in the block guarded by isAlreadyRated when calling
userNovelApi.putNovelRating; remove the unsafe !! and instead ensure a safe
non-null value by handling the nullable: e.g., check novelRatingEntity.novelId
with an if/let or use the elvis operator to provide a clear fallback or throw a
descriptive IllegalStateException before calling userNovelApi.putNovelRating
(update the logic in the same method in UserNovelRepository.kt where
isAlreadyRated is evaluated and the call to userNovelApi.putNovelRating occurs).
| private fun navigateToFeedEdit(feedId: Long) { | ||
| val feedContent = | ||
| feedViewModel.uiState.value.myFeedData.feeds.find { it.id == feedId }?.let { feed -> | ||
| EditFeedModel( | ||
| feedId = feed.id, | ||
| novelId = feed.novel?.id, | ||
| novelTitle = feed.novel?.title, | ||
| isSpoiler = feed.isSpoiler, | ||
| isPublic = feed.isPublic, | ||
| feedContent = feed.content, | ||
| feedCategory = feed.relevantCategories, | ||
| imageUrls = feed.imageUrls, | ||
| ) | ||
| } ?: throw IllegalArgumentException() | ||
| updatedFeedViewModel.uiState.value.myFeedData.feeds | ||
| .find { it.id == feedId } | ||
| ?.let { feed -> | ||
| EditFeedModel( | ||
| feedId = feed.id, | ||
| novelId = feed.novel?.id, | ||
| novelTitle = feed.novel?.title, | ||
| isSpoiler = feed.isSpoiler, | ||
| isPublic = feed.isPublic, | ||
| feedContent = feed.content, | ||
| feedCategory = feed.relevantCategoriesByKr, | ||
| imageUrls = feed.imageUrls, | ||
| ) | ||
| } ?: throw IllegalArgumentException() |
There was a problem hiding this comment.
편집 대상을 myFeedData에서만 찾으면 바로 크래시 날 수 있습니다.
onFirstItemClick은 isMyFeed만 보고 편집으로 보내는데, 해당 피드가 현재 탭에만 있고 myFeedData에는 아직 없을 수 있습니다. 그 경우 Line 164의 IllegalArgumentException()으로 앱이 종료됩니다. 현재 리스트에서 우선 찾거나, 못 찾으면 조용히 반환하거나 재조회로 보강하는 쪽이 안전합니다.
🛠️ 제안
private fun navigateToFeedEdit(feedId: Long) {
- val feedContent =
- updatedFeedViewModel.uiState.value.myFeedData.feeds
- .find { it.id == feedId }
- ?.let { feed ->
- EditFeedModel(
- feedId = feed.id,
- novelId = feed.novel?.id,
- novelTitle = feed.novel?.title,
- isSpoiler = feed.isSpoiler,
- isPublic = feed.isPublic,
- feedContent = feed.content,
- feedCategory = feed.relevantCategoriesByKr,
- imageUrls = feed.imageUrls,
- )
- } ?: throw IllegalArgumentException()
+ val state = updatedFeedViewModel.uiState.value
+ val feed =
+ (state.currentData.feeds + state.myFeedData.feeds)
+ .firstOrNull { it.id == feedId } ?: return
+
+ val feedContent =
+ EditFeedModel(
+ feedId = feed.id,
+ novelId = feed.novel?.id,
+ novelTitle = feed.novel?.title,
+ isSpoiler = feed.isSpoiler,
+ isPublic = feed.isPublic,
+ feedContent = feed.content,
+ feedCategory = feed.relevantCategoriesByKr,
+ imageUrls = feed.imageUrls,
+ )
startActivity(CreateFeedActivity.getIntent(requireContext(), feedContent))
}🧰 Tools
🪛 detekt (1.23.8)
[warning] 164-164: A call to the default constructor of an exception was detected. Instead one of the constructor overloads should be called. This allows to provide more meaningful exceptions.
(detekt.exceptions.ThrowingExceptionsWithoutMessageOrCause)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/main/java/com/into/websoso/ui/main/feed/FeedFragment.kt` around lines
149 - 164, navigateToFeedEdit currently looks up the feed only in
updatedFeedViewModel.uiState.value.myFeedData and throws
IllegalArgumentException if not found, causing a crash when an editable item
exists in the visible list but not in myFeedData; change the lookup to first try
the current visible feed collection (e.g., the fragment's displayed feed list or
uiState.value.feedList/currentTabFeeds) and only then fall back to myFeedData,
and if still not found avoid throwing—either return silently or trigger a
refresh/reload (e.g., call the existing refresh method) instead of throwing;
update navigateToFeedEdit to handle a nullable result safely and proceed to
EditFeedModel creation only when the feed is found.
| fun saveEditedFeed( | ||
| feedId: Long, | ||
| relevantCategories: List<String>, | ||
| editedFeed: String, | ||
| novelId: Long?, | ||
| isSpoiler: Boolean, | ||
| isPublic: Boolean, | ||
| images: List<Uri>, | ||
| ) { | ||
| updateFeedInLocalCache(feedId, editedFeed, relevantCategories, isSpoiler, isPublic) | ||
|
|
||
| scope.launch { | ||
| runCatching { | ||
| feedApi.putFeed( | ||
| feedId = feedId, | ||
| feedRequestDto = multiPartMapper.formatToMultipart<FeedRequestDto>( | ||
| target = FeedRequestDto( | ||
| relevantCategories = relevantCategories, | ||
| feedContent = editedFeed, | ||
| novelId = novelId, | ||
| isSpoiler = isSpoiler, | ||
| isPublic = isPublic, | ||
| ), | ||
| partName = PART_NAME_FEED, | ||
| fileName = FILE_NAME_FEED_JSON, | ||
| ), | ||
| images = images.map { multiPartMapper.formatToMultipart(it) }, | ||
| ) | ||
| }.onFailure { | ||
| Log.e("UpdatedFeedRepository", "Failed to sync edited feed", it) | ||
| } | ||
| } |
There was a problem hiding this comment.
편집 실패가 조용히 묻혀 로컬 상태만 남습니다.
여기는 캐시를 먼저 바꾼 뒤 서버 동기화를 백그라운드에서 수행하는데, 실패 시에는 Log.e만 남기고 끝납니다. 그러면 사용자는 수정이 저장된 것처럼 보이지만, 다음 재조회 시 서버 값으로 되돌아갈 수 있고 호출부도 롤백이나 재시도를 할 수 없습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`
around lines 96 - 127, saveEditedFeed currently updates local cache via
updateFeedInLocalCache then launches a background sync to feedApi.putFeed and
swallows failures with Log.e, leaving callers unaware and the UI inconsistent on
server rollback; change saveEditedFeed to surface failures (e.g., return a
Result/Boolean or suspend and throw) or accept a callback so callers can handle
errors, and on failure undo the optimistic local change by calling
updateFeedInLocalCache with the original data (capture pre-edit state before
calling updateFeedInLocalCache) or enqueue a retry mechanism; ensure
feedApi.putFeed failures from the scope.launch/runCatching block are propagated
to the caller or trigger a rollback/retry path and keep the Log.e for
diagnostics.
| fun syncDirtyFeeds() { | ||
| if (dirtyFeedStates.isEmpty()) return | ||
|
|
||
| val syncMap = dirtyFeedStates.toMap() | ||
| dirtyFeedStates.clear() | ||
| originalFeedStates.clear() | ||
|
|
||
| scope.launch { | ||
| syncMap.forEach { (id, isLiked) -> | ||
| runCatching { | ||
| if (isLiked) feedApi.postLikes(id) else feedApi.deleteLikes(id) | ||
| }.onFailure { | ||
| Log.e("UpdatedFeedRepository", "Failed to sync feed $id", it) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
좋아요 동기화 내역을 너무 일찍 비워서 실패 복구가 불가능합니다.
synkMap을 만든 직후 dirtyFeedStates와 originalFeedStates를 clear()해서, 네트워크 실패가 나면 pending 변경이 그대로 유실됩니다. 게다가 snapshot 복사와 clear()가 원자적이지 않아서 이 메서드가 겹쳐 호출되면 같은 항목을 중복 전송할 수도 있습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`
around lines 289 - 304, syncDirtyFeeds currently copies dirtyFeedStates to
syncMap and immediately clears dirtyFeedStates and originalFeedStates, which
loses pending changes on network failure and can cause duplicate sends on
concurrent calls; fix by making the snapshot-and-clear atomic (use a mutex or
synchronized block) and only clear/remove entries after they have been
successfully synced (e.g., iterate over the snapshot and on success remove the
id from dirtyFeedStates and originalFeedStates, or collect failures and put them
back), ensure scope.launch runs outside the lock so network IO doesn't hold the
mutex, and update syncDirtyFeeds, dirtyFeedStates, originalFeedStates, and the
scope.launch usage accordingly to prevent lost or duplicated like-syncs.
| suspend fun saveRemovedFeed(feedId: Long) { | ||
| runCatching { | ||
| feedApi.deleteFeed(feedId) | ||
| }.onSuccess { | ||
| removeFromFlow(_sosoAllFeeds, feedId) | ||
| removeFromFlow(_sosoRecommendedFeeds, feedId) | ||
| removeFromFlow(_myFeeds, feedId) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 피드를 스포일러로 신고합니다. | ||
| */ | ||
| suspend fun saveSpoilerFeed(feedId: Long) { | ||
| runCatching { | ||
| feedApi.postSpoilerFeed(feedId) | ||
| }.onSuccess { | ||
| markAsSpoilerInFlow(_sosoAllFeeds, feedId) | ||
| markAsSpoilerInFlow(_sosoRecommendedFeeds, feedId) | ||
| markAsSpoilerInFlow(_myFeeds, feedId) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 피드를 부적절한 게시물로 신고합니다. | ||
| */ | ||
| suspend fun saveImpertinenceFeed(feedId: Long) { | ||
| runCatching { | ||
| feedApi.postImpertinenceFeed(feedId) | ||
| }.onSuccess { | ||
| removeFromFlow(_sosoAllFeeds, feedId) | ||
| removeFromFlow(_sosoRecommendedFeeds, feedId) | ||
| removeFromFlow(_myFeeds, feedId) | ||
| } |
There was a problem hiding this comment.
삭제/신고 실패를 호출자가 감지할 수 없습니다.
세 메서드 모두 예외를 내부에서 삼키고 그냥 Unit으로 돌아옵니다. 그래서 feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt의 Line 149-163 같은 호출부는 실패 시 에러 상태도 못 세우고 재시도 기회도 잃습니다. 성공 시에만 캐시를 바꾸고, 실패는 그대로 throw 하거나 Result로 반환하는 편이 안전합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/UpdatedFeedRepository.kt`
around lines 314 - 347, The three suspend functions saveRemovedFeed,
saveSpoilerFeed, and saveImpertinenceFeed currently swallow errors by using
runCatching without propagating failures; update them so callers can detect
failures by either rethrowing the caught exception or returning a Result: call
feedApi within runCatching, onSuccess keep the existing cache updates
(removeFromFlow/markAsSpoilerInFlow on
_sosoAllFeeds/_sosoRecommendedFeeds/_myFeeds) and in onFailure either rethrow
the Throwable (so the suspend function throws) or return the failure as
Result.failure(...) (and change the function signature to return Result<Unit>)
so the caller (e.g., UpdatedFeedViewModel) can observe errors and retry.
| getUserNovels(lastUserNovelId, currentFilter).also { result -> | ||
| _novelTotalCount.update { | ||
| result.getOrNull()?.userNovelCount ?: 0 | ||
| } |
There was a problem hiding this comment.
실패 시 총 개수를 0으로 덮어쓰지 마세요.
여기서는 페이지 추가 로드나 일시적 네트워크 실패도 모두 0으로 반영됩니다. 그러면 목록은 남아 있는데 total count만 갑자기 0으로 바뀌게 됩니다. 성공 응답일 때만 _novelTotalCount를 갱신하고, 실패 시에는 이전 값을 유지하는 쪽이 맞습니다.
수정 예시
- _novelTotalCount.update {
- result.getOrNull()?.userNovelCount ?: 0
- }
+ result.getOrNull()?.userNovelCount?.let { totalCount ->
+ _novelTotalCount.value = totalCount
+ }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@data/library/src/main/java/com/into/websoso/data/library/repository/UserLibraryRepository.kt`
around lines 55 - 58, The current code unconditionally updates _novelTotalCount
with result.getOrNull()?.userNovelCount ?: 0 which overwrites the previous total
on any failure; change the update so it only sets _novelTotalCount when
getUserNovels(...) returns a successful value (e.g., check result.isSuccess or
result.getOrNull() != null or use result.onSuccess) and otherwise leave the
existing _novelTotalCount untouched; locate the getUserNovels(...) call and the
_novelTotalCount.update block and wrap the update so it runs only for successful
results (use result.getOrNull()?.userNovelCount to guard the update).
| val convertedFeeds = myFeedsEntity.feeds.map { userFeed -> | ||
| userFeed.toFeedEntity(userProfile = profile, userId = myId) | ||
| } | ||
|
|
||
| feedRepository.updateMyFeedsCache( | ||
| feeds = convertedFeeds, | ||
| isRefreshed = isFeedRefreshed, | ||
| ) | ||
|
|
||
| return Feeds( | ||
| category = "내 활동", | ||
| isLoadable = myFeedsEntity.isLoadable, | ||
| feeds = emptyList(), |
There was a problem hiding this comment.
여기서 빈 feeds를 반환하면 내 피드 페이징이 앞으로 진행되지 않습니다.
feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt의 Line 123-125는 반환된 result.feeds로 다음 lastId를 계산합니다. 지금처럼 emptyList()를 돌려주면 isLoadable = true여도 lastId가 계속 0이라 다음 요청이 매번 첫 페이지 재조회로 고정됩니다.
🛠️ 제안
return Feeds(
category = "내 활동",
isLoadable = myFeedsEntity.isLoadable,
- feeds = emptyList(),
+ feeds = convertedFeeds.map { it.toDomain() },
)📝 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.
| val convertedFeeds = myFeedsEntity.feeds.map { userFeed -> | |
| userFeed.toFeedEntity(userProfile = profile, userId = myId) | |
| } | |
| feedRepository.updateMyFeedsCache( | |
| feeds = convertedFeeds, | |
| isRefreshed = isFeedRefreshed, | |
| ) | |
| return Feeds( | |
| category = "내 활동", | |
| isLoadable = myFeedsEntity.isLoadable, | |
| feeds = emptyList(), | |
| val convertedFeeds = myFeedsEntity.feeds.map { userFeed -> | |
| userFeed.toFeedEntity(userProfile = profile, userId = myId) | |
| } | |
| feedRepository.updateMyFeedsCache( | |
| feeds = convertedFeeds, | |
| isRefreshed = isFeedRefreshed, | |
| ) | |
| return Feeds( | |
| category = "내 활동", | |
| isLoadable = myFeedsEntity.isLoadable, | |
| feeds = convertedFeeds.map { it.toDomain() }, |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@domain/feed/src/main/java/com/into/websoso/feed/UpdatedGetMyFeedsUseCase.kt`
around lines 48 - 60, The returned Feeds object currently uses feeds =
emptyList() which breaks pagination; change the return to include the converted
feeds (use convertedFeeds from myFeedsEntity.feeds.map) so
UpdatedGetMyFeedsUseCase returns the actual list used by the consumer to compute
lastId; ensure you still call feedRepository.updateMyFeedsCache(feeds =
convertedFeeds, ...) and return Feeds(category = "내 활동", isLoadable =
myFeedsEntity.isLoadable, feeds = convertedFeeds) so the viewmodel can compute
next lastId correctly.
| onFirstItemClick: (feedId: Long, isMyFeed: Boolean) -> Unit, | ||
| onSecondItemClick: (feedId: Long, isMyFeed: Boolean) -> Unit, | ||
| onRefreshPull: () -> Unit, | ||
| onWriteFeedClick: () -> Unit, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, examine the FeedScreen.kt file to understand the callback structure
fd -e "FeedScreen.kt" --type fRepository: Team-WSS/WSS-Android
Length of output: 46
🏁 Script executed:
# Get the structure and size of the file
wc -l feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.ktRepository: Team-WSS/WSS-Android
Length of output: 138
🏁 Script executed:
# Read FeedScreen.kt focusing on the relevant sections
sed -n '60,85p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.ktRepository: Team-WSS/WSS-Android
Length of output: 1126
🏁 Script executed:
# Check lines 170-185 for the other referenced area
sed -n '170,185p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.ktRepository: Team-WSS/WSS-Android
Length of output: 681
🏁 Script executed:
# Check FeedRoute.kt for callback wiring
fd -e "FeedRoute.kt" --type fRepository: Team-WSS/WSS-Android
Length of output: 46
🏁 Script executed:
# Check UpdateFeedRoute.kt for callback wiring
fd -e "UpdateFeedRoute.kt" --type fRepository: Team-WSS/WSS-Android
Length of output: 46
🏁 Script executed:
# Search for route files more broadly
find . -name "*FeedRoute.kt" -type f 2>/dev/null | head -20Repository: Team-WSS/WSS-Android
Length of output: 213
🏁 Script executed:
# Read the full context around the parameter definition and usage
sed -n '65,82p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.ktRepository: Team-WSS/WSS-Android
Length of output: 724
🏁 Script executed:
# Get more context around line 177
sed -n '172,190p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.ktRepository: Team-WSS/WSS-Android
Length of output: 788
🏁 Script executed:
# Search for FeedRoute and UpdateFeedRoute in all files
rg "FeedRoute|UpdateFeedRoute" --type kt -lRepository: Team-WSS/WSS-Android
Length of output: 90
🏁 Script executed:
# Search for routes without type filter
rg "FeedRoute|UpdateFeedRoute" --files-with-matchesRepository: Team-WSS/WSS-Android
Length of output: 273
🏁 Script executed:
# Read FeedRoute.kt to check callback wiring
cat -n ./feature/feed/src/main/java/com/into/websoso/feature/feed/FeedRoute.kt | head -100Repository: Team-WSS/WSS-Android
Length of output: 3565
🏁 Script executed:
# Read UpdateFeedRoute.kt to check callback wiring
cat -n ./feature/feed/src/main/java/com/into/websoso/feature/feed/UpdateFeedRoute.kt | head -100Repository: Team-WSS/WSS-Android
Length of output: 3593
🏁 Script executed:
# Search for the FeedScreen calls to see what callbacks are passed
rg "FeedScreen\(" --context 5 ./feature/feed/src/main/java/com/into/websoso/feature/feed/Repository: Team-WSS/WSS-Android
Length of output: 4797
🏁 Script executed:
# Search for FeedTabRow to check for Tab replacement and icon accessibility
fd -name "FeedTabRow*" -type fRepository: Team-WSS/WSS-Android
Length of output: 294
🏁 Script executed:
# If not found, search for FeedTabRow in all kotlin files
rg "fun FeedTabRow" --context 10Repository: Team-WSS/WSS-Android
Length of output: 2096
🏁 Script executed:
# Also search for the write icon implementation
rg "Icon.*write\|write.*Icon" --context 5 ./feature/feed/src/main/java/com/into/websoso/feature/feed/Repository: Team-WSS/WSS-Android
Length of output: 46
🏁 Script executed:
# Get the full FeedTabRow implementation
sed -n '200,270p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.ktRepository: Team-WSS/WSS-Android
Length of output: 2304
🏁 Script executed:
# Search for Icon and contentDescription in FeedScreen
rg "Icon\(" feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt -A 2 -B 2Repository: Team-WSS/WSS-Android
Length of output: 887
🏁 Script executed:
# Get more lines from FeedTabRow - search for where it ends
sed -n '195,292p' feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.ktRepository: Team-WSS/WSS-Android
Length of output: 3081
쓰기 콜백을 분리한 뒤 route별 배선이 이미 어긋나 있습니다.
FeedSection은 onWriteFeedClick을 사용하지만, FeedRoute는 이를 빈 람다로 전달하고 (onWriteFeedClick = { }) UpdateFeedRoute만 실제 핸들러를 전달합니다 (onWriteFeedClick = onWriteFeedClick). FeedRoute는 @Deprecated 표시되었음에도 여전히 존재하므로, 이 경로가 라우팅되면 같은 화면의 쓰기 진입점 중 하나가 no-op이 됩니다.
추가로, FeedTabRow의 연필 아이콘이 contentDescription = null로 설정되어 있어 스크린 리더 접근성이 누락되었습니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@feature/feed/src/main/java/com/into/websoso/feature/feed/FeedScreen.kt` at
line 69, FeedSection's write callback is being dropped because FeedRoute passes
onWriteFeedClick = { } while only UpdateFeedRoute forwards the real
onWriteFeedClick; update FeedRoute (or remove the deprecated route) so it
forwards the provided onWriteFeedClick into FeedSection/UpdateFeedRoute instead
of a no-op, ensuring the same handler is invoked for the write entry point
(reference FeedRoute, UpdateFeedRoute, FeedSection, onWriteFeedClick). Also fix
accessibility in FeedTabRow by giving the pencil icon a meaningful
contentDescription (use an appropriate string resource or passed label rather
than null) so screen readers announce the action (reference FeedTabRow and
contentDescription).
| fun fetchNextPage(feedId: Long? = null) { | ||
| val state = uiState.value | ||
| val current = state.currentData | ||
| val lastFeedId = feedId ?: current.lastId | ||
|
|
||
| if (state.loading || (!current.isLoadable && lastFeedId != 0L)) return | ||
|
|
||
| _uiState.update { it.copy(loading = true) } | ||
|
|
||
| viewModelScope.launch { | ||
| runCatching { | ||
| when (state.selectedTab) { | ||
| FeedTab.MY_FEED -> { | ||
| getMyFeedsUseCase( | ||
| lastFeedId = lastFeedId, | ||
| genres = state.currentFilter.selectedGenres.map { it.tag }, | ||
| isVisible = state.currentFilter.isVisible, | ||
| sortCriteria = state.myFeedData.sort.name | ||
| .uppercase(), | ||
| isUnVisible = state.currentFilter.isUnVisible, | ||
| ) | ||
| } | ||
|
|
||
| FeedTab.SOSO_FEED -> { | ||
| getFeedsUseCase( | ||
| feedsOption = state.sosoCategory.name.uppercase(), | ||
| lastFeedId = lastFeedId, | ||
| ) | ||
| } | ||
| } | ||
| }.onSuccess { result -> | ||
| _uiState.update { currentState -> | ||
| val updatedSource = currentState.currentData.copy( | ||
| lastId = result.isLoadable.let { | ||
| if (it) result.feeds.lastOrNull()?.id ?: 0 else 0 | ||
| }, | ||
| isLoadable = result.isLoadable, | ||
| ) | ||
| currentState | ||
| .updateCurrentSource(updatedSource) | ||
| .copy(loading = false, isRefreshing = false) |
There was a problem hiding this comment.
응답이 돌아올 때 현재 탭이 바뀌어 있으면 다른 소스의 페이징 상태를 덮어씁니다.
요청은 state.selectedTab/state.sosoCategory 기준으로 시작하는데, 성공 처리에서는 currentState.currentData에 lastId와 isLoadable을 씁니다. 사용자가 로딩 중 탭이나 카테고리를 바꾸면 이전 요청의 응답으로 현재 소스의 페이징 상태가 덮여서 상태가 섞일 수 있으니, 요청 시점의 source key를 캡처해서 그 소스만 갱신해야 합니다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/UpdatedFeedViewModel.kt`
around lines 90 - 130, The fetchNextPage flow updates currentData
(lastId/isLoadable) based on the response but doesn't guard against the user
changing selectedTab/sosoCategory while the request is in-flight, causing
cross-source clobbering; modify fetchNextPage to capture a request-specific
source key (e.g., selectedTab and sosoCategory or a derived sourceId) before
launching the network call, pass/use that key inside the runCatching/onSuccess
block, and when applying the response only update the matching source via
updateCurrentSource (or skip applying if
currentState.selectedTab/currentState.sosoCategory no longer equals the captured
key) so only the source that initiated the request gets its lastId/isLoadable
changed.
| private fun loadLibrary() { | ||
| pagingJob?.cancel() | ||
|
|
||
| pagingJob = viewModelScope.launch { | ||
| libraryRepository | ||
| .getLibraryFlow() | ||
| .map { pagingData -> pagingData.map(NovelEntity::toUiModel) } | ||
| .cachedIn(viewModelScope) | ||
| .collect { result -> | ||
| novels.update { result } | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "LibraryViewModel.kt" | head -20Repository: Team-WSS/WSS-Android
Length of output: 148
🏁 Script executed:
find . -type f -name "LibraryFragment.kt" | head -20Repository: Team-WSS/WSS-Android
Length of output: 135
🏁 Script executed:
cat -n ./feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.ktRepository: Team-WSS/WSS-Android
Length of output: 7783
🏁 Script executed:
cat -n ./app/src/main/java/com/into/websoso/ui/main/library/LibraryFragment.kt | head -100Repository: Team-WSS/WSS-Android
Length of output: 2978
cachedIn(viewModelScope)을 refresh() 경로에서 반복 생성하면 캐시가 누적됩니다.
onResume()마다 refresh()를 호출하고 있는데, 현재 구조는 그때마다 새 cachedIn(viewModelScope) 체인을 만듭니다. pagingJob?.cancel()은 수집 작업만 취소할 뿐, cachedIn() 연산자 자체와 그에 묶인 이전의 paging 소스 구독은 정리되지 않습니다. 뷰모델 스코프 내에 여러 캐시된 플로우가 쌓이면서 메모리 누수와 중복 데이터 소스 구독이 발생할 수 있습니다. cachedIn() 캐시는 한 번만 만들고, 새로고침은 별도의 trigger(예: pager 무효화, 별도 명령어 채널)로 upstream을 재생성하는 구조로 변경하세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@feature/library/src/main/java/com/into/websoso/feature/library/LibraryViewModel.kt`
around lines 69 - 80, loadLibrary currently recreates a new
cachedIn(viewModelScope) chain each time (canceling only the collector via
pagingJob), causing accumulated caches; instead create a single cached flow once
in the ViewModel (e.g. val cachedLibraryFlow =
libraryRepository.getLibraryFlow().map(...).cachedIn(viewModelScope)) and have
loadLibrary/collector subscribe to that cachedLibraryFlow, cancelling/
restarting only the collector (pagingJob) as needed; for refresh, do not
recreate cachedIn—either expose a pager.invalidate() /
repository.invalidatePagingSource() call or add a separate refresh trigger (e.g.
MutableSharedFlow<Unit> consumed via flatMapLatest/switchMapLatest upstream of
getLibraryFlow()) so upstream paging source is re-created without creating new
cachedIn chains (adjust references: loadLibrary, pagingJob,
libraryRepository.getLibraryFlow(), cachedIn(viewModelScope), novels.update).
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt`:
- Around line 44-50: The current saveRemovedFeed function swallows all API
errors by using runCatching and never propagating failures; change it so
feedApi.deleteFeed(feedId) is invoked directly (or rethrow the caught exception
in runCatching.onFailure) and only call _cachedFeeds.removeIf { it.id == feedId
} after a successful delete; reference saveRemovedFeed,
feedApi.deleteFeed(feedId) and _cachedFeeds.removeIf to locate and update the
logic so exceptions bubble to callers and the cache is updated only on success.
In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`:
- Around line 54-56: The current logic only filters feeds against existingIds so
duplicate ids inside the incoming page (result.feeds) will still be added;
update the flow in FeedRepository so you first deduplicate result.feeds by id
(e.g., build a local Map/Set from result.feeds or iterate and keep only the
first occurrence), then compute newUniqueFeeds by filtering those deduplicated
feeds against existingIds and add them to targetCache, ensuring you also update
the id set (existingIds or a mutable Set) as you accept items so duplicates
within the same page are prevented.
- Around line 66-74: The runCatching block in saveRemovedFeed currently swallows
errors (calls feedApi.deleteFeed and only logs onFailure), so change it to
propagate failures: either rethrow the caught exception after logging inside
onFailure or change saveRemovedFeed to return a Result/throws and propagate the
failure to callers; locate the runCatching around feedApi.deleteFeed (and the
cachedFeeds/cachedRecommendedFeeds removals) and implement one of these two
approaches so callers can handle rollback or show errors.
- Around line 19-20: FeedRepository's singleton cachedFeeds and
cachedRecommendedFeeds are mutated concurrently in fetchFeeds() and
saveRemovedFeed(), causing race conditions; protect all cache reads/writes by
introducing a dedicated lock (e.g., private val cacheLock = Any()) and wrap
every mutation and compound read-modify operation on cachedFeeds and
cachedRecommendedFeeds in a synchronized(cacheLock) block (apply this to
fetchFeeds(), saveRemovedFeed() and any other methods touching those lists) so
clear/addAll/removeIf operate as an atomic critical section; alternatively swap
to a thread-safe collection and still synchronize compound updates to preserve
ordering and atomicity.
In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt`:
- Line 58: The container is set to .width(width = 180.dp) but the internal Text
items use .width(width = 192.dp), causing potential overflow; update the Text
modifiers in FeedMoreMenu (the Text composables that currently use
.width(192.dp)) to match the container width (use .width(180.dp)) or switch both
container and Text to use match-parent behavior (e.g., fillMaxWidth()) so the
widths are consistent and no clipping occurs.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 8c901f3e-0dbe-4cdc-a073-9460a42870b4
📒 Files selected for processing (5)
app/src/main/java/com/into/websoso/data/repository/FeedRepository.ktdata/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.ktdomain/feed/src/main/java/com/into/websoso/feed/UpdatedGetFeedsUseCase.ktfeature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.ktfeature/library/src/main/java/com/into/websoso/feature/library/component/LibraryGridListItem.kt
| suspend fun saveRemovedFeed(feedId: Long) { | ||
| runCatching { | ||
| feedApi.deleteFeed(feedId) | ||
| }.onSuccess { | ||
| _cachedFeeds.removeIf { it.id == feedId } | ||
|
|
||
| val novel = novelId?.let { id -> | ||
| libraryLocalDataSource.selectNovelByNovelId(id) | ||
| } | ||
|
|
||
| if (novel != null) { | ||
| val updatedNovel = novel.copy(myFeeds = novel.myFeeds.filterNot { it == content }) | ||
| libraryLocalDataSource.insertNovel(updatedNovel) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
삭제 실패를 삼키지 마세요.
지금 구현은 4xx/5xx/네트워크 실패를 모두 무시해서 호출부가 삭제 실패를 감지할 수 없습니다. API 예외를 그대로 전파하고, 캐시는 성공 후에만 갱신하는 편이 안전합니다.
수정 예시
suspend fun saveRemovedFeed(feedId: Long) {
- runCatching {
- feedApi.deleteFeed(feedId)
- }.onSuccess {
- _cachedFeeds.removeIf { it.id == feedId }
- }
+ feedApi.deleteFeed(feedId)
+ _cachedFeeds.removeIf { it.id == feedId }
}📝 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.
| suspend fun saveRemovedFeed(feedId: Long) { | |
| runCatching { | |
| feedApi.deleteFeed(feedId) | |
| }.onSuccess { | |
| _cachedFeeds.removeIf { it.id == feedId } | |
| val novel = novelId?.let { id -> | |
| libraryLocalDataSource.selectNovelByNovelId(id) | |
| } | |
| if (novel != null) { | |
| val updatedNovel = novel.copy(myFeeds = novel.myFeeds.filterNot { it == content }) | |
| libraryLocalDataSource.insertNovel(updatedNovel) | |
| } | |
| } | |
| } | |
| suspend fun saveRemovedFeed(feedId: Long) { | |
| feedApi.deleteFeed(feedId) | |
| _cachedFeeds.removeIf { it.id == feedId } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/main/java/com/into/websoso/data/repository/FeedRepository.kt` around
lines 44 - 50, The current saveRemovedFeed function swallows all API errors by
using runCatching and never propagating failures; change it so
feedApi.deleteFeed(feedId) is invoked directly (or rethrow the caught exception
in runCatching.onFailure) and only call _cachedFeeds.removeIf { it.id == feedId
} after a successful delete; reference saveRemovedFeed,
feedApi.deleteFeed(feedId) and _cachedFeeds.removeIf to locate and update the
logic so exceptions bubble to callers and the cache is updated only on success.
| private val cachedFeeds = mutableListOf<FeedEntity>() | ||
| private val cachedRecommendedFeeds = mutableListOf<FeedEntity>() |
There was a problem hiding this comment.
싱글톤 캐시가 동시 접근에 안전하지 않습니다.
FeedRepository가 @Singleton인데 fetchFeeds()와 saveRemovedFeed()가 같은 MutableList를 락 없이 수정합니다. 새로고침/페이징/삭제가 겹치면 clear/addAll/removeIf가 서로 엇갈려 캐시가 유실되거나 순서가 꼬일 수 있으니, 캐시 갱신 구간을 하나의 임계영역으로 묶는 편이 안전합니다.
Also applies to: 48-56, 69-71
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`
around lines 19 - 20, FeedRepository's singleton cachedFeeds and
cachedRecommendedFeeds are mutated concurrently in fetchFeeds() and
saveRemovedFeed(), causing race conditions; protect all cache reads/writes by
introducing a dedicated lock (e.g., private val cacheLock = Any()) and wrap
every mutation and compound read-modify operation on cachedFeeds and
cachedRecommendedFeeds in a synchronized(cacheLock) block (apply this to
fetchFeeds(), saveRemovedFeed() and any other methods touching those lists) so
clear/addAll/removeIf operate as an atomic critical section; alternatively swap
to a thread-safe collection and still synchronize compound updates to preserve
ordering and atomicity.
| val existingIds = targetCache.map { it.id }.toSet() | ||
| val newUniqueFeeds = result.feeds.filterNot { it.id in existingIds } | ||
| targetCache.addAll(newUniqueFeeds) |
There was a problem hiding this comment.
한 페이지 안의 중복 feed는 그대로 들어옵니다.
여기서는 기존 캐시에 있던 ID만 걸러서, 서버가 같은 응답 안에 동일한 id를 두 번 내려주면 둘 다 targetCache에 추가됩니다. 응답 자체도 ID 기준으로 한 번 더 정규화하거나, 필터링하면서 Set을 함께 갱신해 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`
around lines 54 - 56, The current logic only filters feeds against existingIds
so duplicate ids inside the incoming page (result.feeds) will still be added;
update the flow in FeedRepository so you first deduplicate result.feeds by id
(e.g., build a local Map/Set from result.feeds or iterate and keep only the
first occurrence), then compute newUniqueFeeds by filtering those deduplicated
feeds against existingIds and add them to targetCache, ensuring you also update
the id set (existingIds or a mutable Set) as you accept items so duplicates
within the same page are prevented.
| runCatching { | ||
| feedApi.deleteFeed(feedId) | ||
| }.onSuccess { | ||
| // 모든 캐시에서 해당 피드 제거 (안전하게 두 곳 모두 확인 가능) | ||
| cachedFeeds.removeIf { it.id == feedId } | ||
| cachedRecommendedFeeds.removeIf { it.id == feedId } | ||
| }.onFailure { | ||
| Log.d("FeedRepository", "saveRemovedFeed 함수 failed : ${it.message}") | ||
| } |
There was a problem hiding this comment.
삭제 실패를 삼키지 않는 편이 안전합니다.
지금은 실패 시 로그만 남기고 정상 복귀해서, 호출부가 에러 표시나 optimistic UI 롤백을 할 기회를 잃습니다. 로그 후 예외를 다시 던지거나 Result를 반환해서 실패를 상위 레이어로 전달해 주세요.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@data/feed/src/main/java/com/into/websoso/data/feed/repository/FeedRepository.kt`
around lines 66 - 74, The runCatching block in saveRemovedFeed currently
swallows errors (calls feedApi.deleteFeed and only logs onFailure), so change it
to propagate failures: either rethrow the caught exception after logging inside
onFailure or change saveRemovedFeed to return a Result/throws and propagate the
failure to callers; locate the runCatching around feedApi.deleteFeed (and the
cachedFeeds/cachedRecommendedFeeds removals) and implement one of these two
approaches so callers can handle rollback or show errors.
| shape = RoundedCornerShape(size = 12.dp), | ||
| ) | ||
| .width(IntrinsicSize.Max), | ||
| ).width(width = 180.dp), |
There was a problem hiding this comment.
컨테이너와 텍스트 아이템 간의 너비 불일치
컨테이너의 너비가 180.dp로 설정되어 있지만 (Line 58), 내부 Text 아이템들은 192.dp로 설정되어 있습니다 (Line 67). 텍스트가 컨테이너보다 12dp 더 넓어 레이아웃 오버플로우 또는 클리핑 문제가 발생할 수 있습니다.
🔧 너비 일치를 위한 수정 제안
Text(
text = label,
textAlign = TextAlign.Center,
style = WebsosoTheme.typography.body2,
color = if (isMyFeed) Black else Secondary100,
modifier = Modifier
- .width(width = 192.dp)
+ .width(width = 180.dp)
.debouncedClickable {또는 fillMaxWidth()를 사용하여 부모 너비에 맞추는 방법도 고려해 보세요.
Also applies to: 67-67
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@feature/feed/src/main/java/com/into/websoso/feature/feed/component/FeedMoreMenu.kt`
at line 58, The container is set to .width(width = 180.dp) but the internal Text
items use .width(width = 192.dp), causing potential overflow; update the Text
modifiers in FeedMoreMenu (the Text composables that currently use
.width(192.dp)) to match the container width (use .width(180.dp)) or switch both
container and Text to use match-parent behavior (e.g., fillMaxWidth()) so the
widths are consistent and no clipping occurs.
📌𝘐𝘴𝘴𝘶𝘦𝘴
릴리즈 1.6.0 업데이트
📎𝘞𝘰𝘳𝘬 𝘋𝘦𝘴𝘤𝘳𝘪𝘱𝘵𝘪𝘰𝘯
📷𝘚𝘤𝘳𝘦𝘦𝘯𝘴𝘩𝘰𝘵
💬𝘛𝘰 𝘙𝘦𝘷𝘪𝘦𝘸𝘦𝘳𝘴
Summary by CodeRabbit
New Features
Refactor
UI