diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreen.kt index 0d117d433f..d8b2fbf258 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreen.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreen.kt @@ -2,9 +2,11 @@ package com.afternote.feature.afternote.presentation.author.detail import androidx.compose.foundation.Image 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -34,8 +36,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -57,6 +61,8 @@ import com.afternote.feature.afternote.presentation.shared.detail.ReceiversCard import com.afternote.feature.afternote.presentation.shared.model.AlbumCover import com.afternote.feature.afternote.presentation.shared.model.ReceiverUiModel +internal const val MEMORIAL_VIDEO_CARD_TEST_TAG = "memorialVideoCard" + /** * 추억 노트 상세 표시 데이터. */ @@ -86,6 +92,7 @@ fun MemorialDetailScreen( isEditable: Boolean = true, onEditClick: () -> Unit = {}, onDeleteConfirm: () -> Unit = {}, + onVideoClick: (String) -> Unit, state: AfternoteDetailState = rememberAfternoteDetailState(), ) { val memorialCategoryLabel = stringResource(R.string.afternote_category_memorial) @@ -134,6 +141,7 @@ fun MemorialDetailScreen( MemorialDetailScrollContent( content = content, categoryLabel = memorialCategoryLabel, + onVideoClick = onVideoClick, modifier = Modifier .padding(paddingValues) @@ -146,6 +154,7 @@ fun MemorialDetailScreen( private fun MemorialDetailScrollContent( content: MemorialDetailContent, categoryLabel: String, + onVideoClick: (String) -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -158,7 +167,10 @@ private fun MemorialDetailScrollContent( Spacer(modifier = Modifier.height(24.dp)) TitleSection(categoryLabel = categoryLabel, userName = content.userName) Spacer(modifier = Modifier.height(24.dp)) - CardSection(content = content) + CardSection(content = content, onVideoClick = onVideoClick) + Spacer(modifier = Modifier.height(24.dp)) + SharingNotice() + Spacer(modifier = Modifier.height(32.dp)) } } @@ -167,22 +179,31 @@ private fun TitleSection( categoryLabel: String, userName: String, ) { + // 프로필 로드 실패·로딩 경합으로 이름이 비어 있으면 이름 세그먼트를 생략해 + // "…에 대한 님의 기록" 렌더를 막는다. + val titleSuffix = + if (userName.isBlank()) { + stringResource(R.string.feature_afternote_memorial_detail_title_suffix_no_name) + } else { + stringResource(R.string.feature_afternote_memorial_detail_title_suffix, userName) + } Text( text = buildAnnotatedString { withStyle(style = SpanStyle(color = AfternoteDesign.colors.gray9)) { append(categoryLabel) } - // 프로필 로드 실패·로딩 경합으로 이름이 비어 있으면 이름 세그먼트를 생략해 - // "…에 대한 님의 기록" 렌더를 막는다. - append(if (userName.isBlank()) "에 대한 기록" else "에 대한 ${userName}님의 기록") + append(titleSuffix) }, style = AfternoteDesign.typography.bodyLargeB, ) } @Composable -private fun CardSection(content: MemorialDetailContent) { +private fun CardSection( + content: MemorialDetailContent, + onVideoClick: (String) -> Unit, +) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { PhotoCard( finalWriteDate = content.finalWriteDate, @@ -196,10 +217,33 @@ private fun CardSection(content: MemorialDetailContent) { VideoCard( videoUrl = content.memorialVideoUrl, thumbnailUrl = content.memorialThumbnailUrl, + onClick = onVideoClick, ) } } +@Composable +private fun SharingNotice() { + InfoCard(modifier = Modifier.fillMaxWidth()) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "•", + style = + AfternoteDesign.typography.bodySmallR.copy( + color = AfternoteDesign.colors.gray6, + ), + ) + Text( + text = stringResource(R.string.feature_afternote_memorial_detail_sharing_notice), + style = + AfternoteDesign.typography.bodySmallR.copy( + color = AfternoteDesign.colors.gray6, + ), + ) + } + } +} + @Composable private fun PhotoCard( finalWriteDate: String, @@ -239,11 +283,17 @@ private fun PhotoCard( private fun VideoCard( videoUrl: String?, thumbnailUrl: String?, + onClick: (String) -> Unit, ) { if (videoUrl.isNullOrBlank()) return InfoCard( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(role = Role.Button) { onClick(videoUrl) } + .testTag(MEMORIAL_VIDEO_CARD_TEST_TAG), content = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( @@ -453,6 +503,7 @@ private fun MemorialDetailScreenPreview() { ), onBackClick = {}, onEditClick = {}, + onVideoClick = {}, ) } } @@ -481,6 +532,7 @@ private fun MemorialDetailScreenDeleteDialogPreview() { ), onBackClick = {}, onEditClick = {}, + onVideoClick = {}, state = stateWithDialog, ) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphDetail.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphDetail.kt index 22dba91827..a23ca06b89 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphDetail.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/navigation/AfternoteNavGraphDetail.kt @@ -1,5 +1,7 @@ package com.afternote.feature.afternote.presentation.author.navigation +import android.content.ActivityNotFoundException +import android.content.Intent import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures @@ -23,6 +25,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode @@ -30,6 +33,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.afternote.core.ui.loading.LoadingBody @@ -223,6 +227,7 @@ internal fun AfternoteDetailNavigation( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } + val onVideoClick = rememberMemorialVideoClickHandler(snackbarHostState) ObserveDeleteResult( deleteResult = (uiState as? AfternoteDetailUiState.Success)?.deleteResult, @@ -250,6 +255,7 @@ internal fun AfternoteDetailNavigation( onBackClick = onNavigateBack, onNavigateToEditor = onNavigateToEditor, onDeleteConfirm = viewModel::deleteAfternote, + onVideoClick = onVideoClick, ) } } @@ -262,6 +268,7 @@ private fun AfternoteDetailSuccessContent( onBackClick: () -> Unit, onNavigateToEditor: (itemId: Long, type: AfternoteType) -> Unit, onDeleteConfirm: (itemId: Long) -> Unit, + onVideoClick: (String) -> Unit, ) { Box { when (val model = state.contentUiModel) { @@ -310,6 +317,7 @@ private fun AfternoteDetailSuccessContent( onNavigateToEditor(state.detailId, model.type) }, onDeleteConfirm = { onDeleteConfirm(state.detailId) }, + onVideoClick = onVideoClick, ) } @@ -323,3 +331,69 @@ private fun AfternoteDetailSuccessContent( } } } + +@Composable +private fun rememberMemorialVideoClickHandler(snackbarHostState: SnackbarHostState): (String) -> Unit { + val context = LocalContext.current + val resources = LocalResources.current + val scope = rememberCoroutineScope() + return remember(context, snackbarHostState, resources, scope) { + { videoUrl -> + launchMemorialVideo( + videoUrl = videoUrl, + startActivity = context::startActivity, + onUnavailable = { + val message = resources.getString(R.string.feature_afternote_memorial_video_no_app) + scope.launch { + snackbarHostState.showSnackbar(message = message) + } + }, + ) + } + } +} + +/** + * 서버가 준 추모 영상 URL을 외부 재생 앱으로 연다. + * + * Android 11+ 패키지 가시성에서는 외부 앱 사전 조회가 실제 처리 가능한 앱이 있어도 실패할 수 있다. + * 따라서 http/https URL만 선별한 뒤 실행을 직접 시도하고, OS가 명시적으로 거부한 경우에만 + * [onUnavailable] 로 폴백한다. + */ +internal fun launchMemorialVideo( + videoUrl: String, + startActivity: (Intent) -> Unit, + onUnavailable: () -> Unit, +) { + val uri = + try { + videoUrl + .takeUnless { it.isBlank() || it.any(Char::isWhitespace) } + ?.toUri() + ?.takeIf { + val scheme = it.scheme + ( + scheme.equals("http", ignoreCase = true) || + scheme.equals("https", ignoreCase = true) + ) && + !it.host.isNullOrBlank() + } + } catch (_: IllegalArgumentException) { + null + } + + if (uri == null) { + onUnavailable() + return + } + + try { + startActivity(Intent(Intent.ACTION_VIEW, uri)) + } catch (_: ActivityNotFoundException) { + onUnavailable() + } catch (_: SecurityException) { + onUnavailable() + } catch (_: IllegalArgumentException) { + onUnavailable() + } +} diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml index abaeb576a7..8df1d8fdd5 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -212,6 +212,10 @@ •••••••• ❝ 추가 수신자 + 에 대한\n%1$s님의 기록 + 에 대한\n기록 + 이 추억 노트는 지정된 신뢰할 연락처에게만 공유되며,\n안전하게 보관됩니다. + 영상을 재생할 수 있는 앱이 없습니다. 애프터노트 작성하기 diff --git a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTest.kt b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTest.kt index 61f938724b..c91b40f080 100644 --- a/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTest.kt +++ b/feature/afternote/presentation/src/screenshotTest/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTest.kt @@ -12,6 +12,7 @@ internal fun memorialDetailScreenScreenshot() { AfternoteTheme { MemorialDetailScreen( onBackClick = {}, + onVideoClick = {}, content = MemorialDetailContent( userName = "서영", @@ -20,3 +21,21 @@ internal fun memorialDetailScreenScreenshot() { ) } } + +@PreviewTest +@Preview(showBackground = true) +@Composable +internal fun memorialDetailScreenWithVideoScreenshot() { + AfternoteTheme { + MemorialDetailScreen( + onBackClick = {}, + onVideoClick = {}, + content = + MemorialDetailContent( + userName = "서영", + finalWriteDate = "2025.11.26", + memorialVideoUrl = "https://cdn.example.com/memorial.mp4", + ), + ) + } +} diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png index 5d5e884fac..5eb0808caa 100644 Binary files a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png new file mode 100644 index 0000000000..2389aadf18 Binary files /dev/null and b/feature/afternote/presentation/src/screenshotTestDebug/reference/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenScreenshotTestKt/memorialDetailScreenWithVideoScreenshot_748aa731_0.png differ diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenInteractionTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenInteractionTest.kt new file mode 100644 index 0000000000..72fa8c1051 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/detail/MemorialDetailScreenInteractionTest.kt @@ -0,0 +1,85 @@ +package com.afternote.feature.afternote.presentation.author.detail + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import com.afternote.core.ui.theme.AfternoteTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MemorialDetailScreenInteractionTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun `영상 URL이 있으면 카드 탭이 정확한 URL을 상위 콜백에 전달한다`() { + val videoUrl = "https://cdn.example.com/memorial.mp4" + var clickedUrl: String? = null + + composeRule.setContent { + AfternoteTheme { + MemorialDetailScreen( + onBackClick = {}, + content = + MemorialDetailContent( + userName = "서영", + memorialVideoUrl = videoUrl, + ), + onVideoClick = { clickedUrl = it }, + ) + } + } + + composeRule + .onNodeWithTag(MEMORIAL_VIDEO_CARD_TEST_TAG) + .performScrollTo() + .assertHasClickAction() + .performClick() + + composeRule.runOnIdle { assertEquals(videoUrl, clickedUrl) } + } + + @Test + fun `영상 URL이 없으면 영상 카드를 표시하지 않는다`() { + composeRule.setContent { + AfternoteTheme { + MemorialDetailScreen( + onBackClick = {}, + onVideoClick = {}, + content = MemorialDetailContent(userName = "서영"), + ) + } + } + + composeRule.onNodeWithTag(MEMORIAL_VIDEO_CARD_TEST_TAG).assertDoesNotExist() + } + + @Test + fun `타이틀 개행과 하단 고지 문구를 그대로 표시한다`() { + composeRule.setContent { + AfternoteTheme { + MemorialDetailScreen( + onBackClick = {}, + onVideoClick = {}, + content = MemorialDetailContent(userName = "서영"), + ) + } + } + + composeRule.onNodeWithText("추억 노트에 대한\n서영님의 기록").assertExists() + composeRule + .onNodeWithText("이 추억 노트는 지정된 신뢰할 연락처에게만 공유되며,\n안전하게 보관됩니다.") + .performScrollTo() + .assertExists() + } +} diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/navigation/MemorialVideoLauncherTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/navigation/MemorialVideoLauncherTest.kt new file mode 100644 index 0000000000..9af81dc529 --- /dev/null +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/navigation/MemorialVideoLauncherTest.kt @@ -0,0 +1,86 @@ +package com.afternote.feature.afternote.presentation.author.navigation + +import android.content.ActivityNotFoundException +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MemorialVideoLauncherTest { + @Test + fun `http와 https URL은 ACTION_VIEW로 직접 실행한다`() { + val urls = + listOf( + "https://cdn.example.com/memorial.mp4", + "http://cdn.example.com/memorial.mp4", + ) + val startedIntents = mutableListOf() + var unavailableCount = 0 + + urls.forEach { url -> + launchMemorialVideo( + videoUrl = url, + startActivity = { startedIntents += it }, + onUnavailable = { unavailableCount += 1 }, + ) + } + + assertEquals(urls, startedIntents.map { it.dataString }) + assertTrue(startedIntents.all { it.action == Intent.ACTION_VIEW }) + assertEquals(0, unavailableCount) + } + + @Test + fun `http와 https가 아닌 URL이나 잘못된 URL은 실행하지 않는다`() { + val rejectedUrls = + listOf( + "", + "not a url", + "ftp://cdn.example.com/memorial.mp4", + "content://videos/memorial", + "javascript:alert(1)", + "intent://memorial#Intent;scheme=https;end", + "tel:0212345678", + "https:///missing-host.mp4", + ) + var startedCount = 0 + var unavailableCount = 0 + + rejectedUrls.forEach { url -> + launchMemorialVideo( + videoUrl = url, + startActivity = { startedCount += 1 }, + onUnavailable = { unavailableCount += 1 }, + ) + } + + assertEquals(0, startedCount) + assertEquals(rejectedUrls.size, unavailableCount) + } + + @Test + fun `외부 실행이 거부되면 안내 콜백을 호출한다`() { + val failures = + listOf( + ActivityNotFoundException("no player"), + SecurityException("blocked"), + IllegalArgumentException("bad uri"), + ) + var unavailableCount = 0 + + failures.forEach { failure -> + launchMemorialVideo( + videoUrl = "https://cdn.example.com/memorial.mp4", + startActivity = { throw failure }, + onUnavailable = { unavailableCount += 1 }, + ) + } + + assertEquals(failures.size, unavailableCount) + } +}