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) + } +}