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