From da90efcbb9af053e2df520ab4462202654a0cbbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sat, 30 May 2026 11:14:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EB=AF=B8=EB=94=94=EC=96=B4=20?= =?UTF-8?q?=EC=BA=A1=EC=B2=98=20=EB=B0=8F=20=EC=84=A0=ED=83=9D=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=EC=9C=84=ED=95=9C=20FileProvider=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사진 및 영상 촬영 시 파일 공유를 위한 `FileProvider`를 `AndroidManifest.xml`에 등록 - 캡처된 미디어 저장 경로(`captured_media/`)를 정의하는 `afternote_file_paths.xml` 추가 - 미디어 소스 선택(갤러리, 사진 촬영, 영상 촬영)에 필요한 문자열 리소스 추가 - `androidx.core.ktx` 의존성 추가 및 관련 매니페스트 설정 업데이트 --- feature/afternote/presentation/build.gradle.kts | 1 + .../presentation/src/main/AndroidManifest.xml | 16 ++++++++++++++-- .../presentation/src/main/res/values/strings.xml | 3 +++ .../src/main/res/xml/afternote_file_paths.xml | 6 ++++++ 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 feature/afternote/presentation/src/main/res/xml/afternote_file_paths.xml diff --git a/feature/afternote/presentation/build.gradle.kts b/feature/afternote/presentation/build.gradle.kts index cf76e402b4..2e1267bfc0 100644 --- a/feature/afternote/presentation/build.gradle.kts +++ b/feature/afternote/presentation/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.paging.runtime) implementation(libs.androidx.paging.compose) + implementation(libs.androidx.core.ktx) testImplementation(libs.coroutines.test) testImplementation(libs.robolectric) diff --git a/feature/afternote/presentation/src/main/AndroidManifest.xml b/feature/afternote/presentation/src/main/AndroidManifest.xml index 44008a4332..fe1cbb4ac3 100644 --- a/feature/afternote/presentation/src/main/AndroidManifest.xml +++ b/feature/afternote/presentation/src/main/AndroidManifest.xml @@ -1,4 +1,16 @@ - + - \ No newline at end of file + + + + + + + diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index c3b0149bff..428d4c11dd 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -317,4 +317,7 @@ 작성 중인 내용이 사라집니다.\n정말 나가시겠어요? 나가기 계속 작성 + 갤러리에서 선택 + 사진 촬영 + 영상 촬영 diff --git a/feature/afternote/presentation/src/main/res/xml/afternote_file_paths.xml b/feature/afternote/presentation/src/main/res/xml/afternote_file_paths.xml new file mode 100644 index 0000000000..74b9064f27 --- /dev/null +++ b/feature/afternote/presentation/src/main/res/xml/afternote_file_paths.xml @@ -0,0 +1,6 @@ + + + + From 1926c80d0514dffe9671a8ac73ae275417d1586b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 13:35:41 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(afternote):=20=EC=B6=94=EC=96=B5=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=82=AC=EC=A7=84=C2=B7=EC=98=81=EC=83=81?= =?UTF-8?q?=20=EC=A6=89=EC=84=9D=20=EC=B4=AC=EC=98=81=20=EC=9D=B8=ED=85=90?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 영정사진·장례식 영상 슬롯이 `PickVisualMedia` 만 물고 있어 *이미 찍어 둔* 미디어만 붙일 수 있었다. 슬롯과 갤러리 사이에 소스 선택 시트를 끼워 촬영 경로를 연다. - `MemorialMediaSourceBottomSheet` — "갤러리에서 선택 / 사진(영상) 촬영" 2 옵션. 시각 규격은 수신자 모듈 `DocumentSourceBottomSheet` 와 같은 패턴을 따랐다. 이 슬롯용 시안이 따로 없어, 디자이너가 다른 화면에 이미 그려 둔 "미디어 추가하기" 시트에 맞춘다. - `MemorialMediaSourceState` — 시트 표시 여부를 대상 슬롯 하나로 표현해 "떠 있는 슬롯" 과 "결과를 받을 슬롯" 이 어긋날 수 없게 두고, 런처 4종(갤러리 2·촬영 2)을 여기 모은다. - `createMemorialCaptureUri` — 결과 파일을 캐시에 만들고 `FileProvider` URI 로 감싼다. 확장자를 박아 두어야 업로드 경로의 `ContentResolver.getType()` 이 종류를 알아본다. 심볼명은 기존 `MEMORIAL` 축(`MemorialPhotoUpload`·`MemorialEditorContent`)을 그대로 따르고, 한글 산문만 사용자 노출명인 "추억 노트"(`afternote_category_memorial`)에 맞춘다. 도메인·서버 계약은 건드리지 않는다. 촬영 URI 도 `content://` 라 기존 `MediaInput.Local` 분기를 그대로 타고, 영상 썸네일 추출도 같은 조건으로 걸린다. 카메라 런타임 권한은 요청하지 않는다 — 매니페스트에 `CAMERA` 를 선언하지 않은 앱이 촬영 인텐트를 쏠 때 Android 는 권한을 묻지 않는다. 선언하면 그 순간부터 필수가 된다. 촬영 앱에 쓰기 권한이 가는 경로는 플랫폼의 `Intent.migrateExtraStreamToClipData()` 이며, 그것이 합법이 되도록 provider 에 `grantUriPermissions="true"` 를 둔다. 음성 녹음은 이번 범위에서 뺀다. 저장 계약(`memorialPhotoUrl`/`memorialVideo`)에 오디오 자리가 없어 FE 만으로 닫히지 않는다 — Afternote-BE#248 + FE #1118 로 후속을 판다. 검증: - `:feature:afternote:presentation:testDebugUnitTest` — 신규 7건 포함 전량 통과. FileProvider authority·cache-path·코드 상수 3자 조립을 병합 매니페스트로 실측하려 이 모듈에 `unitTests.isIncludeAndroidResources` 를 켰다. - `:app:compileDebugAndroidTestKotlin` · `:app:compileDebugUnitTestKotlin` 통과. - `ktlintCheck` 통과. - docker(amd64) `updateDebugScreenshotTest --rerun` → `validateDebugScreenshotTest` 통과. 신규 baseline 2장, 기존 baseline 변경 0장. --- .../afternote/presentation/build.gradle.kts | 7 + .../author/editor/AfternoteEditorScreen.kt | 36 ++- .../memorial/MemorialMediaCaptureFiles.kt | 49 ++++ .../MemorialMediaSourceBottomSheet.kt | 153 +++++++++++++ .../memorial/MemorialMediaSourceState.kt | 214 ++++++++++++++++++ .../drawable/feature_afternote_ic_camera.xml | 22 ++ .../feature_afternote_ic_videocam.xml | 22 ++ .../src/main/res/values/strings.xml | 2 + ...ialMediaSourceBottomSheetScreenshotTest.kt | 34 +++ ...eBottomSheetPhotoScreenshot_748aa731_0.png | Bin 0 -> 15428 bytes ...eBottomSheetVideoScreenshot_748aa731_0.png | Bin 0 -> 15062 bytes .../memorial/MemorialMediaCaptureFilesTest.kt | 65 ++++++ .../memorial/MemorialMediaSourceStateTest.kt | 76 +++++++ 13 files changed, 658 insertions(+), 22 deletions(-) create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheet.kt create mode 100644 feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt create mode 100644 feature/afternote/presentation/src/main/res/drawable/feature_afternote_ic_camera.xml create mode 100644 feature/afternote/presentation/src/main/res/drawable/feature_afternote_ic_videocam.xml create mode 100644 feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTest.kt create mode 100644 feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetPhotoScreenshot_748aa731_0.png create mode 100644 feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetVideoScreenshot_748aa731_0.png create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt create mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceStateTest.kt diff --git a/feature/afternote/presentation/build.gradle.kts b/feature/afternote/presentation/build.gradle.kts index 2e1267bfc0..cccb878252 100644 --- a/feature/afternote/presentation/build.gradle.kts +++ b/feature/afternote/presentation/build.gradle.kts @@ -11,6 +11,13 @@ android { buildFeatures { buildConfig = true } + testOptions { + unitTests { + // Robolectric 이 이 모듈의 *병합된* 매니페스트를 읽게 한다. 끄면 패키지가 + // `org.robolectric.default` 로 떨어져 FileProvider authority 가 매니페스트와 어긋난다 (#369). + isIncludeAndroidResources = true + } + } } dependencies { diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt index 6b6a683a1b..3a8f698d07 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt @@ -1,10 +1,6 @@ package com.afternote.feature.afternote.presentation.author.editor -import android.net.Uri import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -33,7 +29,10 @@ import com.afternote.core.ui.popup.PopupType import com.afternote.core.ui.theme.AfternoteDesign import com.afternote.core.ui.topbar.DetailTopBar import com.afternote.feature.afternote.presentation.R +import com.afternote.feature.afternote.presentation.author.editor.memorial.MemorialMediaSourceSheet +import com.afternote.feature.afternote.presentation.author.editor.memorial.MemorialMediaTarget import com.afternote.feature.afternote.presentation.author.editor.memorial.playlist.Song +import com.afternote.feature.afternote.presentation.author.editor.memorial.rememberMemorialMediaSourceState import com.afternote.feature.afternote.presentation.author.editor.message.EditorMessageTextBlock import com.afternote.feature.afternote.presentation.author.editor.state.AfternoteEditorState import com.afternote.feature.afternote.presentation.author.editor.state.CategoryForm @@ -154,14 +153,13 @@ fun AfternoteEditorScreen( state.setMemorialPlaylistSongs(liveSongs) } - val memorialPhotoPickerLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> - state.setMemorialPhoto(uri?.toString()) - } - val memorialVideoPickerLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> - state.setMemorialVideo(uri?.toString()) - } + // 슬롯을 누르면 곧장 갤러리가 뜨는 대신 "갤러리에서 선택 / 촬영" 시트를 한 단계 끼운다 (#369). + val mediaSourceState = + rememberMemorialMediaSourceState( + snackbarHostState = snackbarHostState, + onPhotoSelected = state.setMemorialPhoto, + onVideoSelected = state.setMemorialVideo, + ) // 작성 도중 이탈 가드: 진입 시점 스냅샷 대비 변경이 있으면 뒤로가기 시 이탈 확인 팝업을 띄운다. // 입력이 debounce 로 휘발성 폼 상태에만 반영되어 pop 시 소실되기 때문. '내용 존재'가 아니라 '변경' 기준인 @@ -231,22 +229,16 @@ fun AfternoteEditorScreen( isPrefillLoading = isPrefillLoading, onNavigateToMemorialPlaylist = onNavigateToMemorialPlaylist, onNavigateToSelectReceiver = onNavigateToSelectReceiver, - onPhotoAddClick = { - memorialPhotoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), - ) - }, - onVideoAddClick = { - memorialVideoPickerLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly), - ) - }, + onPhotoAddClick = { mediaSourceState.open(MemorialMediaTarget.PHOTO) }, + onVideoAddClick = { mediaSourceState.open(MemorialMediaTarget.VIDEO) }, onThumbnailBytesReady = onThumbnailBytesReady, onThumbnailExtractionFailed = onThumbnailExtractionFailed, ) AfternoteEditorDialogs(state = state) + MemorialMediaSourceSheet(state = mediaSourceState) + if (showExitConfirm) { Popup( type = PopupType.Variant2, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt new file mode 100644 index 0000000000..a8da483c38 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt @@ -0,0 +1,49 @@ +package com.afternote.feature.afternote.presentation.author.editor.memorial + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import java.io.File + +/** + * 촬영 결과가 떨어질 캐시 하위 디렉터리. `afternote_file_paths.xml` 의 `` + * 와 같은 값이어야 [FileProvider] 가 URI 를 만들어 준다 — 다르면 `IllegalArgumentException` 으로 터진다. + */ +private const val CAPTURE_DIR = "captured_media" + +/** 매니페스트의 `android:authorities="${applicationId}.afternote.fileprovider"` 와 짝. */ +private const val AUTHORITY_SUFFIX = ".afternote.fileprovider" + +/** + * 카메라 앱이 결과를 써 넣을 빈 파일을 캐시에 만들고, 그 파일을 가리키는 `content://` URI 를 돌려준다. + * + * 카메라 앱은 다른 프로세스라 `file://` 경로를 넘기면 Android 7+ 에서 `FileUriExposedException` 이 난다. + * [FileProvider] 로 감싸 URI 하나에만 쓰기 권한을 위임한다(`grantUriPermission`). + * + * 확장자를 파일명에 박아 두는 이유: 업로드 경로가 `ContentResolver.getType()` 으로 MIME 을 정하는데, + * [FileProvider] 는 그 MIME 을 파일 확장자에서 역산한다. 확장자가 없으면 `application/octet-stream` 이 + * 되어 presigned 발급 확장자가 기본값으로 떨어진다. + */ +internal fun createMemorialCaptureUri( + context: Context, + extension: String, +): Uri { + val dir = File(context.cacheDir, CAPTURE_DIR) + dir.mkdirs() + val file = File.createTempFile("capture_", ".$extension", dir) + return FileProvider.getUriForFile(context, context.packageName + AUTHORITY_SUFFIX, file) +} + +/** + * 촬영이 취소·실패해 쓰이지 않은 빈 파일을 지운다. + * + * 캐시라 방치해도 OS 가 언젠가 회수하지만, 취소를 반복하면 0바이트 파일이 계속 쌓인다. + * 삭제 실패는 무시한다 — 지우지 못해도 사용자에게 알릴 것이 없다. + */ +internal fun discardMemorialCapture( + context: Context, + uri: Uri?, +) { + if (uri == null) return + runCatching { context.contentResolver.delete(uri, null, null) } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheet.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheet.kt new file mode 100644 index 0000000000..e0e5d0ce58 --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheet.kt @@ -0,0 +1,153 @@ +package com.afternote.feature.afternote.presentation.author.editor.memorial + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.afternote.core.ui.modifierextention.bottomBorder +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.core.ui.theme.AfternoteTheme +import com.afternote.feature.afternote.presentation.R +import com.afternote.core.ui.R as CoreUiR + +/** 추억 노트 미디어 슬롯 종류. 시트 문구와 촬영 인텐트를 함께 가른다. */ +internal enum class MemorialMediaTarget { + /** 영정 사진 — 갤러리 이미지 선택 / 사진 촬영. */ + PHOTO, + + /** 장례식에 남길 영상 — 갤러리 영상 선택 / 영상 촬영. */ + VIDEO, +} + +/** + * 추억 노트 미디어 소스 선택 시트 (#369) — "갤러리에서 선택" / "촬영" 두 갈래. + * + * 이전에는 슬롯을 누르면 곧장 `PickVisualMedia` 가 떠서 *이미 찍어 둔* 사진·영상만 붙일 수 있었다. + * 그 사이에 이 시트를 끼워 즉석 촬영 경로를 연다. + * + * 시각 규격은 수신자 모듈의 `DocumentSourceBottomSheet` 와 같은 패턴을 따른다 — 디자이너가 같은 + * "미디어 추가하기" 시트를 다른 화면에 이미 그려 두었고, 이 슬롯용 시안은 따로 없다. + */ +@Composable +internal fun MemorialMediaSourceBottomSheet( + target: MemorialMediaTarget, + onPickFromGallery: () -> Unit, + onCapture: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(8.dp)) + Box( + modifier = + Modifier + .size(width = 40.dp, height = 4.dp) + .clip(RoundedCornerShape(10.dp)) + .background(AfternoteDesign.colors.gray3), + ) + Spacer(Modifier.height(14.dp)) + Text( + text = stringResource(R.string.afternote_editor_media_source_header), + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray6, + ) + Spacer(Modifier.height(18.dp)) + MemorialMediaSourceOption( + iconRes = CoreUiR.drawable.core_ui_ic_image, + label = stringResource(R.string.afternote_editor_media_source_gallery), + onClick = onPickFromGallery, + ) + MemorialMediaSourceOption( + iconRes = + when (target) { + MemorialMediaTarget.PHOTO -> R.drawable.feature_afternote_ic_camera + MemorialMediaTarget.VIDEO -> R.drawable.feature_afternote_ic_videocam + }, + label = + when (target) { + MemorialMediaTarget.PHOTO -> stringResource(R.string.afternote_editor_media_source_take_photo) + MemorialMediaTarget.VIDEO -> stringResource(R.string.afternote_editor_media_source_take_video) + }, + onClick = onCapture, + ) + Spacer(Modifier.height(32.dp)) + } +} + +@Composable +private fun MemorialMediaSourceOption( + @DrawableRes iconRes: Int, + label: String, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .bottomBorder(color = AfternoteDesign.colors.gray3, width = 1.dp) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = null, + tint = AfternoteDesign.colors.iconBk, + modifier = Modifier.size(24.dp), + ) + Text( + text = label, + style = AfternoteDesign.typography.bodyBase, + color = AfternoteDesign.colors.gray9, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MemorialMediaSourceBottomSheetPhotoPreview() { + AfternoteTheme { + MemorialMediaSourceBottomSheet( + target = MemorialMediaTarget.PHOTO, + onPickFromGallery = {}, + onCapture = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MemorialMediaSourceBottomSheetVideoPreview() { + AfternoteTheme { + MemorialMediaSourceBottomSheet( + target = MemorialMediaTarget.VIDEO, + onPickFromGallery = {}, + onCapture = {}, + ) + } +} diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt new file mode 100644 index 0000000000..3e40dcb33c --- /dev/null +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt @@ -0,0 +1,214 @@ +package com.afternote.feature.afternote.presentation.author.editor.memorial + +import android.content.ActivityNotFoundException +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri +import com.afternote.core.ui.theme.AfternoteDesign +import com.afternote.feature.afternote.presentation.R +import kotlinx.coroutines.launch +import java.io.IOException + +/** 사진 촬영 결과 파일 확장자. `image/jpeg` 로 역산되어 presigned 발급 확장자가 된다. */ +private const val PHOTO_CAPTURE_EXTENSION = "jpg" + +/** 영상 촬영 결과 파일 확장자. `video/mp4` 로 역산된다. */ +private const val VIDEO_CAPTURE_EXTENSION = "mp4" + +/** + * 추억 노트 미디어 슬롯의 "어디서 가져올지" 상태 + 갤러리·촬영 인텐트 발사구 (#369). + * + * 시트 표시 여부는 [target] 하나로 표현한다 — non-null 이면 그 슬롯의 시트가 떠 있고, null 이면 닫혀 있다. + * 별도 boolean 을 두지 않아 "떠 있는데 어느 슬롯인지 모르는" 상태가 애초에 만들어지지 않는다. + */ +@Stable +internal class MemorialMediaSourceState( + private val openTarget: MutableState, + private val onPickFromGallery: (MemorialMediaTarget) -> Unit, + private val onCapture: (MemorialMediaTarget) -> Unit, +) { + /** 시트가 열려 있는 슬롯. null 이면 시트가 닫혀 있다. */ + val target: MemorialMediaTarget? get() = openTarget.value + + fun open(target: MemorialMediaTarget) { + openTarget.value = target + } + + fun dismiss() { + openTarget.value = null + } + + fun pickFromGallery() { + consumeTarget()?.let(onPickFromGallery) + } + + fun capture() { + consumeTarget()?.let(onCapture) + } + + /** 인텐트를 쏘기 전에 시트를 닫는다 — 결과를 들고 돌아왔을 때 시트가 남아 있으면 화면을 가린다. */ + private fun consumeTarget(): MemorialMediaTarget? = target?.also { dismiss() } +} + +/** + * [MemorialMediaSourceState] 와 그것이 쏘는 4종 런처(갤러리 사진·갤러리 영상·사진 촬영·영상 촬영)를 만든다. + * + * 촬영 인텐트(`ACTION_IMAGE_CAPTURE`·`ACTION_VIDEO_CAPTURE`)는 결과를 우리가 지정한 URI 에 써 넣고 + * 성공 여부만 boolean 으로 돌려준다. 그래서 "어느 파일에 쓰라고 했는지" 를 결과가 올 때까지 들고 있어야 + * 하는데, 촬영 중 프로세스가 죽어도 복원되도록 [rememberSaveable] 로 둔다 — 카메라 앱이 전면에 있는 동안은 + * 우리 프로세스가 회수되기 쉬운 구간이다. + * + * 카메라 권한을 요청하지 않는 것은 의도다. 매니페스트에 `android.permission.CAMERA` 를 선언하지 않은 앱이 + * 이 인텐트를 쏠 때 Android 는 런타임 권한을 요구하지 않는다 — 촬영은 카메라 앱의 프로세스에서 일어나고 + * 우리는 결과 파일만 받는다. 반대로 선언해 두면 그 순간부터 런타임 권한이 *필수* 가 된다. + * + * @param onPhotoSelected 영정 사진 확정 URI. 갤러리 취소는 기존 동작을 유지해 null 로 전달된다. + * @param onVideoSelected 장례식에 남길 영상 확정 URI. 위와 같다. + */ +@Composable +internal fun rememberMemorialMediaSourceState( + snackbarHostState: SnackbarHostState, + onPhotoSelected: (String?) -> Unit, + onVideoSelected: (String?) -> Unit, +): MemorialMediaSourceState { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val captureUnavailableMessage = stringResource(R.string.afternote_editor_media_capture_unavailable) + + val openTarget = rememberSaveable { mutableStateOf(null) } + val pendingPhotoCapture = rememberSaveable { mutableStateOf(null) } + val pendingVideoCapture = rememberSaveable { mutableStateOf(null) } + + val photoGalleryLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + onPhotoSelected(uri?.toString()) + } + val videoGalleryLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? -> + onVideoSelected(uri?.toString()) + } + val photoCaptureLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { captured: Boolean -> + val uri = pendingPhotoCapture.value?.toUri() + pendingPhotoCapture.value = null + if (captured && uri != null) onPhotoSelected(uri.toString()) else discardMemorialCapture(context, uri) + } + val videoCaptureLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo()) { captured: Boolean -> + val uri = pendingVideoCapture.value?.toUri() + pendingVideoCapture.value = null + if (captured && uri != null) onVideoSelected(uri.toString()) else discardMemorialCapture(context, uri) + } + + return remember(openTarget) { + MemorialMediaSourceState( + openTarget = openTarget, + onPickFromGallery = { target -> + when (target) { + MemorialMediaTarget.PHOTO -> { + photoGalleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + } + + MemorialMediaTarget.VIDEO -> { + videoGalleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.VideoOnly), + ) + } + } + }, + onCapture = { target -> + val pending = + when (target) { + MemorialMediaTarget.PHOTO -> pendingPhotoCapture + MemorialMediaTarget.VIDEO -> pendingVideoCapture + } + val launch: (Uri) -> Unit = + when (target) { + MemorialMediaTarget.PHOTO -> photoCaptureLauncher::launch + MemorialMediaTarget.VIDEO -> videoCaptureLauncher::launch + } + val extension = + when (target) { + MemorialMediaTarget.PHOTO -> PHOTO_CAPTURE_EXTENSION + MemorialMediaTarget.VIDEO -> VIDEO_CAPTURE_EXTENSION + } + launchCapture(context, pending, extension, launch) { + scope.launch { snackbarHostState.showSnackbar(captureUnavailableMessage) } + } + }, + ) + } +} + +/** + * 결과 파일을 만들고 촬영 인텐트를 쏜다. 실패하면 만들다 만 파일을 되돌리고 [onUnavailable] 로 알린다. + * + * 실패 갈래는 둘뿐이다 — 캐시에 파일을 못 만들거나(저장공간), 촬영을 받아 줄 앱이 없거나 + * ([ActivityNotFoundException] — 카메라 없는 기기·에뮬레이터). 둘 다 "지금은 촬영할 수 없다" 로 같은 안내를 + * 준다. 조용히 삼키면 눌러도 아무 일이 없는 버튼이 된다. + */ +private inline fun launchCapture( + context: Context, + pending: MutableState, + extension: String, + launch: (Uri) -> Unit, + onUnavailable: () -> Unit, +) { + val uri = + try { + createMemorialCaptureUri(context, extension) + } catch (_: IOException) { + onUnavailable() + return + } + pending.value = uri.toString() + try { + launch(uri) + } catch (_: ActivityNotFoundException) { + pending.value = null + discardMemorialCapture(context, uri) + onUnavailable() + } +} + +/** + * [MemorialMediaSourceState.target] 이 정해져 있을 때만 뜨는 소스 선택 시트. + * + * `ModalBottomSheet` 자체를 여기서 감싸 두어, 호출 화면은 이 컴포저블 한 줄만 두면 된다. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun MemorialMediaSourceSheet(state: MemorialMediaSourceState) { + val target = state.target ?: return + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = state::dismiss, + sheetState = sheetState, + containerColor = AfternoteDesign.colors.gray1, + dragHandle = null, + ) { + MemorialMediaSourceBottomSheet( + target = target, + onPickFromGallery = state::pickFromGallery, + onCapture = state::capture, + ) + } +} diff --git a/feature/afternote/presentation/src/main/res/drawable/feature_afternote_ic_camera.xml b/feature/afternote/presentation/src/main/res/drawable/feature_afternote_ic_camera.xml new file mode 100644 index 0000000000..4eb5dbb1f6 --- /dev/null +++ b/feature/afternote/presentation/src/main/res/drawable/feature_afternote_ic_camera.xml @@ -0,0 +1,22 @@ + + + + diff --git a/feature/afternote/presentation/src/main/res/drawable/feature_afternote_ic_videocam.xml b/feature/afternote/presentation/src/main/res/drawable/feature_afternote_ic_videocam.xml new file mode 100644 index 0000000000..acf6d026e1 --- /dev/null +++ b/feature/afternote/presentation/src/main/res/drawable/feature_afternote_ic_videocam.xml @@ -0,0 +1,22 @@ + + + + diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index 428d4c11dd..cc4cdb7109 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -317,7 +317,9 @@ 작성 중인 내용이 사라집니다.\n정말 나가시겠어요? 나가기 계속 작성 + 미디어 추가하기 갤러리에서 선택 사진 촬영 영상 촬영 + 카메라를 사용할 수 없습니다. diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTest.kt new file mode 100644 index 0000000000..b34e19cd03 --- /dev/null +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTest.kt @@ -0,0 +1,34 @@ +package com.afternote.feature.afternote.presentation.author.editor.memorial + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.afternote.core.ui.theme.AfternoteTheme +import com.android.tools.screenshot.PreviewTest + +/** 영정 사진 슬롯 — 갤러리 / 사진 촬영. */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun memorialMediaSourceBottomSheetPhotoScreenshot() { + AfternoteTheme { + MemorialMediaSourceBottomSheet( + target = MemorialMediaTarget.PHOTO, + onPickFromGallery = {}, + onCapture = {}, + ) + } +} + +/** 장례식에 남길 영상 슬롯 — 아이콘과 문구만 갈린다. 두 벌을 두어 슬롯별 문구가 바뀌면 드러나게 한다. */ +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun memorialMediaSourceBottomSheetVideoScreenshot() { + AfternoteTheme { + MemorialMediaSourceBottomSheet( + target = MemorialMediaTarget.VIDEO, + onPickFromGallery = {}, + onCapture = {}, + ) + } +} diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetPhotoScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetPhotoScreenshot_748aa731_0.png new file mode 100644 index 0000000000000000000000000000000000000000..0a44130c2b3ebe86ae35816cbb9420d15c0b168d GIT binary patch literal 15428 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 literal 0 HcmV?d00001 diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetVideoScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetVideoScreenshot_748aa731_0.png new file mode 100644 index 0000000000000000000000000000000000000000..5d299c2f8c64cfa6528b90b1ad5db78f5a850307 GIT binary patch literal 15062 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 literal 0 HcmV?d00001 diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt new file mode 100644 index 0000000000..68190efd73 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt @@ -0,0 +1,65 @@ +package com.afternote.feature.afternote.presentation.author.editor.memorial + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +/** + * 즉석 촬영 결과 파일과 그 `content://` URI 계약 (#369). + * + * 여기서 지키는 것은 매니페스트(`FileProvider` authority)·`afternote_file_paths.xml`(cache-path)· + * 코드 상수 셋이 서로 어긋나지 않는다는 것이다. 어긋나면 `IllegalArgumentException` 으로 터지는데 + * 그 지점이 카메라를 띄우려는 순간이라, 조립 오류치고는 드러나는 시점이 늦다. + * + * SDK 를 못 박는 이유: 라이브러리 모듈은 targetSdk 를 따로 두지 않아 compileSdk(37) 가 그대로 실리는데 + * Robolectric 4.15 가 아는 최신 SDK 는 35 라, 두면 "targetSdkVersion > maxSdkVersion" 으로 기동부터 + * 실패한다. 여기서 보는 것은 조립이지 플랫폼 버전별 동작이 아니라 고정으로 충분하다. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MemorialMediaCaptureFilesTest { + private val context: Context = RuntimeEnvironment.getApplication() + + /** + * 한 메서드에 다 넣은 것은 의도다. `FileProvider` 는 authority 하나당 경로 전략을 **static** 으로 + * 캐시하는데, Robolectric 은 테스트 *메서드마다* 다른 임시 dataDir 을 준다. 메서드를 나누면 두 번째 + * 메서드부터 첫 메서드의 캐시(그 메서드의 임시 경로)를 물려받아 "루트를 못 찾는다" 로 실패한다. + * 프로덕션에는 없는 제약이라 코드를 비트는 대신 테스트를 한 흐름으로 둔다. + */ + @Test + fun `촬영 결과는 매니페스트 authority 로 노출되고 확장자가 MIME 으로 역산된다`() { + val photo = createMemorialCaptureUri(context, "jpg") + + // (1) 매니페스트의 android:authorities 와 코드 상수가 같은 문자열인가. + assertEquals("content", photo.scheme) + assertEquals(context.packageName + ".afternote.fileprovider", photo.authority) + + // (2) 업로드는 ContentResolver.getType() 으로 presigned 확장자를 정한다. 여기가 octet-stream 이면 + // 촬영본이 전부 기본 확장자(jpg)로 올라간다 — 영상이 .jpg 로 올라가는 형태. + val video = createMemorialCaptureUri(context, "mp4") + assertEquals("image/jpeg", context.contentResolver.getType(photo)) + assertEquals("video/mp4", context.contentResolver.getType(video)) + + // (3) 파일은 file_paths.xml 이 노출하는 캐시 하위 디렉터리에 있고, 호출마다 새로 만들어진다 + // (재사용하면 두 번째 촬영이 첫 결과를 덮어쓴다). + val dir = File(context.cacheDir, "captured_media") + assertTrue(dir.isDirectory) + assertNotEquals(photo, video) + assertEquals(2, dir.listFiles().orEmpty().size) + + // (4) 취소 시 폐기하면 0바이트 파일이 남지 않는다. + discardMemorialCapture(context, video) + assertEquals(listOf("jpg"), dir.listFiles().orEmpty().map { it.extension }) + + // (5) 결과 콜백은 pending URI 가 비어 있는 상태로도 올 수 있다(프로세스 복원 실패 등). + discardMemorialCapture(context, null) + assertEquals(1, dir.listFiles().orEmpty().size) + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceStateTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceStateTest.kt new file mode 100644 index 0000000000..41a3a67ab9 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceStateTest.kt @@ -0,0 +1,76 @@ +package com.afternote.feature.afternote.presentation.author.editor.memorial + +import androidx.compose.runtime.mutableStateOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * 소스 선택 시트의 열림/닫힘과 선택 라우팅 (#369). + * + * 시트 표시 여부를 [MemorialMediaSourceState.target] 하나로 표현한 덕에, "떠 있는 슬롯" 과 + * "결과를 받을 슬롯" 이 어긋날 수 없다는 것을 여기서 확인한다. + */ +class MemorialMediaSourceStateTest { + private val galleryCalls = mutableListOf() + private val captureCalls = mutableListOf() + private val state = + MemorialMediaSourceState( + openTarget = mutableStateOf(null), + onPickFromGallery = galleryCalls::add, + onCapture = captureCalls::add, + ) + + @Test + fun `기본은 닫힘이다`() { + assertNull(state.target) + } + + @Test + fun `연 슬롯이 그대로 시트 대상이 된다`() { + state.open(MemorialMediaTarget.VIDEO) + + assertEquals(MemorialMediaTarget.VIDEO, state.target) + } + + @Test + fun `갤러리를 고르면 시트를 닫고 그 슬롯으로 넘긴다`() { + state.open(MemorialMediaTarget.PHOTO) + + state.pickFromGallery() + + assertEquals(listOf(MemorialMediaTarget.PHOTO), galleryCalls) + assertNull(state.target) + } + + @Test + fun `촬영을 고르면 시트를 닫고 그 슬롯으로 넘긴다`() { + state.open(MemorialMediaTarget.VIDEO) + + state.capture() + + assertEquals(listOf(MemorialMediaTarget.VIDEO), captureCalls) + assertNull(state.target) + } + + @Test + fun `닫힌 상태의 선택은 인텐트를 쏘지 않는다`() { + // 시트가 사라지는 애니메이션 도중 들어온 탭이 인텐트를 두 번 쏘는 것을 막는다. + state.pickFromGallery() + state.capture() + + assertEquals(emptyList(), galleryCalls) + assertEquals(emptyList(), captureCalls) + } + + @Test + fun `바깥을 눌러 닫으면 아무 슬롯도 선택되지 않는다`() { + state.open(MemorialMediaTarget.PHOTO) + + state.dismiss() + + assertNull(state.target) + assertEquals(emptyList(), galleryCalls) + assertEquals(emptyList(), captureCalls) + } +} From da17d093ec39d1c7a4c2700e3550db91c7d3f862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 15:39:31 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor(afternote):=20discardMemorialCaptu?= =?UTF-8?q?re=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=A5=BC=20non-null?= =?UTF-8?q?=20=EB=A1=9C=20=EC=A2=81=ED=9E=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `uri: Uri?` + 함수 안 `if (uri == null) return` 은 시그니처가 사실을 잘못 말한다. "URI 가 없을 수도 있다" 가 아니라 **"지울 것이 없을 수도 있다"** 이고, 그건 pending 슬롯을 들고 있는 호출부가 아는 사실이다. 안에서 삼키면 호출부의 갈래가 보이지 않는다. 타입으로 좁히고 판정을 호출부로 올린다: when { uri == null -> Unit // 지울 것이 없다 (pending 소실) captured -> onPhotoSelected(uri.toString()) else -> discardMemorialCapture(context, uri) } 세 갈래가 한자리에 서서 "성공" 과 "취소" 와 "애초에 없음" 이 구분된다. 함수는 "지운다" 하나만 한다. 세 번째 호출부(ActivityNotFoundException 갈래)는 원래 non-null 이었다. 테스트의 `discardMemorialCapture(context, null)` 케이스는 타입으로 불가능해져 지운다 — 검증할 것이 없어진 게 아니라 검증할 필요가 없어졌다. 검증: :feature:afternote:presentation:testDebugUnitTest · ktlintCheck · :app:compileDebugAndroidTestKotlin · :app:compileDebugUnitTestKotlin 통과. --- .../editor/memorial/MemorialMediaCaptureFiles.kt | 6 ++++-- .../editor/memorial/MemorialMediaSourceState.kt | 12 ++++++++++-- .../editor/memorial/MemorialMediaCaptureFilesTest.kt | 5 +---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt index a8da483c38..71b38bb72d 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt @@ -39,11 +39,13 @@ internal fun createMemorialCaptureUri( * * 캐시라 방치해도 OS 가 언젠가 회수하지만, 취소를 반복하면 0바이트 파일이 계속 쌓인다. * 삭제 실패는 무시한다 — 지우지 못해도 사용자에게 알릴 것이 없다. + * + * "지울 것이 없다" 는 호출부가 아는 사실이라 여기서 받지 않는다 — [uri] 가 non-null 인 덕에 + * 이 함수는 "지운다" 하나만 한다. */ internal fun discardMemorialCapture( context: Context, - uri: Uri?, + uri: Uri, ) { - if (uri == null) return runCatching { context.contentResolver.delete(uri, null, null) } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt index 3e40dcb33c..e8681e8d0e 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt @@ -107,13 +107,21 @@ internal fun rememberMemorialMediaSourceState( rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { captured: Boolean -> val uri = pendingPhotoCapture.value?.toUri() pendingPhotoCapture.value = null - if (captured && uri != null) onPhotoSelected(uri.toString()) else discardMemorialCapture(context, uri) + when { + uri == null -> Unit + captured -> onPhotoSelected(uri.toString()) + else -> discardMemorialCapture(context, uri) + } } val videoCaptureLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo()) { captured: Boolean -> val uri = pendingVideoCapture.value?.toUri() pendingVideoCapture.value = null - if (captured && uri != null) onVideoSelected(uri.toString()) else discardMemorialCapture(context, uri) + when { + uri == null -> Unit + captured -> onVideoSelected(uri.toString()) + else -> discardMemorialCapture(context, uri) + } } return remember(openTarget) { diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt index 68190efd73..dfe81b723a 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt @@ -55,11 +55,8 @@ class MemorialMediaCaptureFilesTest { assertEquals(2, dir.listFiles().orEmpty().size) // (4) 취소 시 폐기하면 0바이트 파일이 남지 않는다. + // "지울 것이 없는" 경우(pending 이 비어 있음)는 호출부가 거르므로 여기 갈래가 없다 — non-null 파라미터. discardMemorialCapture(context, video) assertEquals(listOf("jpg"), dir.listFiles().orEmpty().map { it.extension }) - - // (5) 결과 콜백은 pending URI 가 비어 있는 상태로도 올 수 있다(프로세스 복원 실패 등). - discardMemorialCapture(context, null) - assertEquals(1, dir.listFiles().orEmpty().size) } } From d33c4779a6d2a1606fe64867da6f08850225830f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Tue, 25 Aug 2026 21:59:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(afternote):=20=EC=B4=AC=EC=98=81=20?= =?UTF-8?q?=EC=9D=B8=ED=85=90=ED=8A=B8=20=EB=B0=9C=EC=82=AC=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20=ED=85=94=EB=A0=88=EB=A9=94=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EC=97=90=20=EB=82=A8=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `launchCapture` 의 두 실패 갈래를 `catch (_: …)` 로 버리고 있었다. 화면에 나가는 문구는 "카메라를 사용할 수 없습니다" 하나라, 제보가 와도 **저장공간 문제인지 카메라 앱 부재인지 가를 단서가 어디에도 없다.** 촬영이 안 된다는 제보야말로 그 구분이 필요한 자리다. 예외를 버리지 않고 위로 올린다: onUnavailable: () -> Unit → onUnavailable: (Throwable) -> Unit `launchCapture` 는 사유를 넘기기만 하고, 문구는 호출부가 만들고 기록은 화면 밖으로 올라간다. 배선은 로컬 썸네일 추출 실패(MEMORIAL_THUMBNAIL_EXTRACT)와 같은 모양이다 — 컴포저블은 ErrorReporter 를 주입받지 못하므로 콜백으로 ViewModel 까지 올린다. rememberMemorialMediaSourceState(onCaptureFailed = …) → AfternoteEditorScreen(onCaptureFailed = …) → AfternoteNavGraphEditor: editViewModel::onMemorialCaptureLaunchFailed → recordAfternoteFailure(MEMORIAL_CAPTURE_LAUNCH, e) 새 stage 를 따로 둔 이유는 기존 키에 섞으면 콘솔에서 한 키에 다른 성격의 실패가 겹쳐서다. 검증: 모듈 유닛테스트 · ktlintCheck · :app:compileDebugUnitTestKotlin 통과. :app:compileDebugAndroidTestKotlin 이 `AfternoteAuthorExtendedAndroidTest` 의 호출부 누락을 잡아 함께 채웠다 — 게이트가 의도대로 동작한 사례. --- .../AfternoteAuthorExtendedAndroidTest.kt | 1 + .../author/editor/AfternoteEditorScreen.kt | 2 ++ .../author/editor/AfternoteEditorViewModel.kt | 10 ++++++++++ .../memorial/MemorialMediaSourceState.kt | 19 +++++++++++++------ .../navigation/AfternoteNavGraphEditor.kt | 1 + .../reporting/AfternoteFailureReporting.kt | 8 ++++++++ 6 files changed, 35 insertions(+), 6 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt index a920dfac69..6fc8d2b834 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AfternoteAuthorExtendedAndroidTest.kt @@ -387,6 +387,7 @@ private fun AuthorEditorForUpdate( onNavigateToSelectReceiver = {}, onThumbnailBytesReady = {}, onThumbnailExtractionFailed = {}, + onCaptureFailed = {}, onThumbnailUploadErrorConsumed = viewModel::onThumbnailUploadErrorConsumed, onValidationErrorConsumed = viewModel::onValidationErrorConsumed, state = state, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt index 3a8f698d07..b3dc139264 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorScreen.kt @@ -69,6 +69,7 @@ fun AfternoteEditorScreen( onNavigateToSelectReceiver: () -> Unit, onThumbnailBytesReady: (ByteArray?) -> Unit, onThumbnailExtractionFailed: (Throwable) -> Unit, + onCaptureFailed: (Throwable) -> Unit, onThumbnailUploadErrorConsumed: () -> Unit, onValidationErrorConsumed: () -> Unit, modifier: Modifier = Modifier, @@ -159,6 +160,7 @@ fun AfternoteEditorScreen( snackbarHostState = snackbarHostState, onPhotoSelected = state.setMemorialPhoto, onVideoSelected = state.setMemorialVideo, + onCaptureFailed = onCaptureFailed, ) // 작성 도중 이탈 가드: 진입 시점 스냅샷 대비 변경이 있으면 뒤로가기 시 이탈 확인 팝업을 띄운다. diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt index 74c9e40d9f..05f8fdb1af 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt @@ -343,6 +343,16 @@ class AfternoteEditorViewModel errorReporter.recordAfternoteFailure(AfternoteFailureStage.MEMORIAL_THUMBNAIL_EXTRACT, throwable) } + /** + * 즉석 촬영 인텐트를 띄우지 못한 실패를 기록한다. + * + * 화면에는 "카메라를 사용할 수 없습니다" 한 줄만 나가고 사유가 지워지므로, UI 가 예외를 + * 넘겨주지 않으면 저장공간 문제인지 카메라 앱 부재인지 콘솔에서 가를 수 없다. + */ + fun onMemorialCaptureLaunchFailed(throwable: Throwable) { + errorReporter.recordAfternoteFailure(AfternoteFailureStage.MEMORIAL_CAPTURE_LAUNCH, throwable) + } + fun saveAfternote( editingId: Long?, category: EditorCategory, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt index e8681e8d0e..004f5fd143 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt @@ -80,12 +80,15 @@ internal class MemorialMediaSourceState( * * @param onPhotoSelected 영정 사진 확정 URI. 갤러리 취소는 기존 동작을 유지해 null 로 전달된다. * @param onVideoSelected 장례식에 남길 영상 확정 URI. 위와 같다. + * @param onCaptureFailed 촬영 인텐트를 띄우지 못한 사유. 화면 문구는 두 갈래가 같아 사유가 지워지므로 + * 호출처가 텔레메트리로 남긴다. */ @Composable internal fun rememberMemorialMediaSourceState( snackbarHostState: SnackbarHostState, onPhotoSelected: (String?) -> Unit, onVideoSelected: (String?) -> Unit, + onCaptureFailed: (Throwable) -> Unit, ): MemorialMediaSourceState { val context = LocalContext.current val scope = rememberCoroutineScope() @@ -158,7 +161,8 @@ internal fun rememberMemorialMediaSourceState( MemorialMediaTarget.PHOTO -> PHOTO_CAPTURE_EXTENSION MemorialMediaTarget.VIDEO -> VIDEO_CAPTURE_EXTENSION } - launchCapture(context, pending, extension, launch) { + launchCapture(context, pending, extension, launch) { failure -> + onCaptureFailed(failure) scope.launch { snackbarHostState.showSnackbar(captureUnavailableMessage) } } }, @@ -169,6 +173,9 @@ internal fun rememberMemorialMediaSourceState( /** * 결과 파일을 만들고 촬영 인텐트를 쏜다. 실패하면 만들다 만 파일을 되돌리고 [onUnavailable] 로 알린다. * + * 예외를 통째로 넘기는 이유: 사용자에게 나가는 문구는 두 갈래가 같아도, 제보가 왔을 때 어느 쪽인지 + * 가르려면 사유가 남아 있어야 한다. 문구는 호출부가 만들고 기록은 호출부가 위로 올린다. + * * 실패 갈래는 둘뿐이다 — 캐시에 파일을 못 만들거나(저장공간), 촬영을 받아 줄 앱이 없거나 * ([ActivityNotFoundException] — 카메라 없는 기기·에뮬레이터). 둘 다 "지금은 촬영할 수 없다" 로 같은 안내를 * 준다. 조용히 삼키면 눌러도 아무 일이 없는 버튼이 된다. @@ -178,22 +185,22 @@ private inline fun launchCapture( pending: MutableState, extension: String, launch: (Uri) -> Unit, - onUnavailable: () -> Unit, + onUnavailable: (Throwable) -> Unit, ) { val uri = try { createMemorialCaptureUri(context, extension) - } catch (_: IOException) { - onUnavailable() + } catch (e: IOException) { + onUnavailable(e) return } pending.value = uri.toString() try { launch(uri) - } catch (_: ActivityNotFoundException) { + } catch (e: ActivityNotFoundException) { pending.value = null discardMemorialCapture(context, uri) - onUnavailable() + onUnavailable(e) } } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt index 724c6b7544..1237a9a04c 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphEditor.kt @@ -189,6 +189,7 @@ internal fun AfternoteEditorNavigation( onNavigateToSelectReceiver = onNavigateToSelectReceiver, onThumbnailBytesReady = editViewModel::uploadMemorialThumbnail, onThumbnailExtractionFailed = editViewModel::onMemorialThumbnailExtractionFailed, + onCaptureFailed = editViewModel::onMemorialCaptureLaunchFailed, onThumbnailUploadErrorConsumed = editViewModel::onThumbnailUploadErrorConsumed, onValidationErrorConsumed = editViewModel::onValidationErrorConsumed, liveSongs = liveSongs, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt index 6a03f2ebd8..7f1ce22c04 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt @@ -41,6 +41,14 @@ enum class AfternoteFailureStage( */ MEMORIAL_THUMBNAIL_EXTRACT("memorial_thumbnail_extract"), + /** + * 즉석 촬영 인텐트를 띄우지 못한 실패 — 결과 파일을 못 만들거나(저장공간) 받아 줄 앱이 없거나. + * + * 사용자에게는 "카메라를 사용할 수 없습니다" 한 줄만 나가 둘이 구분되지 않는다. 제보가 왔을 때 + * 어느 쪽인지 가르려면 예외 자체가 남아 있어야 한다. + */ + MEMORIAL_CAPTURE_LAUNCH("memorial_capture_launch"), + DETAIL_LOAD("detail_load"), /**