diff --git a/docs/20260706_메인_V2_미니_플레이어와_오디오_알림_이동/plan-task.md b/docs/20260706_메인_V2_미니_플레이어와_오디오_알림_이동/plan-task.md new file mode 100644 index 00000000..bdc0a7c5 --- /dev/null +++ b/docs/20260706_메인_V2_미니_플레이어와_오디오_알림_이동/plan-task.md @@ -0,0 +1,448 @@ +# 메인 V2 미니 플레이어와 오디오 알림 이동 구현 계획/TASK + +> **For agentic workers:** REQUIRED SUB-SKILL: 구현 시 `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`를 사용해 task 단위로 진행한다. 각 단계는 체크박스(`- [ ]`)로 추적하고, 완료 즉시 `- [x]`로 갱신한다. 구현 범위 변경이 생기면 이 문서를 먼저 수정한 뒤 코드에 반영한다. + +**Goal:** `MainV2Activity`에서 오디오 재생 중 미니 플레이어를 표시하고, Notification Shade의 오디오 알림 터치 시 새 플레이어 또는 오디오 상세페이지로 이동한다. + +**Architecture:** `MainV2Activity`는 기존 `MainActivity`의 `MediaController` 기반 `clMiniPlayer` 흐름을 기준으로 미니 플레이어 상태를 표시한다. 알림 터치는 서비스별 `PendingIntent`에 명시 route extra를 담고, `MainV2Activity`가 해당 extra를 소비해 `AudioContentPlayerFragment` 또는 `AudioContentDetailActivity`로 분기한다. + +**Tech Stack:** Kotlin, Android XML Views, ViewBinding, Media3 `MediaController`/`MediaSession`, Android `PendingIntent`, JUnit4 source-level test. + +--- + +## 전제와 성공 기준 +- PRD: `docs/20260706_메인_V2_미니_플레이어와_오디오_알림_이동/prd.md` +- 이번 문서 작성 턴에서는 구현하지 않는다. +- 구현 전 레거시 파일 수정 승인 필요: + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt` +- 구현 대상: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt` +- 조건부 구현 대상: + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt` +- 확인 대상: + - `app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt` + - `app/src/main/res/layout/activity_main_v2.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerFragment.kt` +- 성공 기준: + - `MainV2Activity`가 `SharedPreferenceManager.isPlayerServiceRunningFlow`를 통해 미니 플레이어를 표시/숨김 처리한다. + - `MainV2Activity` 미니 플레이어가 title, artist, artwork, play/pause icon을 `MediaController` 상태와 동기화한다. + - `MainV2Activity` 미니 플레이어 터치 시 `AudioContentPlayerFragment`가 표시된다. + - `AudioContentPlayerService` 알림 터치 route는 `MainV2Activity`에서 player fragment 표시로 이어진다. + - `AudioContentPlayService` 알림 터치 route는 `MainV2Activity`에서 `AudioContentDetailActivity` 이동으로 이어진다. + - 처리한 알림 route extra는 중복 실행되지 않도록 제거된다. + - 채팅 필터 진입과 일반 딥링크 처리가 회귀하지 않는다. +- 구현 완료 후 최소 다음 명령을 실행한다. + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2AudioNotificationRouteSourceTest"` + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerServiceNotificationRouteSourceTest"` + - `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.AudioContentPlayServiceNotificationRouteSourceTest"` + - `./gradlew :app:compileDebugKotlin` + - `./gradlew :app:ktlintCheck` + - `git diff --check` + +--- + +## 검토한 접근 + +### 접근 A: `MainV2Activity`에서만 알림 진입 추정 +- 내용: `onNewIntent()`에서 extra가 없는 `MainV2Activity` 진입을 보고 player service 상태를 추정한다. +- 장점: 레거시 서비스 파일을 수정하지 않는다. +- 단점: 일반 앱 진입과 알림 터치를 구분할 수 없어 의도치 않게 player fragment가 열릴 수 있다. +- 결론: 채택하지 않는다. + +### 접근 B: 서비스 알림 `PendingIntent`에 route extra 추가 +- 내용: 새 player service는 `audio_player`, 기존 play service는 `audio_detail` route extra를 전달한다. +- 장점: 알림 본문 터치와 일반 진입을 명확히 구분하고, `MainActivity`의 두 오디오 흐름과 동일한 목적지 결정을 유지한다. +- 단점: 레거시 경로의 service 파일 2개를 최소 수정해야 한다. +- 결론: 권장안. 구현 전 사용자 승인 후 진행한다. + +--- + +## 파일 구조 +- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt` + - 서비스에서 참조 가능한 public 알림 route `const val`을 추가한다. + - `onCreate()`와 `onNewIntent()`에서 알림 route를 소비한다. + - player route는 `showPlayerFragment()`로 연결한다. + - detail route는 `AudioContentDetailActivity` explicit intent로 연결한다. + - route 처리 후 intent extra를 제거한다. +- Modify after approval: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt` + - `initMediaSession()`의 session activity pending intent에 player route extra를 추가한다. +- Modify after approval: `app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt` + - `updateNotification()`의 content pending intent에 detail route extra와 `Constants.EXTRA_AUDIO_CONTENT_ID`를 추가한다. +- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2AudioNotificationRouteSourceTest.kt` + - `MainV2Activity`의 route 소비 계약을 source-level로 검증한다. +- Create after approval: `app/src/test/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerServiceNotificationRouteSourceTest.kt` + - `AudioContentPlayerService`의 player route pending intent 계약을 검증한다. +- Create after approval: `app/src/test/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayServiceNotificationRouteSourceTest.kt` + - `AudioContentPlayService`의 detail route pending intent 계약을 검증한다. +- No Modify: + - `app/src/main/res/layout/activity_main_v2.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerFragment.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt` + +--- + +### Phase 1: 기존 흐름 확인 + +- [x] **Task 1.1: `MainActivity` 미니 플레이어 흐름 확인** + - 확인: + - `app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt` + - `app/src/main/res/layout/activity_main.xml` + - 결과: + - `clMiniPlayer`는 `SharedPreferenceManager.isPlayerServiceRunningFlow`가 `true`일 때 1500ms 후 표시된다. + - `clMiniPlayer` 터치 시 `AudioContentPlayerFragment`를 표시한다. + - `ivPlayerStop`은 `AudioContentPlayerService`에 `STOP_SERVICE` action을 전달한다. + - `MediaController` listener가 metadata와 play/pause icon을 갱신한다. + - 별도 `rlMiniPlayer`는 `AudioContentPlayService` broadcast를 받아 표시되고, 터치 시 `AudioContentDetailActivity`로 이동한다. + - 검증 기록: + - 2026-07-06: `rg -n "clMiniPlayer|rlMiniPlayer|AudioContentPlayerService|AudioContentPlayService|showPlayerFragment|AudioContentDetailActivity" app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt`와 source 확인으로 두 오디오 흐름을 확인했다. + +- [x] **Task 1.2: `MainV2Activity` 현재 미니 플레이어 흐름 확인** + - 확인: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt` + - `app/src/main/res/layout/activity_main_v2.xml` + - 결과: + - `MainV2Activity`에는 `observePlayerState()`, `initAndVisibleMiniPlayer()`, `connectPlayerService()`, `setupMediaController()`, `deInitMiniPlayer()`, `showPlayerFragment()`가 이미 존재한다. + - `activity_main_v2.xml`에는 `cl_mini_player`가 bottom navigation 위에 정의되어 있다. + - 구현 시 신규 레이아웃 추가 없이 기존 binding을 사용하면 된다. + - 검증 기록: + - 2026-07-06: `rg -n "observePlayerState|initAndVisibleMiniPlayer|connectPlayerService|showPlayerFragment|clMiniPlayer" app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt app/src/main/res/layout/activity_main_v2.xml`로 확인했다. + +- [x] **Task 1.3: 오디오 알림 PendingIntent 흐름 확인** + - 확인: + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt` + - 결과: + - `AudioContentPlayerService.initMediaSession()`은 `MainV2Activity` pending intent를 `setSessionActivity()`에 설정한다. + - `AudioContentPlayService.updateNotification()`은 `MainV2Activity` pending intent를 `setContentIntent()`에 설정한다. + - 두 intent 모두 현재 알림 터치 route를 구분할 extra가 없다. + - 검증 기록: + - 2026-07-06: `rg -n "setSessionActivity|setContentIntent|PendingIntent|getActivity|MainV2Activity" app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt`로 확인했다. + +### Phase 2: `MainV2Activity` 알림 route 소비 계약 추가 + +- [x] **Task 2.1: `MainV2Activity` source test 작성** + - 생성: + - `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2AudioNotificationRouteSourceTest.kt` + - 검증 기준: + - `MainV2Activity.kt`가 `EXTRA_AUDIO_NOTIFICATION_ROUTE`를 정의한다. + - `ROUTE_AUDIO_PLAYER`와 `ROUTE_AUDIO_DETAIL`을 정의한다. + - 세 route 상수는 service 파일에서 참조할 수 있도록 `private` 없이 `const val`로 정의한다. + - `handleAudioNotificationRoute` 또는 동등한 메서드가 존재한다. + - `ROUTE_AUDIO_PLAYER` 처리 시 `showPlayerFragment()`를 호출한다. + - `ROUTE_AUDIO_DETAIL` 처리 시 `AudioContentDetailActivity::class.java`와 `Constants.EXTRA_AUDIO_CONTENT_ID`를 사용한다. + - 처리 후 `intent.removeExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE)`를 호출한다. + - `onNewIntent()`에서 `EXTRA_CHAT_FILTER` 처리 이후 알림 route 처리가 실행된다. + - 테스트 예시: + ```kotlin + package kr.co.vividnext.sodalive.v2.main + + import org.junit.Assert.assertTrue + import org.junit.Test + import java.io.File + + class MainV2AudioNotificationRouteSourceTest { + private val source = File( + "src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt" + ).readText() + + @Test + fun `오디오 알림 route는 플레이어와 상세페이지로 분기한다`() { + assertTrue(source.contains("EXTRA_AUDIO_NOTIFICATION_ROUTE")) + assertTrue(source.contains("const val EXTRA_AUDIO_NOTIFICATION_ROUTE")) + assertTrue(source.contains("ROUTE_AUDIO_PLAYER")) + assertTrue(source.contains("ROUTE_AUDIO_DETAIL")) + assertTrue(source.contains("handleAudioNotificationRoute")) + assertTrue(source.contains("showPlayerFragment()")) + assertTrue(source.contains("AudioContentDetailActivity::class.java")) + assertTrue(source.contains("Constants.EXTRA_AUDIO_CONTENT_ID")) + assertTrue(source.contains("removeExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE)")) + } + } + ``` + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2AudioNotificationRouteSourceTest"` + - Expected: 구현 전에는 `AssertionError`로 실패한다. + - 검증 기록: + - 2026-07-06: `MainV2AudioNotificationRouteSourceTest.kt`를 추가했다. 초기 병렬 Gradle 실행 중 `processDebugManifest` 임시 산출물 누락으로 테스트 assertion 전 빌드 실패가 발생해, 구현 후 순차 재실행으로 테스트 PASS를 확인했다. + +- [x] **Task 2.2: `MainV2Activity`에 알림 route 처리 추가** + - 수정: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt` + - 구현 방향: + ```kotlin + companion object { + private const val EXTRA_CHAT_FILTER: String = "extra_chat_filter" + const val EXTRA_AUDIO_NOTIFICATION_ROUTE: String = "extra_audio_notification_route" + const val ROUTE_AUDIO_PLAYER: String = "audio_player" + const val ROUTE_AUDIO_DETAIL: String = "audio_detail" + } + + private fun handleAudioNotificationRoute(intent: Intent): Boolean { + val route = intent.getStringExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE) ?: return false + intent.removeExtra(EXTRA_AUDIO_NOTIFICATION_ROUTE) + + return when (route) { + ROUTE_AUDIO_PLAYER -> { + if (isLoggedIn()) { + handler.post { showPlayerFragment() } + } else { + showLoginActivity() + } + true + } + + ROUTE_AUDIO_DETAIL -> { + val contentId = intent.getLongExtra(Constants.EXTRA_AUDIO_CONTENT_ID, 0) + intent.removeExtra(Constants.EXTRA_AUDIO_CONTENT_ID) + if (contentId > 0 && isLoggedIn()) { + startActivity( + Intent(applicationContext, AudioContentDetailActivity::class.java).apply { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, contentId) + } + ) + } else if (!isLoggedIn()) { + showLoginActivity() + } + contentId > 0 + } + + else -> false + } + } + ``` + - 적용 위치: + - `onCreate()`: `isLoggedIn()` 블록의 `executeDeeplink(intent)` 예약 전에 알림 route를 먼저 확인한다. + - `onNewIntent()`: `EXTRA_CHAT_FILTER` 처리 이후, `executeDeeplink(intent)` 전에 알림 route를 확인한다. + - 주의: + - `EXTRA_CHAT_FILTER`는 기존처럼 가장 먼저 처리한다. + - 알림 route가 처리되면 같은 intent의 일반 딥링크 실행을 생략한다. + - `showPlayerFragment()`가 activity state 저장 이후 실행되지 않도록 필요 시 lifecycle state를 확인하거나 `handler.post` 수준의 최소 지연만 사용한다. + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2AudioNotificationRouteSourceTest"` + - Expected: PASS + - 검증 기록: + - 2026-07-06: `MainV2Activity.kt`에 public route 상수와 `handleAudioNotificationRoute()`를 추가하고, `onCreate()`/`onNewIntent()`에서 딥링크 전에 route를 소비하도록 연결했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2AudioNotificationRouteSourceTest"` PASS. + +### Phase 3: 오디오 서비스 알림 PendingIntent route 추가 + +- [x] **Task 3.1: 레거시 서비스 파일 수정 승인 확인** + - 확인 필요: + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt` + - 작업: + - 두 파일은 `kr.co.vividnext.sodalive.v2` 하위가 아니므로 구현 전에 사용자에게 레거시 파일 최소 수정 승인을 받는다. + - 검증 기준: + - 승인 없이는 Task 3.2~3.5를 진행하지 않는다. + - 검증 기록: + - 2026-07-06: 사용자가 `승인하고 진행`을 선택해 `AudioContentPlayerService.kt`, `AudioContentPlayService.kt` 최소 수정 승인을 확인했다. + +- [x] **Task 3.2: `AudioContentPlayerService` notification route source test 작성** + - 생성: + - `app/src/test/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerServiceNotificationRouteSourceTest.kt` + - 검증 기준: + - service source가 `MainV2Activity.EXTRA_AUDIO_NOTIFICATION_ROUTE`를 사용한다. + - service source가 `MainV2Activity.ROUTE_AUDIO_PLAYER`를 사용한다. + - `PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE`는 유지된다. + - 테스트 예시: + ```kotlin + package kr.co.vividnext.sodalive.audio_content.player + + import org.junit.Assert.assertTrue + import org.junit.Test + import java.io.File + + class AudioContentPlayerServiceNotificationRouteSourceTest { + private val source = File( + "src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt" + ).readText() + + @Test + fun `MediaSession session activity는 플레이어 route를 전달한다`() { + assertTrue(source.contains("MainV2Activity.EXTRA_AUDIO_NOTIFICATION_ROUTE")) + assertTrue(source.contains("MainV2Activity.ROUTE_AUDIO_PLAYER")) + assertTrue(source.contains("PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE")) + } + } + ``` + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerServiceNotificationRouteSourceTest"` + - Expected: 구현 전에는 `AssertionError`로 실패한다. + - 검증 기록: + - 2026-07-06: `AudioContentPlayerServiceNotificationRouteSourceTest.kt`를 추가했다. 구현 전 실행에서 `AssertionError`로 실패해 RED를 확인했다. + +- [x] **Task 3.3: `AudioContentPlayerService` session activity pending intent에 player route 추가** + - 수정: + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt` + - 구현 방향: + ```kotlin + val contextIntent = Intent(applicationContext, MainV2Activity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra( + MainV2Activity.EXTRA_AUDIO_NOTIFICATION_ROUTE, + MainV2Activity.ROUTE_AUDIO_PLAYER + ) + } + ``` + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerServiceNotificationRouteSourceTest"` + - Expected: PASS + - 검증 기록: + - 2026-07-06: `AudioContentPlayerService.kt`의 session activity intent에 `ROUTE_AUDIO_PLAYER` extra를 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerServiceNotificationRouteSourceTest"` PASS. + +- [x] **Task 3.4: `AudioContentPlayService` notification route source test 작성** + - 생성: + - `app/src/test/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayServiceNotificationRouteSourceTest.kt` + - 검증 기준: + - service source가 `MainV2Activity.EXTRA_AUDIO_NOTIFICATION_ROUTE`를 사용한다. + - service source가 `MainV2Activity.ROUTE_AUDIO_DETAIL`을 사용한다. + - content pending intent가 `Constants.EXTRA_AUDIO_CONTENT_ID`를 전달한다. + - `PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT`는 유지된다. + - 테스트 예시: + ```kotlin + package kr.co.vividnext.sodalive.audio_content + + import org.junit.Assert.assertTrue + import org.junit.Test + import java.io.File + + class AudioContentPlayServiceNotificationRouteSourceTest { + private val source = File( + "src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt" + ).readText() + + @Test + fun `기존 오디오 알림은 상세 route와 content id를 전달한다`() { + assertTrue(source.contains("MainV2Activity.EXTRA_AUDIO_NOTIFICATION_ROUTE")) + assertTrue(source.contains("MainV2Activity.ROUTE_AUDIO_DETAIL")) + assertTrue(source.contains("Constants.EXTRA_AUDIO_CONTENT_ID")) + assertTrue(source.contains("PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_CANCEL_CURRENT")) + } + } + ``` + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.AudioContentPlayServiceNotificationRouteSourceTest"` + - Expected: 구현 전에는 `AssertionError`로 실패한다. + - 검증 기록: + - 2026-07-06: `AudioContentPlayServiceNotificationRouteSourceTest.kt`를 추가했다. 초기 병렬 Gradle 실행 중 KSP 임시 산출물 EOF로 테스트 assertion 전 빌드 실패가 발생해, 구현 후 순차 재실행으로 테스트 PASS를 확인했다. + +- [x] **Task 3.5: `AudioContentPlayService` content pending intent에 detail route와 content id 추가** + - 수정: + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt` + - 구현 방향: + ```kotlin + val intent = Intent(this, MainV2Activity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + putExtra( + MainV2Activity.EXTRA_AUDIO_NOTIFICATION_ROUTE, + MainV2Activity.ROUTE_AUDIO_DETAIL + ) + contentId?.let { + putExtra(Constants.EXTRA_AUDIO_CONTENT_ID, it) + } + } + ``` + - 검증: + - Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.AudioContentPlayServiceNotificationRouteSourceTest"` + - Expected: PASS + - 검증 기록: + - 2026-07-06: `AudioContentPlayService.kt`의 notification content intent에 `ROUTE_AUDIO_DETAIL`과 현재 `contentId` extra를 추가했다. `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.AudioContentPlayServiceNotificationRouteSourceTest"` PASS. + +### Phase 4: 통합 검증 + +- [x] **Task 4.1: source-level test 실행** + - 실행: + ```bash + ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2AudioNotificationRouteSourceTest" + ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerServiceNotificationRouteSourceTest" + ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.audio_content.AudioContentPlayServiceNotificationRouteSourceTest" + ``` + - 기대 결과: + - 모두 PASS한다. + - 검증 기록: + - 2026-07-06: 세 source-level test를 순차 실행해 모두 PASS를 확인했다. 병렬 실행 시 Gradle manifest/KSP 임시 산출물 경합으로 빌드 단계 실패가 있었으나, 순차 실행에서는 재현되지 않았다. + +- [x] **Task 4.2: 컴파일과 ktlint 확인** + - 실행: + ```bash + ./gradlew :app:compileDebugKotlin + ./gradlew :app:ktlintCheck + git diff --check + ``` + - 기대 결과: + - 모두 PASS한다. + - 검증 기록: + - 2026-07-06: `./gradlew :app:compileDebugKotlin`, `./gradlew :app:ktlintCheck`, `git diff --check` 모두 PASS를 확인했다. `ktlintCheck` 과정에서 이번 변경으로 검사 대상이 된 `audio_content` 패키지 파일의 기존 underscore package-name 규칙 위반은 저장소 내 기존 suppress 관례와 동일하게 file-level suppress로 처리했다. + +- [x] **Task 4.3: 수동 확인** + - 확인: + - 로그인 상태에서 `AudioContentPlayerService` 기반 오디오를 재생하면 `MainV2Activity` 하단에 미니 플레이어가 표시된다. + - 미니 플레이어의 title, artist, artwork, play/pause icon이 현재 재생 상태와 맞다. + - 미니 플레이어 터치 시 `AudioContentPlayerFragment`가 표시된다. + - 미니 플레이어 stop 터치 시 재생 서비스가 종료되고 미니 플레이어가 숨겨진다. + - Notification Shade에서 `AudioContentPlayerService` 알림 본문 터치 시 `MainV2Activity`가 열리고 player fragment가 표시된다. + - Notification Shade에서 `AudioContentPlayService` 알림 본문 터치 시 `AudioContentDetailActivity`가 열리고 현재 content id가 전달된다. + - `MainV2Activity.newChatDmIntent()`로 진입한 채팅 필터 흐름이 기존처럼 동작한다. + - 콘텐츠 딥링크 bundle로 진입하면 기존처럼 `AudioContentDetailActivity`로 이동한다. + - 검증 기록: + - 2026-07-06: 연결된 디바이스(`adb devices`)에서 debug 앱 설치를 확인했고, `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`로 일반 앱 런처 진입이 crash 없이 실행됨을 확인했다. + - 2026-07-06: `adb shell am start -W -n kr.co.vividnext.sodalive.debug/kr.co.vividnext.sodalive.v2.main.MainV2Activity --es extra_audio_notification_route audio_player` 직접 실행은 `MainV2Activity`가 exported가 아니어서 OS `SecurityException`으로 차단됐다. 실제 알림 PendingIntent는 앱 내부에서 생성되므로 route 계약은 source-level test와 compile로 검증했다. + - 2026-07-06: 로그인 세션과 실제 오디오 재생 상태를 요구하는 미니 플레이어 UI/Notification Shade end-to-end 확인은 현재 자동 실행 환경에서 완료하지 못했다. + +--- + +## Verification Log +- 2026-07-06: 문서 작성 단계에서는 코드 구현과 Gradle 검증을 수행하지 않았다. `MainActivity`, `MainV2Activity`, `activity_main_v2.xml`, `AudioContentPlayerService`, `AudioContentPlayService`를 정적으로 확인해 PRD와 계획/TASK 문서만 작성했다. + +- 2026-07-06: 구현 완료 후 route source-level test 3개, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`를 실행했다. 일반 앱 런처 표면은 연결 디바이스에서 확인했고, non-exported Activity 직접 route 실행은 OS 권한으로 차단되어 source-level test로 계약을 검증했다. +- 2026-07-06: 리뷰 게이트에서 비로그인 알림 진입 시 route가 로그인 가드 밖에서 소비되지 않는 차단 이슈가 발견되어 `MainV2Activity`의 route 처리 순서를 수정했다. `MainV2AudioNotificationRouteSourceTest`에 route handler가 `isLoggedIn()`보다 먼저 평가되는 회귀 검증을 추가했고, route source-level test 3개, `compileDebugKotlin`, `ktlintCheck`, `git diff --check`를 재실행해 모두 PASS를 확인했다. + +### Phase 5: `AudioContentPlayService` 기반 MainV2 미니 플레이어 표시 보완 + +- [x] **Task 5.1: `MainV2Activity` legacy 오디오 미니 플레이어 source test 작성** + - 생성/수정: + - `app/src/test/java/kr/co/vividnext/sodalive/v2/main/MainV2ActivitySourceTest.kt` + - 검증 기준: + - `MainV2Activity`가 `Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER` receiver를 등록/해제한다. + - receiver가 `Constants.EXTRA_AUDIO_CONTENT_SHOWING`, `Constants.EXTRA_AUDIO_CONTENT_ID`, title, nickname, cover image, playing 상태를 읽는다. + - 표시 상태에서는 `binding.clMiniPlayer.visibility = View.VISIBLE`을 설정한다. + - 미니 플레이어 터치 시 `AudioContentDetailActivity`로 `Constants.EXTRA_AUDIO_CONTENT_ID`를 전달한다. + - play/pause/stop 버튼은 `AudioContentPlayService.MusicAction`을 전달한다. + - 검증 기록: + - 2026-07-06: `MainV2ActivitySourceTest`에 `ACTION_MAIN_AUDIO_CONTENT_RECEIVER` receiver 계약을 추가했다. 구현 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest"` 실행 시 `AssertionError`로 실패해 RED를 확인했다. + +- [x] **Task 5.2: `MainV2Activity`에 `AudioContentPlayService` receiver 연결** + - 수정: + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt` + - 구현 방향: + - `MainActivity.AudioContentReceiver`의 동작을 기준으로 신규 UI를 만들지 않고 `activity_main_v2.xml`의 기존 `clMiniPlayer` 뷰를 재사용한다. + - `onResume()`에서 `Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER` receiver를 등록하고, 기존 `AudioContentPlayService.MusicAction.INIT` 호출은 유지한다. + - `onPause()`에서 receiver를 해제한다. + - `isShowing == true`이면 `clMiniPlayer`를 하단 탭 위에 표시하고 title, nickname, cover, play/pause, stop을 반영한다. + - `isShowing != true`이면 `clMiniPlayer`를 숨기고 UI를 초기화한다. + - 주의: + - 레거시 서비스 파일은 추가 수정하지 않는다. + - XML 레이아웃과 디자인은 변경하지 않는다. + - `AudioContentPlayerService` 기반 MediaController 미니 플레이어 흐름은 유지한다. + - 검증 기록: + - 2026-07-06: `MainV2Activity`에 `AudioContentReceiver`를 추가하고 `onResume()`/`onPause()`에서 `Constants.ACTION_MAIN_AUDIO_CONTENT_RECEIVER`를 등록/해제하도록 연결했다. receiver는 기존 `clMiniPlayer`에 title, nickname, cover, play/pause, stop, 상세 이동을 반영한다. 레거시 서비스 파일과 XML은 추가 수정하지 않았다. + +- [x] **Task 5.3: 후속 검증** + - 실행: + ```bash + ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2ActivitySourceTest" + ./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.MainV2AudioNotificationRouteSourceTest" + ./gradlew :app:compileDebugKotlin + ./gradlew :app:ktlintCheck + git diff --check + ``` + - 수동 확인: + - `AudioContentPlayService` 기반 오디오 재생 중 `MainV2Activity` 하단 탭 바로 위에 미니 플레이어가 표시된다. + - Notification Shade의 기존 오디오 알림 본문 터치로 상세 진입 후, MainV2로 돌아왔을 때 미니 플레이어가 표시된다. + - 미니 플레이어 터치 시 현재 `contentId`의 `AudioContentDetailActivity`로 이동한다. + - 검증 기록: + - 2026-07-06: `MainV2ActivitySourceTest`, `MainV2AudioNotificationRouteSourceTest`, `compileDebugKotlin`, `ktlintCheck`, `git diff --check` 모두 PASS를 확인했다. 연결 디바이스에서 `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`로 debug 앱 launcher smoke 실행도 완료했다. 실제 오디오 재생 상태의 미니 플레이어 육안 확인은 사용자 환경에서 재확인이 필요하다. diff --git a/docs/20260706_메인_V2_미니_플레이어와_오디오_알림_이동/prd.md b/docs/20260706_메인_V2_미니_플레이어와_오디오_알림_이동/prd.md new file mode 100644 index 00000000..b9cadadd --- /dev/null +++ b/docs/20260706_메인_V2_미니_플레이어와_오디오_알림_이동/prd.md @@ -0,0 +1,148 @@ +# PRD: 메인 V2 미니 플레이어와 오디오 알림 이동 + +## 1. Overview +`MainV2Activity`에서 오디오 재생 중 미니 플레이어를 안정적으로 표시하고, Notification Shade의 오디오 재생 알림 터치 시 현재 재생 방식에 맞는 플레이어 또는 오디오 상세페이지로 이동한다. + +--- + +## 2. Problem +- `MainActivity`는 오디오 재생 상태를 관찰해 하단 미니 플레이어를 표시하고, 재생/일시정지/정지/상세 이동 동작을 제공한다. +- `MainV2Activity`에는 `AudioContentPlayerService` 기반 `clMiniPlayer` 표시 코드가 이미 들어와 있으나, Notification Shade에서 알림을 터치했을 때 플레이어 또는 상세페이지로 분기하는 명시적인 진입 계약이 없다. +- `AudioContentPlayerService`의 `MediaSession.setSessionActivity`와 `AudioContentPlayService.updateNotification()` 모두 현재 `MainV2Activity`를 열지만, 알림에서 온 진입인지 구분할 extra가 없어 `MainV2Activity`가 목적지를 결정할 수 없다. +- 기존 `MainActivity`에는 두 종류의 오디오 흐름이 공존한다. + - `AudioContentPlayerService` 기반 `clMiniPlayer`: 미니 플레이어 터치 시 `AudioContentPlayerFragment`를 표시한다. + - `AudioContentPlayService` 기반 `rlMiniPlayer`: 미니 플레이어 터치 시 `AudioContentDetailActivity`로 이동한다. + +--- + +## 3. Goals +- `MainV2Activity`에서 오디오 재생 중 하단 미니 플레이어가 `MainActivity`의 `clMiniPlayer` 흐름과 동일하게 표시된다. +- `SharedPreferenceManager.isPlayerServiceRunningFlow`가 `true`이면 지연 후 `clMiniPlayer`를 표시하고, `false`이면 숨긴다. +- 미니 플레이어에는 현재 미디어의 title, artist, artwork, 재생/일시정지 상태가 반영된다. +- 미니 플레이어 터치 시 `AudioContentPlayerFragment`를 표시한다. +- 미니 플레이어 정지 버튼 터치 시 `AudioContentPlayerService`에 `STOP_SERVICE` action을 전달한다. +- Notification Shade에서 `AudioContentPlayerService` 알림을 터치하면 `MainV2Activity`가 열리고 `AudioContentPlayerFragment`를 표시한다. +- Notification Shade에서 `AudioContentPlayService` 알림을 터치하면 `MainV2Activity`가 열리고 현재 `contentId`의 `AudioContentDetailActivity`로 이동한다. +- 알림 터치 분기는 explicit extra로 구분해 일반 앱 진입, 딥링크, 채팅 필터 진입과 충돌하지 않는다. + +--- + +## 4. Non-Goals +- 이번 요청에서는 코드, 리소스, 레이아웃을 구현하지 않는다. 문서만 생성한다. +- 미니 플레이어의 디자인, 높이, 색상, 아이콘, XML 구조를 변경하지 않는다. +- `AudioContentPlayerFragment`의 UI/재생 기능을 변경하지 않는다. +- `AudioContentDetailActivity` 내부 동작을 변경하지 않는다. +- 알림 액션 버튼의 play/pause/stop 동작을 변경하지 않는다. +- 신규 오디오 플레이어 화면을 만들지 않는다. +- 딥링크 라우팅 전체 구조를 리팩터링하지 않는다. + +--- + +## 5. Target Users +- v2 메인 화면을 사용하면서 오디오 재생 상태를 하단에서 확인하고 제어하려는 사용자. +- Notification Shade의 재생 알림을 터치해 현재 듣는 콘텐츠 또는 플레이어로 즉시 복귀하려는 사용자. +- 기존 `MainActivity` 동작을 기준으로 `MainV2Activity`의 오디오 UX를 맞춰야 하는 Android 개발자. + +--- + +## 6. User Stories +- 사용자는 v2 메인 화면에서 오디오가 재생 중이면 하단 미니 플레이어를 보고 싶다. +- 사용자는 미니 플레이어에서 재생/일시정지와 정지를 바로 제어하고 싶다. +- 사용자는 미니 플레이어를 터치하면 현재 재생 목록/플레이어 화면을 보고 싶다. +- 사용자는 새 플레이어 알림을 터치하면 전체 플레이어로 돌아가고 싶다. +- 사용자는 기존 오디오 콘텐츠 알림을 터치하면 해당 오디오 상세페이지로 이동하고 싶다. + +--- + +## 7. Core Features + +### Feature A. MainV2Activity 미니 플레이어 표시 + +#### Requirements +- 구현 기준은 `MainActivity`의 다음 흐름이다. + - `SharedPreferenceManager.isPlayerServiceRunningFlow.collect` + - `handler.postDelayed(showMiniPlayerRunnable, 1500)` + - `initAndVisibleMiniPlayer()` + - `connectPlayerService()` + - `MediaController`의 metadata/playWhenReady listener + - `deInitMiniPlayer()` +- `MainV2Activity`는 이미 같은 이름과 유사한 흐름의 메서드를 보유하므로, 구현 시 기존 코드를 유지하고 누락/불일치만 보완한다. +- `activity_main_v2.xml`의 `cl_mini_player`, `iv_player_cover`, `tv_player_title`, `tv_player_nickname`, `iv_player_play_or_pause`, `iv_player_stop`을 그대로 사용한다. +- 미니 플레이어 터치 동작은 `showPlayerFragment()`를 호출해 `AudioContentPlayerFragment(screenWidth, arrayListOf())`를 표시한다. +- 정지 버튼은 `AudioContentPlayerService`에 `action = "STOP_SERVICE"`를 전달한다. +- `MediaController` 연결 실패 시 crash 없이 로그만 남기고, controller future와 controller 참조를 정리한다. + +#### Edge Cases +- 로그인하지 않은 상태에서는 기존 `MainV2Activity.isLoggedIn()` 조건상 player state 관찰을 시작하지 않는다. +- 서비스가 종료되면 pending runnable을 제거하고 미니 플레이어를 숨기며 controller를 release한다. +- `MediaController`가 이미 연결되어 있거나 연결 중이면 중복 연결하지 않는다. +- metadata가 null이면 title/artist/artwork를 임의 값으로 덮어쓰지 않는다. + +### Feature B. Notification Shade 알림 터치 이동 + +#### Requirements +- 알림 터치 진입은 일반 `MainV2Activity` 실행과 구분되는 explicit extra로 표시한다. +- 권장 extra 계약: + - `EXTRA_AUDIO_NOTIFICATION_ROUTE = "extra_audio_notification_route"` + - `ROUTE_AUDIO_PLAYER = "audio_player"` + - `ROUTE_AUDIO_DETAIL = "audio_detail"` + - 상세 이동 시 `Constants.EXTRA_AUDIO_CONTENT_ID`를 함께 전달한다. +- `AudioContentPlayerService.initMediaSession()`의 `setSessionActivity` pending intent는 `ROUTE_AUDIO_PLAYER`를 전달한다. +- `AudioContentPlayService.updateNotification()`의 content pending intent는 `ROUTE_AUDIO_DETAIL`과 현재 `contentId`를 전달한다. +- `MainV2Activity.onCreate()`와 `onNewIntent()`는 채팅 필터 처리 이후, 일반 딥링크 처리 전 또는 별도 우선순위로 알림 route를 소비한다. +- `ROUTE_AUDIO_PLAYER`를 받으면 로그인 상태에서 `AudioContentPlayerFragment`를 표시한다. +- `ROUTE_AUDIO_DETAIL`과 유효한 `contentId > 0`을 받으면 `AudioContentDetailActivity`로 이동한다. +- 알림 route를 처리한 뒤에는 intent extra를 제거해 회전, 재전달, `setIntent()` 후 중복 처리를 방지한다. + +#### Edge Cases +- `ROUTE_AUDIO_DETAIL`인데 `contentId`가 없거나 0이면 상세페이지 이동을 하지 않고 일반 메인 진입으로 둔다. +- `ROUTE_AUDIO_PLAYER`인데 서비스가 이미 종료되어 미니 플레이어/controller를 연결할 수 없으면 player fragment 표시를 생략하고 일반 메인 진입으로 둔다. +- `EXTRA_CHAT_FILTER`가 있는 `MainV2Activity` intent는 기존 채팅 탭 초기 필터 동작을 우선 유지한다. +- `Constants.EXTRA_DATA` 딥링크 bundle이 있는 경우에는 알림 route와 충돌하지 않도록 알림 route 처리 후에만 딥링크를 실행한다. +- 비로그인 상태에서 알림을 터치하면 기존 로그인 유도 정책을 따른다. 상세페이지 직접 이동은 로그인 이후 처리 대상이며, 이번 범위에서 신규 deferred route 저장소는 만들지 않는다. + +--- + +## 8. UX / UI Expectations +- 미니 플레이어는 `activity_main_v2.xml`에서 이미 정의된 위치처럼 bottom navigation 바로 위에 표시된다. +- 미니 플레이어가 보이면 `fl_container`는 미니 플레이어 위쪽까지만 차지해 하단 UI와 겹치지 않는다. +- Notification Shade에서 알림을 터치하면 사용자가 기대하는 현재 오디오 맥락으로 즉시 복귀한다. + - 새 `AudioContentPlayerService` 알림: 플레이어 바텀시트. + - 기존 `AudioContentPlayService` 알림: 오디오 상세페이지. +- 알림 액션 버튼 터치와 알림 본문 터치의 동작은 구분된다. + +--- + +## 9. Technical Constraints +- Android Kotlin, XML View, ViewBinding, Media3 `MediaController`/`MediaSession` 구조를 유지한다. +- `MainV2Activity`의 기존 bottom navigation, chat filter, deeplink 처리 순서를 깨지 않는다. +- 신규 `Activity`, `Fragment`, `ViewModel`은 만들지 않는다. +- 신규 route 상수는 구현 시 `MainV2Activity` companion object 또는 v2 main 하위의 좁은 범위에 둔다. +- 서비스 pending intent에서 참조해야 하므로 `MainV2Activity`에 둘 경우 `private`이 아닌 `const val`로 정의한다. +- `AudioContentPlayerService`, `AudioContentPlayService`는 `kr.co.vividnext.sodalive.v2` 하위가 아니므로 구현 전에 레거시 파일 수정 필요성을 사용자에게 확인한다. +- 레거시 파일 수정을 승인받지 못하면 알림 터치 진입을 안정적으로 구분할 수 없으므로 Feature B는 보류한다. +- 관련 근거 파일: + - `app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt` + - `app/src/main/res/layout/activity_main.xml` + - `app/src/main/res/layout/activity_main_v2.xml` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerService.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/AudioContentPlayService.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/detail/AudioContentDetailActivity.kt` + - `app/src/main/java/kr/co/vividnext/sodalive/audio_content/player/AudioContentPlayerFragment.kt` + +--- + +## 10. Metrics +- 정량 지표는 이번 범위에 추가하지 않는다. +- 검증 기준은 다음 동작의 수동/정적 확인이다. + - `MainV2Activity`에서 오디오 재생 중 미니 플레이어가 표시된다. + - 미니 플레이어 터치 시 player fragment가 표시된다. + - 새 플레이어 알림 터치 시 player fragment가 표시된다. + - 기존 오디오 알림 터치 시 `AudioContentDetailActivity`로 이동한다. + - 일반 딥링크, 채팅 필터 진입이 회귀하지 않는다. + +--- + +## 11. Open Questions +- 없음. 다만 실제 구현 시 `AudioContentPlayerService`, `AudioContentPlayService` 수정은 레거시 파일 수정에 해당하므로 구현 착수 전에 사용자 승인을 받아야 한다.