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/build.gradle.kts b/feature/afternote/presentation/build.gradle.kts
index cf76e402b4..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 {
@@ -33,6 +40,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/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..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
@@ -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
@@ -70,6 +69,7 @@ fun AfternoteEditorScreen(
onNavigateToSelectReceiver: () -> Unit,
onThumbnailBytesReady: (ByteArray?) -> Unit,
onThumbnailExtractionFailed: (Throwable) -> Unit,
+ onCaptureFailed: (Throwable) -> Unit,
onThumbnailUploadErrorConsumed: () -> Unit,
onValidationErrorConsumed: () -> Unit,
modifier: Modifier = Modifier,
@@ -154,14 +154,14 @@ 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,
+ onCaptureFailed = onCaptureFailed,
+ )
// 작성 도중 이탈 가드: 진입 시점 스냅샷 대비 변경이 있으면 뒤로가기 시 이탈 확인 팝업을 띄운다.
// 입력이 debounce 로 휘발성 폼 상태에만 반영되어 pop 시 소실되기 때문. '내용 존재'가 아니라 '변경' 기준인
@@ -231,22 +231,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/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/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..71b38bb72d
--- /dev/null
+++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFiles.kt
@@ -0,0 +1,51 @@
+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바이트 파일이 계속 쌓인다.
+ * 삭제 실패는 무시한다 — 지우지 못해도 사용자에게 알릴 것이 없다.
+ *
+ * "지울 것이 없다" 는 호출부가 아는 사실이라 여기서 받지 않는다 — [uri] 가 non-null 인 덕에
+ * 이 함수는 "지운다" 하나만 한다.
+ */
+internal fun discardMemorialCapture(
+ context: Context,
+ uri: Uri,
+) {
+ 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..004f5fd143
--- /dev/null
+++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceState.kt
@@ -0,0 +1,229 @@
+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. 위와 같다.
+ * @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()
+ 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
+ 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
+ when {
+ uri == null -> Unit
+ captured -> 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) { failure ->
+ onCaptureFailed(failure)
+ scope.launch { snackbarHostState.showSnackbar(captureUnavailableMessage) }
+ }
+ },
+ )
+ }
+}
+
+/**
+ * 결과 파일을 만들고 촬영 인텐트를 쏜다. 실패하면 만들다 만 파일을 되돌리고 [onUnavailable] 로 알린다.
+ *
+ * 예외를 통째로 넘기는 이유: 사용자에게 나가는 문구는 두 갈래가 같아도, 제보가 왔을 때 어느 쪽인지
+ * 가르려면 사유가 남아 있어야 한다. 문구는 호출부가 만들고 기록은 호출부가 위로 올린다.
+ *
+ * 실패 갈래는 둘뿐이다 — 캐시에 파일을 못 만들거나(저장공간), 촬영을 받아 줄 앱이 없거나
+ * ([ActivityNotFoundException] — 카메라 없는 기기·에뮬레이터). 둘 다 "지금은 촬영할 수 없다" 로 같은 안내를
+ * 준다. 조용히 삼키면 눌러도 아무 일이 없는 버튼이 된다.
+ */
+private inline fun launchCapture(
+ context: Context,
+ pending: MutableState,
+ extension: String,
+ launch: (Uri) -> Unit,
+ onUnavailable: (Throwable) -> Unit,
+) {
+ val uri =
+ try {
+ createMemorialCaptureUri(context, extension)
+ } catch (e: IOException) {
+ onUnavailable(e)
+ return
+ }
+ pending.value = uri.toString()
+ try {
+ launch(uri)
+ } catch (e: ActivityNotFoundException) {
+ pending.value = null
+ discardMemorialCapture(context, uri)
+ onUnavailable(e)
+ }
+}
+
+/**
+ * [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/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"),
/**
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 c3b0149bff..cc4cdb7109 100644
--- a/feature/afternote/presentation/src/main/res/values/strings.xml
+++ b/feature/afternote/presentation/src/main/res/values/strings.xml
@@ -317,4 +317,9 @@
작성 중인 내용이 사라집니다.\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 @@
+
+
+
+
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 0000000000..0a44130c2b
Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetPhotoScreenshot_748aa731_0.png differ
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 0000000000..5d299c2f8c
Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaSourceBottomSheetScreenshotTestKt/memorialMediaSourceBottomSheetVideoScreenshot_748aa731_0.png differ
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..dfe81b723a
--- /dev/null
+++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/editor/memorial/MemorialMediaCaptureFilesTest.kt
@@ -0,0 +1,62 @@
+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바이트 파일이 남지 않는다.
+ // "지울 것이 없는" 경우(pending 이 비어 있음)는 호출부가 거르므로 여기 갈래가 없다 — non-null 파라미터.
+ discardMemorialCapture(context, video)
+ assertEquals(listOf("jpg"), dir.listFiles().orEmpty().map { it.extension })
+ }
+}
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)
+ }
+}