diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt
index d1d35e51b7..a9a64652d8 100644
--- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt
+++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/MemorialReceivedDetailScreen.kt
@@ -2,7 +2,6 @@ package com.afternote.feature.afternote.presentation.receiver.detail
import android.content.ActivityNotFoundException
import android.content.Intent
-import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -21,8 +20,12 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -30,6 +33,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -50,6 +54,7 @@ import com.afternote.feature.afternote.presentation.shared.detail.MessageSection
import com.afternote.feature.afternote.presentation.shared.detail.song.MemorialPlaylist
import com.afternote.feature.afternote.presentation.shared.model.AlbumCover
import com.afternote.feature.afternote.presentation.shared.model.MessageBlockUiModel
+import kotlinx.coroutines.launch
/**
* MEMORIAL(추억 노트) 카테고리의 수신자 측 상세 화면.
@@ -73,11 +78,14 @@ fun MemorialReceivedDetailScreen(
songCount: Int = 16,
memorialVideoUrl: String? = null,
memorialThumbnailUrl: String? = null,
+ snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
profileImageResId ?: R.drawable.receiver_img_default_profile_deceased
+ val onVideoClick = rememberReceivedMemorialVideoClickHandler(snackbarHostState)
Scaffold(
containerColor = Color.Transparent,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
// statusBarsPadding: 엣지투엣지로 그려 콘텐츠가 상태바 아래까지 깔리므로, 상태바 높이만큼 top 패딩
// → 탑바가 상태바(시계·배터리) 밑에서 시작(겹침 방지). 동적 인셋이라 회전·분할화면에도 대응.
@@ -134,6 +142,7 @@ fun MemorialReceivedDetailScreen(
ReceiverVideoSection(
memorialVideoUrl = memorialVideoUrl,
memorialThumbnailUrl = memorialThumbnailUrl,
+ onVideoClick = onVideoClick,
)
}
},
@@ -153,13 +162,42 @@ fun MemorialReceivedDetailScreen(
}
}
+/**
+ * 추모 영상 카드의 클릭 처리를 만든다.
+ *
+ * 실행 실패는 서버 작업 실패가 아니라 이 기기의 사정(막힌 URL·재생 앱 없음)이라 재시도 팝업(#446)이 아니라
+ * 스낵바로 알린다 — 작성자 쪽 상세(#1336)와 같은 채널이다. 원인이 다르면 문구도 다르므로 콜백 둘을 각각
+ * 다른 리소스에 붙인다 (#1391).
+ */
+@Composable
+private fun rememberReceivedMemorialVideoClickHandler(snackbarHostState: SnackbarHostState): (String) -> Unit {
+ val context = LocalContext.current
+ val resources = LocalResources.current
+ val scope = rememberCoroutineScope()
+ return remember(context, resources, scope, snackbarHostState) {
+ { videoUrl ->
+ launchReceivedMemorialVideo(
+ videoUrl = videoUrl,
+ startActivity = context::startActivity,
+ onRejected = {
+ val message = resources.getString(R.string.receiver_memorial_video_invalid_url)
+ scope.launch { snackbarHostState.showSnackbar(message) }
+ },
+ onUnavailable = {
+ val message = resources.getString(R.string.receiver_memorial_video_no_app)
+ scope.launch { snackbarHostState.showSnackbar(message) }
+ },
+ )
+ }
+ }
+}
+
@Composable
private fun ReceiverVideoSection(
+ onVideoClick: (String) -> Unit,
memorialVideoUrl: String? = null,
memorialThumbnailUrl: String? = null,
) {
- val context = LocalContext.current
- val noVideoAppMessage = stringResource(R.string.receiver_memorial_video_no_app)
Column(modifier = Modifier.fillMaxWidth()) {
ReceiverSectionHeader()
Spacer(modifier = Modifier.height(12.dp))
@@ -170,31 +208,7 @@ private fun ReceiverVideoSection(
.fillMaxWidth()
// clip 을 clickable 앞에: 눌림 피드백이 InfoCard 의 12dp 둥근 모서리 안에서만 그려지게 (모서리 밖 사각 번짐 방지)
.clip(RoundedCornerShape(12.dp))
- .clickable {
- launchReceivedMemorialVideo(
- videoUrl = memorialVideoUrl,
- startActivity = context::startActivity,
- // 원인이 다르면 문구도 달라야 한다 — 아래 둘을 한 문장으로 덮으면
- // 스킴 차단 상황에 «앱이 없습니다» 라는 거짓 안내가 나간다.
- // 채널(Snackbar) 전환·리소스화·표출 테스트는 #1391 건 3.
- onRejected = {
- Toast
- .makeText(
- context,
- "영상 주소가 올바르지 않아 재생할 수 없습니다.",
- Toast.LENGTH_SHORT,
- ).show()
- },
- onUnavailable = {
- Toast
- .makeText(
- context,
- noVideoAppMessage,
- Toast.LENGTH_SHORT,
- ).show()
- },
- )
- },
+ .clickable { onVideoClick(memorialVideoUrl) },
) {
ReceiverMemorialVideoThumbnail(thumbnailUrl = memorialThumbnailUrl)
}
diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml
index 04f38832ce..df642c9086 100644
--- a/feature/afternote/presentation/src/main/res/values/strings.xml
+++ b/feature/afternote/presentation/src/main/res/values/strings.xml
@@ -47,6 +47,7 @@
다음 메일
구글 메일
outlook
+ 영상 주소가 올바르지 않아 재생할 수 없습니다.
영상을 재생할 수 있는 앱이 없습니다.
%1$d개의 읽지 않은 타임 레터가 있습니다.
diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoNoticeTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoNoticeTest.kt
new file mode 100644
index 0000000000..278486b467
--- /dev/null
+++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoNoticeTest.kt
@@ -0,0 +1,70 @@
+package com.afternote.feature.afternote.presentation.receiver.detail
+
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+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.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+
+/**
+ * 추모 영상 실행 안내가 Toast 가 아니라 스낵바로 나가고, 두 원인이 서로 다른 문구로 갈리는지 단언한다 (#1391).
+ *
+ * Toast 는 Compose semantics 에 안 잡혀 어떤 테스트로도 단언할 수 없었다 — 채널 전환이 곧 검증 가능성이다.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class ReceivedMemorialVideoNoticeTest {
+ @get:Rule
+ val composeRule = createComposeRule()
+
+ @Test
+ fun `막힌 URL 은 주소 문제로 안내한다`() {
+ showDetail(videoUrl = "javascript:alert(1)")
+
+ clickVideoCard()
+
+ composeRule.onNodeWithText("영상 주소가 올바르지 않아 재생할 수 없습니다.").assertIsDisplayed()
+ composeRule.onNodeWithText("영상을 재생할 수 있는 앱이 없습니다.").assertDoesNotExist()
+ }
+
+ @Test
+ fun `재생할 앱이 없으면 앱 부재로 안내한다`() {
+ // 실행 가능한 액티비티가 없으면 ActivityNotFoundException 을 던지게 해 OS 거부를 재현한다.
+ shadowOf(RuntimeEnvironment.getApplication()).checkActivities(true)
+ showDetail(videoUrl = "https://cdn.example.com/memorial.mp4")
+
+ clickVideoCard()
+
+ composeRule.onNodeWithText("영상을 재생할 수 있는 앱이 없습니다.").assertIsDisplayed()
+ composeRule.onNodeWithText("영상 주소가 올바르지 않아 재생할 수 없습니다.").assertDoesNotExist()
+ }
+
+ private fun showDetail(videoUrl: String) {
+ composeRule.setContent {
+ AfternoteTheme {
+ MemorialReceivedDetailScreen(
+ senderName = "김발신",
+ onNavigateToFullList = {},
+ onNavigateToPlaylist = {},
+ onBackClick = {},
+ albumCovers = emptyList(),
+ memorialVideoUrl = videoUrl,
+ )
+ }
+ }
+ }
+
+ private fun clickVideoCard() {
+ composeRule.onNodeWithContentDescription("영상 재생").performScrollTo().performClick()
+ composeRule.waitForIdle()
+ }
+}