fix(content): 콘텐츠 상세 펼침 말줄임을 복원한다

This commit is contained in:
2026-08-03 16:33:35 +09:00
parent b5cfdc83e3
commit 26eae18edc
5 changed files with 383 additions and 0 deletions

View File

@@ -1,3 +1,5 @@
@file:Suppress("ktlint:package-name", "ktlint:standard:package-name")
package kr.co.vividnext.sodalive.audio_content.detail
import android.annotation.SuppressLint
@@ -10,6 +12,7 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager
@@ -463,6 +466,11 @@ class AudioContentDetailActivity : BaseActivity<ActivityAudioContentDetailBindin
}
viewModel.isExpandDetail.observe(this) {
binding.tvDetail.ellipsize = if (it) {
null
} else {
TextUtils.TruncateAt.END
}
binding.tvDetail.maxLines = if (it) {
Int.MAX_VALUE
} else {

View File

@@ -0,0 +1,51 @@
@file:Suppress("ktlint:package-name", "ktlint:standard:package-name")
package kr.co.vividnext.sodalive.audio_content.detail
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class AudioContentDetailActivitySourceTest {
@Test
fun `긴 설명을 펼치면 말줄임을 제거한다`() {
val observer = expandDetailObserver()
assertTrue(observer.contains("binding.tvDetail.ellipsize = if (it)"))
assertTrue(observer.contains("null"))
assertTrue(observer.contains("Int.MAX_VALUE"))
}
@Test
fun `긴 설명을 접으면 두 줄 말줄임을 복원한다`() {
val observer = expandDetailObserver()
assertTrue(observer.contains("TextUtils.TruncateAt.END"))
assertTrue(observer.contains("2"))
}
@Test
fun `긴 설명의 반복 토글 상태를 한 observer에서 처리한다`() {
val observer = expandDetailObserver()
assertTrue(observer.contains("binding.tvDetail.ellipsize = if (it)"))
assertTrue(observer.contains("binding.tvDetail.maxLines = if (it)"))
}
private fun expandDetailObserver(): String {
val source = projectFile(
"app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt"
).readText()
return source
.substringAfter("viewModel.isExpandDetail.observe(this) {")
.substringBefore("viewModel.isShowPreviewAlert.observe(this) {")
}
private fun projectFile(relativePath: String): File {
val candidates = listOf(File(relativePath), File("../$relativePath"))
return candidates.firstOrNull { it.exists() }
?: error("Project file not found: $relativePath")
}
}

View File

@@ -0,0 +1,167 @@
# 콘텐츠 상세 긴 내용 펼침 성능 개선 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 콘텐츠 상세의 긴 작품 설명을 펼칠 때 불필요한 말줄임 처리를 제거하고 기존 접힘 동작을 유지한다.
**Architecture:** 기존 `AudioContentDetailActivity``isExpandDetail` observer 안에서 `TextView.maxLines``TextView.ellipsize`를 함께 갱신한다. ViewModel, XML, API와 화면 구조는 변경하지 않는다.
**Tech Stack:** Kotlin, Android TextView, LiveData, JUnit 4, Gradle
---
| 문서 항목 | 내용 |
|---|---|
| 상태 | 구현 완료 |
| 작성일 | 2026-08-03 |
| 요구사항 기준 | `docs/20260803_콘텐츠_상세_긴_내용_펼침_성능_개선/prd.md` |
| API 기준 | 해당 없음: API 변경 없음 |
| 현재 Phase | Phase 1 긴 내용 펼침 처리 |
| 현재 활성 Goal | 없음 |
## 범위
### 포함
- `AudioContentDetailActivity`의 설명 접기/펼치기 상태 처리
- 접힘, 펼침, 반복 토글 회귀 source test
- focused test, debug build, 연결 기기 수동 검증
### 제외
- XML 및 스크롤/추천 목록 구조 변경
- 신규 helper, dependency, benchmark framework
- 관련 없는 레거시 코드 정리
## 기술적 제약
- 사용자 승인에 따라 해당 레거시 Activity의 observer만 최소 수정한다.
- 기존 `HomeBusinessInfoBinder``CharacterDetailFragment``ellipsize = null`/`END` 패턴을 따른다.
- 테스트를 먼저 추가하고 RED 확인 후 production 코드를 수정한다.
- 커밋은 사용자가 명시적으로 요청하지 않았으므로 수행하지 않는다.
## 시나리오 계약
| 시나리오 | 이진 통과 조건 | 자동 검증 | 실제 표면 |
|---|---|---|---|
| 펼침 | observer가 `Int.MAX_VALUE``ellipsize = null`을 함께 설정한다. | `AudioContentDetailActivitySourceTest.긴 설명을 펼치면 말줄임을 제거한다` | 연결 기기 콘텐츠 상세 화면 터치 |
| 접힘 | observer가 `2``TextUtils.TruncateAt.END`를 함께 설정한다. | `AudioContentDetailActivitySourceTest.긴 설명을 접으면 두 줄 말줄임을 복원한다` | 연결 기기 콘텐츠 상세 화면 재터치 |
| 반복 토글 회귀 | 하나의 observer 분기에 펼침/접힘 값이 모두 존재한다. | `AudioContentDetailActivitySourceTest.긴 설명의 반복 토글 상태를 한 observer에서 처리한다` | 연결 기기에서 펼침 후 접힘 |
중단 조건: 세 시나리오, focused test, debug build, 진단과 리뷰가 통과하고 가능한 실제 기기 검증 및 정리 기록이 남으면 즉시 중단한다.
## Phase 1
**Phase 결과:** 긴 작품 설명이 펼침 상태에서 말줄임 없이 표시되고 접힘 상태가 보존된다.
**선행조건:** `DETAIL-001~003`, `DEC-001` 확정.
**Phase 완료 조건:** `P1-T1``P1-GATE` 완료 및 검증 기록 누적.
### 구현 항목
#### Task 1.1 설명 접기/펼치기 상태 최적화
**Goal 실행 `P1-T1`:** 펼침 상태의 불필요한 ellipsize를 제거하고 접힘 상태에서 복원한다.
- **시작 조건:** PRD와 이 계획 문서 작성 완료.
- **완료 증거:** source test RED/GREEN, debug build, 변경 파일 진단.
- **범위 밖:** 레이아웃 구조, ViewModel, API 수정.
**Files:**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivitySourceTest.kt`
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
**Interfaces:**
- Consumes: `AudioContentDetailViewModel.isExpandDetail: LiveData<Boolean>`
- Produces: 펼침 시 `ellipsize == null`, 접힘 시 `ellipsize == TextUtils.TruncateAt.END`
- [x] **RED:** 세 시나리오를 검증하는 source test를 작성한다.
- [x] **RED 확인:** `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivitySourceTest"` 실행 시 ellipsize 상태 미구현 assertion 실패를 확인한다.
- [x] **GREEN:** `isExpandDetail` observer에서 `ellipsize`를 펼침/접힘 상태에 맞게 설정한다.
- [x] **GREEN 확인:** 같은 focused test가 모두 통과하는지 확인한다.
- [x] **REFACTOR:** 추가 abstraction 없이 import와 변경 블록만 정리하고 focused test 및 build를 다시 확인한다.
계획된 최소 구현:
```kotlin
binding.tvDetail.ellipsize = if (it) {
null
} else {
TextUtils.TruncateAt.END
}
```
### 완료 조건
- [x] `DETAIL-001~003`이 source test와 구현으로 추적된다.
- [x] 요청 밖 파일과 동작을 변경하지 않았다.
### 검증 방법
#### Phase 1 Gate
**Goal 실행 `P1-GATE`:** 접기/펼치기 동작과 회귀 여부를 최종 판정한다.
```bash
./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivitySourceTest"
./gradlew :app:assembleDebug
adb devices
```
**Expected:** focused test 3건 통과, debug build exit code 0, 연결 기기에서 접힘/펼침 상태 확인 또는 접근 불가 사유 기록.
- [x] focused test 3건이 통과했다.
- [x] ktlint main/test와 debug build가 성공했다.
- [x] 연결 기기에서 접힘, 펼침, 재접힘 상태를 확인했다.
- [x] 독립 코드 및 화면 리뷰가 PASS로 종료됐다.
## 실행 순서와 의존성
| 순서 | Goal | 선행조건 | 병행 가능 |
|---:|---|---|---|
| 1 | `P1-T1` RED | 문서 작성 완료 | 아니요 |
| 2 | `P1-T1` GREEN | RED 확인 | 아니요 |
| 3 | `P1-GATE` | GREEN 확인 | 아니요 |
## 변경 금지 항목
- 테스트 삭제, skip, assertion 완화
- 신규 dependency 또는 공통 abstraction 추가
- 레이아웃, ViewModel, API와 관련 없는 코드 수정
- 사용자 요청 없는 커밋 및 Git 이력 변경
## Progress
### `P1-T1` 1차 실행 — 2026-08-03
- 상태: 구현 및 Gate 검증 완료, 리뷰 진행 중
- 무엇을: PRD와 구현 계획을 작성하고 변경 범위를 고정했다.
- 왜: 코드 변경 전 문서 준비와 최소 변경 범위를 확정하기 위해서다.
- 어떻게:
- `codegraph``rg` 조사 — 기존 동일 패턴과 source test 관례 확인.
- `adb devices` — 기기 `2cec640c34017ece` 연결 확인.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.detail.AudioContentDetailActivitySourceTest"` — 의도한 assertion 실패 3건으로 RED 확인.
- 같은 focused test 재실행 — 3건 통과, `BUILD SUCCESSFUL`.
- `./gradlew :app:ktlintMainSourceSetCheck :app:ktlintTestSourceSetCheck :app:assembleDebug``BUILD SUCCESSFUL`.
- 연결 기기 debug APK 설치 및 콘텐츠 상세 토글 — 접힘 2줄 말줄임, 펼침 전체 표시, 재접힘 복원 확인.
- `adb shell dumpsys gfxinfo kr.co.vividnext.sodalive.debug` — 펼침 렌더링 프레임 150ms, 접힘 렌더링 프레임 117ms.
- 남은 항목: 독립 리뷰와 QA 임시 파일 정리.
- 다음 행동: 변경 diff와 최신 화면 캡처를 독립 검토한다.
## Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|---|---|---|---|---|---|
| 2026-08-03 | `DEC-001` | 확정 | observer에 ellipsize 상태 복원만 추가한다. | 사용자 최소 변경 요청 | `P1-T1`, PRD `DETAIL-001~003` |
## Verification Log
- 2026-08-03: 구현 전 `git status --short` 출력 없음으로 clean worktree를 확인했다.
- 2026-08-03: Kotlin LSP는 사용자가 이전에 설치를 거부해 실행할 수 없었으며, Kotlin compiler, focused test와 ktlint로 대체 검증했다.
- 2026-08-03: 실기기에서 접힘/펼침/재접힘 화면과 UI hierarchy를 캡처해 세 시나리오를 확인했다.
- 2026-08-03: 독립 코드 리뷰와 CJK 표시 리뷰가 PASS였다. 기능 QA의 초기 프레임 예산 후보는 명시된 수용 기준에 없는 조건으로 재판정되어 철회됐고 최종 PASS를 받았다.
- 2026-08-03: `./gradlew :app:testDebugUnitTest :app:ktlintMainSourceSetCheck :app:ktlintTestSourceSetCheck :app:assembleDebug` 최종 실행이 `BUILD SUCCESSFUL`로 완료됐다.
- 2026-08-03: 기기와 로컬의 QA 임시 XML, screenshot 및 notepad 파일을 삭제했다.

View File

@@ -0,0 +1,73 @@
# 콘텐츠 상세 긴 내용 펼침 성능 개선 PRD
## 문서 정보
| 항목 | 내용 |
|---|---|
| 문서 상태 | 구현 완료 |
| 작성일 | 2026-08-03 |
| 최종 수정일 | 2026-08-03 |
| 대상 제품 | SodaLive Android 콘텐츠 상세 |
| 관련 API Contract | 해당 없음: API 변경 없음 |
| 관련 구현 계획 | `docs/20260803_콘텐츠_상세_긴_내용_펼침_성능_개선/plan-task.md` |
| 관련 review | 구현 후 `reviews/phase1-content-detail-expansion-review.md` |
## 1. Overview
콘텐츠 상세 화면에서 긴 작품 설명을 터치해 전체 내용을 펼칠 때 발생하는 지연을 최소 변경으로 줄인다. 기존 접기/펼치기 UX와 데이터 흐름은 유지한다.
## 2. Problem Statement
- 약 1,000자의 작품 설명을 펼칠 때 3초 이상 지연되는 사례가 있다.
- 현재 `TextView`는 접힘용 `ellipsize=end`를 유지한 채 `maxLines``Int.MAX_VALUE`로 변경한다.
- 긴 본문을 펼친 상태에서는 말줄임 계산이 필요하지 않다.
## 3. Goals
- 펼친 상태에서 `TextView`의 말줄임 처리를 제거한다.
- 접힌 상태에서는 기존 2줄 및 끝 말줄임 표시를 유지한다.
- 반복해서 접고 펼쳐도 각 상태가 정확히 복원된다.
- 기존 상세 화면의 API, ViewModel, 레이아웃 구조는 변경하지 않는다.
## 4. Non-Goals
- `NestedScrollView` 또는 하위 `RecyclerView` 구조 변경
- 애니메이션, 별도 더보기 버튼 또는 신규 UI 추가
- API, 번역 데이터, 콘텐츠 본문 가공 변경
- 정량 성능 기준을 검증하기 위한 benchmark framework 추가
## 5. 핵심 사용자 흐름
1. 사용자가 콘텐츠 상세 화면에서 2줄로 접힌 작품 설명을 본다.
2. 작품 설명을 터치하면 전체 본문이 말줄임 없이 표시된다.
3. 다시 터치하면 2줄 끝 말줄임 상태로 복원된다.
## 6. 기능 요구사항
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|---|---|---|---|---|
| `DETAIL-001` | 확정 | 펼침 상태에서는 말줄임을 사용하지 않는다. | `maxLines == Int.MAX_VALUE`, `ellipsize == null` | `P1-T1` |
| `DETAIL-002` | 확정 | 접힘 상태에서는 기존 표시를 유지한다. | `maxLines == 2`, `ellipsize == TextUtils.TruncateAt.END` | `P1-T1` |
| `DETAIL-003` | 확정 | 반복 토글 시 상태가 복원된다. | 펼침 후 접힘 처리에 두 상태가 모두 명시된다. | `P1-T1`, `P1-GATE` |
## 7. 성능과 품질 요구사항
- 신규 dependency와 abstraction을 추가하지 않는다.
- 기존 저장소의 `TextView` 접기/펼치기 패턴을 재사용한다.
- source regression test, debug unit test, debug build를 통과해야 한다.
- 연결된 Android 기기에서 가능한 범위의 실제 화면 검증을 수행한다.
## 8. 성공 기준
- [x] 펼침 시 `ellipsize``null`로 설정된다.
- [x] 접힘 시 `ellipsize``END`로 복원된다.
- [x] 접힘/펼침의 기존 `maxLines` 값이 유지된다.
- [x] focused test와 debug build가 성공한다.
- [x] 실제 기기 검증 결과가 기록된다.
## 9. Decision Log
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|---|---|---|---|---|---|
| 2026-08-03 | `DEC-001` | 확정 | 펼침 상태의 `ellipsize`만 제거하고 접힘 시 복원한다. | 사용자 최소 변경 요청과 저장소 내 동일 패턴 | `DETAIL-001~003`, `P1-T1` |
| 2026-08-03 | `DEC-002` | 확정 | 레이아웃 구조와 추천 목록 측정 최적화는 제외한다. | 검증되지 않은 범위 확장을 피한다. | Non-Goals, `P1-GATE` |

View File

@@ -0,0 +1,84 @@
# Phase 1 콘텐츠 상세 긴 내용 펼침 리뷰
## 1. 리뷰 정보
| 항목 | 내용 |
|---|---|
| 리뷰 대상 | Phase 1 / Task 1.1 |
| 기준 commit 또는 working tree | working tree |
| 리뷰 일자 | 2026-08-03 |
| 리뷰어 | Oracle 코드 리뷰, 기능 QA, CJK 표시 QA |
| 기준 문서 | `prd.md`, `plan-task.md` |
| 리뷰 상태 | 판정 완료 |
## 2. 리뷰 목적과 범위
### 목적
- `DETAIL-001~003`과 최소 변경 요구사항 충족 여부를 확인한다.
- source test, 빌드 및 실기기 검증 기록과 실제 구현이 일치하는지 확인한다.
### 포함 범위
- 코드: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt`
- 테스트: `app/src/test/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivitySourceTest.kt`
- 수동 검증: 연결 기기의 접힘, 펼침, 재접힘 상태와 `gfxinfo`
### 제외 범위
- 스크롤 및 추천 목록 레이아웃 구조
- 신규 benchmark framework와 60Hz 단일 프레임 목표
## 3. 검토한 근거
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|---|---|---|
| focused test RED | 성공 | 구현 전 assertion 실패 3건 |
| focused test GREEN | 성공 | 테스트 3건 통과 |
| 전체 debug unit test | 성공 | `:app:testDebugUnitTest` 통과 |
| ktlint main/test | 성공 | 두 source set 검사 통과 |
| `:app:assembleDebug` | 성공 | debug APK 생성 |
| 실기기 접힘 → 펼침 → 재접힘 | 성공 | 2줄 말줄임 → 전체 표시 → 2줄 말줄임 복원 |
| 실기기 `gfxinfo` | 성공 | 펼침 150ms, 접힘 117ms 렌더링 프레임 |
## 4. 발견 사항 요약
확정 발견 사항 없음.
| ID | 심각도 | 상태 | 제목 | 후속 goal |
|---|---|---|---|---|
| `REV-P1-001` | Low | 오탐 | 16ms 프레임 예산 미충족 후보 | 없음 |
## 5. 발견 사항 상세
### REV-P1-001 — 16ms 프레임 예산 미충족 후보
- **심각도:** Low
- **상태:** 오탐
- **관련 요구사항:** `DETAIL-001~003`
- **소유 Task:** `P1-T1`
**관찰 내용**
초기 기능 QA에서 펼침 150ms와 접힘 117ms가 60Hz 프레임 예산을 초과한다는 후보가 제기됐다.
**판정 기록**
- 2026-08-03 — PRD에는 16ms 목표가 없고 사용자 요청은 기존 3초 이상 지연을 최소 변경으로 줄이는 것이다. 현재 결과는 세 기능 요구사항을 충족하고 기존 관찰값보다 크게 개선됐으므로 차단 기준이 아니며 리뷰어 재판정에서 PASS로 철회됐다.
## 6. 확정 항목의 plan·goal 전환
전환 항목 없음.
## 7. 리뷰 종료 판정
| 판정 항목 | 결과 | 근거 |
|---|---|---|
| 리뷰 범위 전체 확인 | 충족 | 코드, 테스트, 문서 및 최신 실기기 캡처 확인 |
| 후보 항목 판정 완료 | 충족 | `REV-P1-001` 오탐 판정 |
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
| 검증 명령과 결과 기록 | 충족 | `plan-task.md` Progress와 Verification Log |
**최종 결론:** 확정 발견 사항 없음, 독립 리뷰 PASS.
**남은 항목:** 없음.