연습 녹음 화면 구현#124
Conversation
* **feat: 연습 녹음(Practice Recording) 핵심 로직 구현**
* `PracticeRecordingAudioController`: `MediaRecorder`와 `MediaPlayer`를 사용한 음성 녹음 및 재생 제어 클래스 추가.
* `PracticeRecordingViewModel`: 녹음 상태(IDLE, RECORDING, RECORDED, PLAYING) 관리 및 타이머 로직 구현.
* `PracticeRecordingUiState` 및 `Contract`: MVI 패턴 기반의 상태, 의향(Intent), 효과(Effect) 정의.
* **feat: 연습 녹음 UI 컴포넌트 및 화면 추가**
* `PracticeRecordingScreen`: 권한 체크 로직을 포함한 연습 녹음 메인 화면 구현.
* `PracticeRecordingControl`: 녹음/정지/재생 상태에 따른 아이콘 및 타이머 텍스트 표시 컴포넌트 추가.
* `PracticeScriptCard`: 화면 중앙에 연습용 스크립트를 표시하는 카드 컴포넌트 추가.
* `strings.xml`: 연습 녹음 관련 타이틀, 안내 문구 및 랜덤 연습용 스크립트 배열 추가.
* **feat: 홈 화면 연동 및 네비게이션 설정**
* `HomeScreen`: 바텀 시트 내 '연습하기' 버튼을 추가하여 연습 녹음 화면으로의 네비게이션 연동.
* `PresentationSheet` & `EmptyPresentationSheet`: '연습하기' 버튼 UI 및 클릭 이벤트 리스너 추가.
* `HomeEntryBuilder`: `PracticeRecordingNavKey`를 통한 화면 진입점 정의.
* `PrezelAppState`: 앱 시작 화면을 `SplashNavKey`에서 `HomeNavKey`로 임시 변경.
* **build: 권한 설정 추가**
* `AndroidManifest.xml`: 오디오 녹음을 위한 `RECORD_AUDIO` 권한 추가.
* feat: 연습 녹음 분석 단계별 UI 컴포넌트 추가
* `PracticeRecordingAnalysisLoadingPage`: 분석 중임을 나타내는 Lottie 애니메이션 및 문구 표시
* `PracticeRecordingAnalysisErrorPage`: 분석 실패 또는 음성 인식 오류 시 재시도 안내 화면 구현
* `PracticeRecordingAnalysisSuccessPage`: 발음 점수 및 속도 분석 결과를 카드 형태의 시각적 요소와 함께 표시
* feat: `PracticeRecordingViewModel` 및 `UiState` 내 분석 로직 연동
* `PracticeRecordingAnalysisStatus`: 분석 상태(Ready, Loading, Success, Error)를 관리하는 Sealed Interface 추가
* `ClickAnalyze` 인텐트 처리 및 가상의 로딩 지연(3초) 후 결과 화면 전환 로직 구현
* refactor: `PracticeRecordingScreen` 구조 개선
* 분석 상태에 따라 상단 바와 메인 컨텐츠 영역이 전환되도록 화면 흐름 분리
* 분석 완료 시 홈으로 이동할 수 있도록 `navigateToHome` 내비게이션 콜백 추가
* docs: 분석 결과 표시를 위한 리소스 추가
* 성공 결과 등급별 카드 이미지(`perfect`, `good`, `try`) 및 오류 아이콘 벡터 추가
* 분석 관련 다국어 문자열(발화 점수, 속도 상태 등) 추가
* **feat: 녹음 및 재생 시 예외 처리 로직 추가**
* `PracticeRecordingAudioController`의 주요 메서드(`startRecording`, `stopRecording`, `startPlayback`) 반환 타입을 `Result`로 변경하여 오류 상황을 캡처하도록 개선했습니다.
* 녹음 시작 실패 시 사용자에게 알림을 주기 위한 `PracticeRecordingUiMessage` 및 `PracticeRecordingUiEffect.ShowMessage`를 추가했습니다.
* `LaunchedEffect`를 통해 스낵바(`showPrezelSnackbar`)로 녹음 실패 메시지를 출력하는 로직을 구현했습니다.
* **refactor: `PracticeRecordingUiState` 구조 개선**
* 기존 `PracticeRecordingPhase` 열거형을 제거하고, 상태별 데이터를 포함할 수 있는 `PracticeRecordingState` sealed interface로 개편했습니다. (`Idle`, `Recording`, `Recorded`, `Playing`)
* 상태 전이에 따른 `currentSeconds` 및 `totalSeconds` 계산 로직을 도메인 모델 내부로 캡슐화했습니다.
* **refactor: `PracticeRecordingViewModel` 로직 고도화**
* `PracticeRecordingState` 개편에 맞춰 녹음/재생 제어 및 타이머 로직을 리팩터링했습니다.
* 오디오 컨트롤러의 `Result` 반환값에 따라 성공/실패 처리를 명시적으로 수행하며, 실패 시 에러 상태(`PracticeRecordingAnalysisStatus.Error`)로 전환하도록 보완했습니다.
* 상수를 사용하여 지연 시간(`TIMER_DELAY_MILLIS` 등)을 체계적으로 관리합니다.
* **etc: 리소스 및 컴포넌트 업데이트**
* 녹음 실패 관련 문자열 리소스를 추가했습니다.
* `PracticeRecordingAnalysisErrorType`의 명칭을 보다 명확하게 변경했습니다 (`ANALYZE` -> `ANALYSIS_FAILED`, `VOICE` -> `VOICE_RECOGNITION_FAILED`).
* **refactor: 연습 녹음 관련 컴포넌트 및 로직 패키지 구조 재구성**
* 분석 관련 컴포넌트(`PracticeRecordingAnalysisPages`, `PracticeRecordingAnalysisSuccessPage`)를 `practice.analysis.component` 패키지로 이동했습니다.
* 오디오 제어 로직(`PracticeRecordingAudioController`)을 `practice.audio` 패키지로 이동했습니다.
* 패키지 이동에 따른 `PracticeRecordingViewModel` 및 관련 클래스의 임포트 경로를 수정했습니다.
* **feat: PracticeRecordingAnalysisScreen 분리 및 UI 로직 개선**
* `PracticeRecordingScreen`에 포함되어 있던 분석 상태별 UI 분기 로직을 별도의 `PracticeRecordingAnalysisScreen` 컴포넌트로 추출했습니다.
* `PracticeRecordingScreen`에서 권한 요청 및 녹음 제어 클릭 핸들러를 `remember` API를 사용해 최적화하고 가독성을 높였습니다.
* **refactor: 도메인 모델 및 확장 함수 정리**
* `PracticeRecordingState`를 `PracticeRecordingControlState`로 변환하는 `toControlState` 확장 함수를 `PracticeRecordingControl.kt`로 이동하여 응집도를 높였습니다.
* `PracticeRecordingScreen` 내부에서 사용하던 스크립트 선택 및 권한 상태 관리 로직을 전용 `remember` 함수(`rememberPracticeScript`, `rememberRecordAudioPermissionState`)로 분리했습니다.
* **refactor: 분석 결과 관련 화면 패키지 이동 및 명칭 변경**
* `practice.analysis` 패키지를 `practice.result`로 변경하고 관련 컴포넌트들을 이동했습니다.
* `PracticeRecordingAnalysisScreen`을 `PracticeRecordingResultScreen`으로 변경했습니다.
* `PracticeRecordingAnalysisSuccessPage`를 `PracticeRecordingResultPage`로 변경했습니다.
* `PracticeRecordingAnalysisErrorPage`를 `PracticeRecordingAnalysisFailurePage`로 변경했습니다.
* **refactor: 연습 녹음 화면(PracticeRecordingScreen) UI 구조 개선**
* 별도 파일로 분리되어 있던 `PracticeRecordingButtonArea`를 제거하고 `PracticeRecordingScreen` 내부에 `PrezelButtonArea`를 직접 구현하여 구조를 단순화했습니다.
* `PracticeScriptCard` 컴포넌트를 제거하고 `PracticeRecordingContent` 내부에 인라인 `Box` 형태로 통합했습니다.
* **refactor: 분석 로딩 화면 및 컴포넌트 정리**
* 사용되지 않는 `PracticeRecordingAnalysisPages.kt` 파일을 삭제하고 필요한 로딩 페이지 로직을 `practice.result` 패키지로 재배치했습니다.
* **feat: 연습(Practice) 관련 도메인 모델 및 Repository 추가**
* 연습 스크립트(`PracticeScript`), 녹음 업로드(`PracticeRecordingUpload`), 분석 결과(`PracticeRecordingAnalysisResult`)를 정의하는 도메인 모델을 추가했습니다.
* `PracticeRepository` 인터페이스를 정의하고, 가짜 데이터(Fake Data)를 반환하는 `PracticeRepositoryImpl`을 구현했습니다.
* **feat: 연습 관련 UseCase 구현 및 DI 설정**
* `FetchPracticeScriptUseCase`, `UploadPracticeRecordingUseCase`, `FetchPracticeRecordingAnalysisResultUseCase`를 추가하여 비즈니스 로직을 분리했습니다.
* `RepositoryModule`에 `PracticeRepository` 의존성 주입 설정을 추가했습니다.
* **feat: 연습 녹음 화면(PracticeRecordingScreen) 기능 확장**
* 기존에 로컬 리소스에서 랜덤하게 가져오던 연습 스크립트를 서버(UseCase)에서 불러오도록 변경했습니다.
* 녹음 완료 후 '분석하기' 클릭 시 실제 녹음 파일을 업로드하고 분석 결과를 조회하는 흐름을 구현했습니다.
* 녹음 중단 실패 및 재생 실패에 대한 예외 처리와 스낵바 메시지(`PracticeRecordingUiMessage`)를 추가했습니다.
* **refactor: UI 상태 및 결과 화면 연동**
* `PracticeRecordingUiState`에 `practiceScript`를 추가하고, `Success` 상태에 분석 결과 모델을 포함하도록 수정했습니다.
* `PracticeRecordingResultScreen`에서 실제 분석 데이터(발음 점수, 속도)를 표시하도록 연동했습니다.
* `PracticeRecordingContent` 레이아웃이 화면 전체 높이를 적절히 활용하도록 수정했습니다.
* **build: feature:home:impl 모듈 의존성 추가**
* `core:domain` 모듈 의존성을 추가하여 UseCase를 사용할 수 있도록 설정했습니다.
* **feat: 마이크 권한 관리 로직 분리 및 고도화**
* 권한 요청 및 상태 관리를 위한 `RecordAudioPermission.kt`를 추가하여 관련 로직을 모듈화했습니다.
* `rememberRecordAudioPermissionControlClickHandler`를 통해 녹음 버튼 클릭 시 권한 상태(허용, 거부, 영구 거부)에 따른 분기 처리를 구현했습니다.
* 일반 거부와 영구 거부(`shouldShowRequestPermissionRationale`)를 구분하여 각각 다른 UI 피드백을 제공하도록 개선했습니다.
* **feat: 녹음 화면 UI 컴포넌트 정리 및 기능 추가**
* `PracticeRecordingTopAppBar`를 별도 파일로 분리하여 코드 가독성을 높였습니다.
* 대본 로드 실패, 권한 거부 등에 대한 새로운 에러 메시지(`PracticeRecordingUiMessage`)와 관련 문자열 리소스를 추가했습니다.
* 스낵바 표시 시 `collectLatest`와 `dismiss()`를 사용하여 이전 메시지를 정리하고 최신 상태를 즉시 반영하도록 수정했습니다.
* **refactor: PracticeRecordingViewModel 비즈니스 로직 보강**
* `fetchPracticeScript` 로직을 개선하여 데이터 로드 실패 시 에러 효과(Effect)를 발생시키도록 수정했습니다.
* 권한 거부 상황을 처리하기 위한 `DenyRecordAudioPermission`, `DenyRecordAudioPermissionPermanently` Intent를 추가했습니다.
* 내부 메서드 명칭 변경 (`sendMessage` -> `showMessage`) 및 전반적인 예외 처리 로직을 강화했습니다.
* **build: 관련 리소스 추가**
* 마이크 권한 거부 및 대본 로드 실패 안내를 위한 다국어 문자열을 `strings.xml`에 추가했습니다.
* docs: 연습 관련 UseCase 클래스에 KDoc 주석 추가 `core:domain` 모듈 내 연습(Practice) 기능과 관련된 UseCase 클래스들에 동작 흐름과 역할을 설명하는 한글 주석을 추가했습니다. * `FetchPracticeRecordingAnalysisResultUseCase`: 녹음본 ID를 기반으로 분석 결과를 조회하는 로직 설명 추가 * `UploadPracticeRecordingUseCase`: 녹음본 파일 업로드 요청 처리 로직 설명 추가 * `FetchPracticeScriptUseCase`: 연습용 대본 조회 요청 처리 로직 설명 추가
📝 WalkthroughWalkthrough이 PR은 사용자가 연습 스크립트를 녹음하고, 재생한 후, AI 기반 분석 결과를 받는 전체 연습 녹음 기능을 구현합니다. 오디오 컨트롤러, 도메인 모델, UI 상태 관리, 녹음/분석 화면 구성요소, 그리고 홈 네비게이션 통합을 포함합니다. ChangesPractice Recording Feature
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@Prezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.kt`:
- Around line 12-31: The three suspend methods fetchPracticeScript,
uploadPracticeRecording, and fetchPracticeRecordingAnalysisResult currently wrap
delay(...) inside runCatching which swallows CancellationException; update each
to rethrow CancellationException instead of returning a failure Result — either
append .onFailure { if (it is CancellationException) throw it } to the
runCatching chain for each method or replace runCatching with a try/catch that
rethrows CancellationException and converts other exceptions into
Result.failure(...), ensuring proper cancellation propagation.
In
`@Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt`:
- Around line 111-114: releaseRecorder()가 recorder만 해제하고 recordingStartedAt을
초기화하지 않아 재시도나 중복 stop 시 이전 세션 타이머가 남을 수 있습니다; releaseRecorder()에서
recorder?.release(); recorder = null과 함께 recordingStartedAt도 null/초기값으로 재설정(예:
recordingStartedAt = 0L 또는 null)하여 타이머 기준값을 공통 정리 경로에서 함께 비우도록 수정하세요.
In
`@Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.kt`:
- Around line 43-58: The script Box (in PracticeRecordingContent.kt) should be
made vertically scrollable so long scripts don't get clipped; add a scroll state
and verticalScroll to the Box's modifier chain (use rememberScrollState() and
Modifier.verticalScroll(...)) and adjust content alignment to top center if
needed so text starts at the top; keep the existing styling (fillMaxWidth,
weight(1f), clip, background, padding) and apply the verticalScroll to that same
Modifier before calling Text(practiceScript).
In
`@Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt`:
- Around line 52-67: The two LaunchedEffect(Unit) blocks in
PracticeRecordingScreen are racing: the intent is sent via
viewModel.onIntent(PracticeRecordingUiIntent.LoadPracticeScript) before
viewModel.uiEffect.collectLatest is guaranteed to be collecting, risking lost
channel-based effects; consolidate into a single LaunchedEffect or reorder so
collectLatest on viewModel.uiEffect is started first, then call
viewModel.onIntent(PracticeRecordingUiIntent.LoadPracticeScript) afterwards,
ensuring snackbarHostState.currentSnackbarData?.dismiss() and
snackbarHostState.showPrezelSnackbar(...) are handled inside the collector
(viewModel.uiEffect.collectLatest) and not run prior to collection.
In
`@Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt`:
- Around line 33-100: The returned RecordAudioPermissionRequest instance is
being memoized with remember in rememberRecordAudioPermissionRequest, which
freezes currentOnPermissionPermanentlyDenied to its initial value; remove the
outer remember so a fresh RecordAudioPermissionRequest is created on each
recomposition (still use rememberUpdatedState for
currentOnPermissionPermanentlyDenied and keep the launcher local), ensuring the
onPermanentlyDenied field of the returned RecordAudioPermissionRequest
references the latest currentOnPermissionPermanentlyDenied rather than a stale
captured value.
In `@Prezel/feature/home/impl/src/main/res/values/strings.xml`:
- Around line 44-46: Update the three English string resources used for image
content descriptions to Korean/descriptive phrases: change
feature_home_impl_practice_recording_analysis_card_perfect,
feature_home_impl_practice_recording_analysis_card_good, and
feature_home_impl_practice_recording_analysis_card_try to appropriate Korean
accessibility texts (e.g., “완벽해요”, “잘했어요”, “다음에 또 도전해 보세요” or similar), ensuring
they remain concise and suitable for contentDescription usage in the UI.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 380f4233-c833-48cf-b0dd-6e3865f3d063
📒 Files selected for processing (38)
Prezel/app/src/main/AndroidManifest.xmlPrezel/app/src/main/java/com/team/prezel/ui/PrezelAppState.ktPrezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.ktPrezel/core/data/src/main/java/com/team/prezel/core/data/repository/PracticeRepositoryImpl.ktPrezel/core/domain/src/main/java/com/team/prezel/core/domain/repository/practice/PracticeRepository.ktPrezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeRecordingAnalysisResultUseCase.ktPrezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/FetchPracticeScriptUseCase.ktPrezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/UploadPracticeRecordingUseCase.ktPrezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingAnalysisResult.ktPrezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeRecordingUpload.ktPrezel/core/model/src/main/java/com/team/prezel/core/model/practice/PracticeScript.ktPrezel/core/ui/src/main/res/drawable/core_ui_error_analyze.xmlPrezel/core/ui/src/main/res/drawable/core_ui_error_voice.xmlPrezel/feature/home/impl/build.gradle.ktsPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/HomeScreen.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/EmptyPresentationSheet.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/component/body/PresentationSheet.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/HomeEntryBuilder.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/navigation/PracticeRecordingNavKey.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingContent.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingTopAppBar.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiEffect.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingUiMessage.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisLoadingPage.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingResultPage.ktPrezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_good.xmlPrezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_perfect.xmlPrezel/feature/home/impl/src/main/res/drawable/feature_home_impl_card_try.xmlPrezel/feature/home/impl/src/main/res/values/strings.xml
* **refactor: `RecordAudioPermission` 관련 Compose 코드 최적화**
* 불필요한 `remember` 및 `rememberUpdatedState`를 제거하여 권한 요청 람다와 `RecordAudioPermissionRequest` 생성 로직을 단순화했습니다.
* 상태 변경에 따른 불필요한 리컴포지션 오버헤드를 줄였습니다.
* **fix: `PracticeRecordingAudioController` 리소스 해제 로직 보완**
* `releaseRecorder()` 호출 시 `recordingStartedAt` 시간을 0으로 초기화하도록 수정하여, 녹음기 해제 후 발생할 수 있는 시간 계산 오류를 방지했습니다.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt`:
- Line 18: recordingFile 변수에 할당된 이전 temp .m4a 파일이 제거되지 않아 cacheDir에 고아 파일이 쌓입니다;
새 녹음 파일을 생성하는 코드(예: startRecording / createTempRecordingFile 같은 곳)에서 새 파일을 확정하거나
대체할 때 이전 recordingFile이 가리키던 파일을 삭제하도록 추가하고, 녹음 취소/확정/release() 경로 모두에서
recordingFile을 삭제(파일.delete())하고 recordingFile = null로 초기화하도록 구현하세요; 참조할 심볼:
recordingFile, 녹음 시작/종료 메서드(예: startRecording/stopRecording/confirmRecording)와
release()를 찾아 이전파일 정리 로직을 삽입하면 됩니다.
- Around line 50-58: stopRecording() currently computes duration from
recordingStartedAt and returns a success even when there is no active recording;
guard the method by first checking that recorder is non-null and
recordingStartedAt is a valid start timestamp (e.g., > 0) and if not return a
Result.failure(IllegalStateException("not recording")) instead of proceeding;
when valid, compute durationSeconds from recordingStartedAt, call
recorder.stop() inside runCatching, return Result.success(max(durationSeconds,
0)), and ensure releaseRecorder() still runs (e.g., keep the existing
finally/also behavior) so cleanup always happens; reference symbols:
stopRecording(), recorder, recordingStartedAt, releaseRecorder(),
recorder.stop().
In
`@Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.kt`:
- Around line 56-66: The composable's hasRecordAudioPermission and
isPermanentlyDenied are only set once and can become stale when the user changes
permission in system Settings; add a lifecycle observer (e.g., DisposableEffect
+ LifecycleEventObserver on LocalActivity.current.lifecycle) to re-check
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) and
ActivityCompat.shouldShowRequestPermissionRationale(activity,
Manifest.permission.RECORD_AUDIO) on ON_RESUME and assign the results back to
hasRecordAudioPermission and isPermanentlyDenied so both states stay
synchronized with the system permission when the activity resumes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 7ba79f57-a036-4386-8c21-68fd0b35ea9a
📒 Files selected for processing (2)
Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/audio/PracticeRecordingAudioController.kt
* **feat: 공통 권한 요청 `rememberPermissionRequest` 구현**
* 기존 `RecordAudioPermission` 내부에 중복되어 있던 권한 요청 로직을 `core:ui` 모듈의 공통 유틸리티 클래스로 추출했습니다.
* `LifecycleEventObserver`를 추가하여 앱이 다시 활성화(`ON_RESUME`)될 때 권한 상태를 자동으로 동기화하도록 개선했습니다.
* 권한 허용 여부, 영구 거부 상태를 추적하고 콜백을 관리하는 `PermissionRequest` 데이터 클래스를 정의했습니다.
* **refactor: `RecordAudioPermission` 리팩터링**
* 새로 구현한 공통 유틸리티 `rememberPermissionRequest`를 사용하도록 기존 코드를 제거하고 구조를 단순화했습니다.
* **fix: `PracticeRecordingAudioController` 리소스 관리 및 예외 처리 강화**
* 새로운 녹음을 시작할 때 이전 임시 파일을 삭제하도록 수정했습니다.
* `stopRecording` 호출 시 녹음기가 비활성 상태인 경우에 대한 예외 처리를 추가했습니다.
* `release` 호출 시 현재 녹음 중인 파일을 명시적으로 삭제하도록 리소스 관리 로직을 보완했습니다.
* **feat: `AnalyzePracticeRecordingUseCase` 및 관련 모델 추가**
* 녹음본 업로드와 분석 결과 조회를 통합 수행하는 `AnalyzePracticeRecordingUseCase`를 추가했습니다.
* UI 레이어에서 사용할 분석 상태(`PracticeRecordingAnalysisStatus`) 및 모델(`PracticeRecordingAnalysisUiModel`)을 정의했습니다.
* `PracticeRecordingState` 및 분석 관련 모델들을 별도 패키지(`model`)로 분리하여 구조를 개선했습니다.
* **feat: `RecordingAudioController` 추상화 및 구현**
* 오디오 녹음 및 재생 로직을 인터페이스(`RecordingAudioController`)로 추상화했습니다.
* `MediaRecorder`와 `MediaPlayer`를 사용하는 `MediaRecordingAudioController` 구현체를 추가하고, Hilt를 이용한 의존성 주입(`RecordingAudioModule`)을 설정했습니다.
* **refactor: `PracticeRecordingViewModel` 로직 개선**
* 녹음 분석 시 `AnalyzePracticeRecordingUseCase`를 사용하도록 변경했습니다.
* Intent 명칭을 보다 명확하게 변경했습니다. (`ClickControl` -> `ToggleRecordingControl`, `ClickAnalyze` -> `AnalyzeClicked` 등)
* 분석 시작 시 재생 중인 오디오를 중지하는 로직을 추가했습니다.
* **refactor: `PracticeRepositoryImpl` 위치 변경 및 패키징 정리**
* `PracticeRepositoryImpl`을 `repository.practice` 패키지로 이동하여 일관성을 높였습니다.
* **ui: 분석 결과 화면 및 권한 처리 연동**
* 도메인 모델(`PracticeRecordingSpeed`) 대신 UI 모델(`PracticeRecordingAnalysisSpeed`)을 사용하도록 결과 화면을 수정했습니다.
* 오디오 권한 거부 관련 Intent 처리 로직을 갱신된 명칭에 맞게 수정했습니다.
* **feat: `core:audio` 모듈 신설 및 오디오 제어 로직 이동**
* 기존 `feature:home:impl` 내에 위치하던 오디오 녹음 및 재생 관련 로직을 공통 모듈인 `core:audio`로 추출했습니다.
* `RecordingAudioController`: 인터페이스를 `core:audio`로 이동하고 외부 접근이 가능하도록 `public`으로 변경했습니다.
* `MediaRecordingAudioController`: `MediaRecorder`와 `MediaPlayer`를 사용하는 구현체를 이동했습니다.
* `RecordingAudioModule`: Hilt를 이용한 의존성 주입 설정을 이동했습니다.
* `feature:home:impl`에서 `core:audio` 모듈을 의존성에 추가하고 관련 import를 수정했습니다.
* **refactor: PermissionRequest 유틸리티 코드 정리**
* `core:ui` 모듈의 `PermissionRequest` 내 중복되는 권한 확인 로직을 확장 함수(`isPermissionGranted`, `isPermissionPermanentlyDenied`)로 추출하여 가독성을 높였습니다.
* 권한 결과 처리 로직(when 문 사용) 및 상태 동기화 로직을 간결하게 개선했습니다.
* **build: 프로젝트 설정 업데이트**
* `settings.gradle.kts`에 `:core:audio` 모듈을 추가했습니다.
* `core:audio` 모듈의 `build.gradle.kts`를 정의하고 필요한 플러그인을 설정했습니다.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt`:
- Around line 75-93: There is a race between assigning the shared field player
and calling start() in startPlayback(): assign the new MediaPlayer to the player
field before calling start(), and ensure you can roll back on failure by storing
the previous player reference, setting pendingPlayer/player to the new instance
first, and if any exception occurs release the new instance and restore the
previous player; also make the OnCompletionListener capture the specific player
instance (not rely on a mutable field) so stopPlayback() operates on the correct
player.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 140cdf19-b3be-4bc1-bc3c-ed1239fd9e7b
📒 Files selected for processing (21)
Prezel/core/audio/build.gradle.ktsPrezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.ktPrezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.ktPrezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioModule.ktPrezel/core/data/src/main/java/com/team/prezel/core/data/di/RepositoryModule.ktPrezel/core/data/src/main/java/com/team/prezel/core/data/repository/practice/PracticeRepositoryImpl.ktPrezel/core/domain/src/main/java/com/team/prezel/core/domain/usecase/practice/AnalyzePracticeRecordingUseCase.ktPrezel/core/ui/src/main/java/com/team/prezel/core/ui/util/PermissionRequest.ktPrezel/feature/home/impl/build.gradle.ktsPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingViewModel.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/RecordAudioPermission.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiState.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingState.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.ktPrezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.ktPrezel/settings.gradle.kts
✅ Files skipped from review due to trivial changes (6)
- Prezel/feature/home/impl/build.gradle.kts
- Prezel/core/audio/build.gradle.kts
- Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisUiModel.kt
- Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/model/PracticeRecordingAnalysisStatus.kt
- Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/component/PracticeRecordingControl.kt
- Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/PracticeRecordingResultScreen.kt
🚧 Files skipped from review as they are similar to previous changes (3)
- Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/contract/PracticeRecordingUiIntent.kt
- Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/PracticeRecordingScreen.kt
- Prezel/feature/home/impl/src/main/java/com/team/prezel/feature/home/impl/practice/result/component/PracticeRecordingAnalysisFailurePage.kt
| val newPlayer = runCatching { | ||
| val mediaPlayer = MediaPlayer() | ||
| pendingPlayer = mediaPlayer | ||
| mediaPlayer.apply { | ||
| setDataSource(filePath) | ||
| prepare() | ||
| setOnCompletionListener { | ||
| stopPlayback() | ||
| onComplete() | ||
| } | ||
| start() | ||
| } | ||
| }.getOrElse { throwable -> | ||
| pendingPlayer?.release() | ||
| throw throwable | ||
| } | ||
|
|
||
| player = newPlayer | ||
| newPlayer.duration.toSeconds() |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Android MediaPlayer에서 아주 짧은 오디오의 경우 start() 직후 OnCompletionListener가 즉시 호출될 수 있는지, 그리고 공유 player 참조는 start() 전에 할당하는 것이 권장되는지 공식 문서/신뢰 가능한 자료로 확인해 주세요.
💡 Result:
네, Android MediaPlayer에서 아주 짧은 오디오 파일의 경우 start 직후 OnCompletionListener가 즉시 호출될 수 있습니다. 이는 오디오 길이가 매우 짧아 재생이 빠르게 완료되기 때문이며, 공식 문서에서 OnCompletionListener가 "playback of a media source has completed" 시 호출된다고 명시되어 있습니다 (MediaPlayer.OnCompletionListener 문서). 짧은 사운드 효과에서 흔히 발생하며, seekTo나 에러(-38)가 원인일 수 있지만 정상 동작입니다. StackOverflow 등에서 short sound에서 "immediately" 호출되는 사례 다수 보고. 공유 player 참조는 start 전에 클래스 멤버로 할당하는 것이 권장됩니다. 로컬 변수로 하면 GC로 인해 OnCompletionListener가 호출되지 않을 수 있으며, Android Developers의 MediaPlayer Best Practices (Medium article by Nicole Borrelli)에서 MediaPlayer 인스턴스를 재사용하고 멤버로 유지하라고 권고합니다. start 전에 setOnCompletionListener 설정 후 prepare/start 하는 것이 표준입니다.
Citations:
- 1: https://developer.android.com/reference/android/media/MediaPlayer.OnCompletionListener
- 2: https://stackoverflow.com/questions/9998677/cannot-get-android-mediaplayer-oncompletion-to-fire
- 3: https://stackoverflow.com/questions/9998677/cannot-get-android-mediaplayer-oncompletion-to-fire/19555480
- 4: https://medium.com/androiddevelopers/deep-dive-mediaplayer-best-practices-feb4d15a66f5
- 5: https://stackoverflow.com/questions/17229041/mediaplayer-oncompletion-is-not-being-called-accurately
- 6: https://developer.android.com/reference/kotlin/android/media/MediaPlayer
- 7: https://developer.android.com/media/platform/mediaplayer/state-resources
startPlayback()에서 완료 콜백과 player 필드 할당 순서의 레이스 조건이 있습니다.
매우 짧은 오디오는 start() 직후 OnCompletionListener가 즉시 호출될 수 있습니다(Android 공식 문서 확인됨). 콜백이 먼저 실행되면 stopPlayback()이 아직 이전 player 값을 참조하게 되어 리소스 누수 또는 상태 역전이 발생합니다. player를 start() 전에 할당하고 실패 시 롤백하세요.
🔧 제안 수정
override fun startPlayback(
filePath: String,
onComplete: () -> Unit,
): Result<Int> =
runCatching {
stopPlayback()
var pendingPlayer: MediaPlayer? = null
val newPlayer = runCatching {
val mediaPlayer = MediaPlayer()
pendingPlayer = mediaPlayer
- mediaPlayer.apply {
- setDataSource(filePath)
- prepare()
- setOnCompletionListener {
- stopPlayback()
- onComplete()
- }
- start()
- }
+ mediaPlayer.setDataSource(filePath)
+ mediaPlayer.prepare()
+ mediaPlayer.setOnCompletionListener {
+ stopPlayback()
+ onComplete()
+ }
+ player = mediaPlayer
+ mediaPlayer.start()
+ mediaPlayer
}.getOrElse { throwable ->
+ if (player === pendingPlayer) player = null
pendingPlayer?.release()
throw throwable
}
- player = newPlayer
newPlayer.duration.toSeconds()
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt`
around lines 75 - 93, There is a race between assigning the shared field player
and calling start() in startPlayback(): assign the new MediaPlayer to the player
field before calling start(), and ensure you can roll back on failure by storing
the previous player reference, setting pendingPlayer/player to the new instance
first, and if any exception occurs release the new instance and restore the
previous player; also make the OnCompletionListener capture the specific player
instance (not rely on a mutable field) so stopPlayback() operates on the correct
player.
📌 작업 내용
연습 녹음 화면을 추가하고 홈 화면에서 진입할 수 있도록 네비게이션을 연결했습니다.
마이크 권한 요청, 녹음 시작/중지, 녹음 파일 재생/정지, 녹음 시간 표시 흐름을 구현했습니다.
녹음 분석 요청 흐름을 추가했습니다.
분석 결과 화면을 추가했습니다.
녹음/재생 로직을
core-audio모듈로 분리했습니다.RecordingAudioControllerMediaRecordingAudioController공통 권한 요청 유틸
rememberPermissionRequest를 추가했습니다.🧩 관련 이슈
📸 스크린샷
2026-05-02.18.58.38.mov
📢 논의하고 싶은 내용
Summary by CodeRabbit
릴리스 노트
새로운 기능
수정