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