diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 4e4ebb889c..8e22dc4207 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -11,4 +11,8 @@ android {
dependencies {
implementation(libs.androidx.work.runtime.ktx)
+
+ // launchMemorialVideo 의 URL 검증이 android.net.Uri 파싱에 기대므로 JVM 테스트에 Android
+ // 구현이 필요하다. 매니페스트는 읽지 않으므로 isIncludeAndroidResources 는 켜지 않는다.
+ testImplementation(libs.robolectric)
}
diff --git a/core/common/src/main/kotlin/com/afternote/core/common/media/MemorialVideoLauncher.kt b/core/common/src/main/kotlin/com/afternote/core/common/media/MemorialVideoLauncher.kt
new file mode 100644
index 0000000000..441e1ee45d
--- /dev/null
+++ b/core/common/src/main/kotlin/com/afternote/core/common/media/MemorialVideoLauncher.kt
@@ -0,0 +1,61 @@
+package com.afternote.core.common.media
+
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import androidx.core.net.toUri
+
+/**
+ * 서버가 준 추모 영상 URL을 외부 재생 앱으로 연다.
+ *
+ * 이 함수는 **보안 가드**다. 저장된 값은 발신자가 넣은 비관리 URL 이고 서버가 원문 그대로
+ * 돌려주므로, `http`/`https` 가 아닌 스킴은 실행하지 않는다 (#1394 — 발신자발 위험 스킴 차단).
+ * 허용 스킴·host 검증 규칙이 여러 곳에 흩어져 있으면 한쪽만 고칠 때 다른 쪽이 뚫리므로,
+ * 발신자·수신자 화면이 이 한 벌을 공유한다 (#1436).
+ *
+ * Android 11+ 패키지 가시성에서는 외부 앱 사전 조회가 실제 처리 가능한 앱이 있어도 실패할 수
+ * 있다. 따라서 사전 조회 대신 실행을 직접 시도하고, OS가 명시적으로 거부한 경우에만 폴백한다.
+ *
+ * 콜백을 둘로 나눈 이유는 **원인이 다르면 안내도 달라야 하기 때문**이다. 하나로 합치면 URL 이
+ * 막힌 경우에도 «재생할 앱이 없습니다» 가 나가는데, 그건 앱 유무와 무관한 거짓이다.
+ *
+ * @param onRejected URL 이 검증을 통과하지 못해 실행 자체를 시도하지 않은 경우
+ * @param onUnavailable 실행을 시도했으나 OS 가 거부한 경우
+ */
+fun launchMemorialVideo(
+ videoUrl: String,
+ startActivity: (Intent) -> Unit,
+ onRejected: () -> 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) {
+ onRejected()
+ return
+ }
+
+ try {
+ startActivity(Intent(Intent.ACTION_VIEW, uri))
+ } catch (_: ActivityNotFoundException) {
+ onUnavailable()
+ } catch (_: SecurityException) {
+ onUnavailable()
+ } catch (_: IllegalArgumentException) {
+ onUnavailable()
+ }
+}
diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoLauncherTest.kt b/core/common/src/test/java/com/afternote/core/common/media/MemorialVideoLauncherTest.kt
similarity index 89%
rename from feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoLauncherTest.kt
rename to core/common/src/test/java/com/afternote/core/common/media/MemorialVideoLauncherTest.kt
index 7cdc292d37..d95cf7f355 100644
--- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoLauncherTest.kt
+++ b/core/common/src/test/java/com/afternote/core/common/media/MemorialVideoLauncherTest.kt
@@ -1,4 +1,4 @@
-package com.afternote.feature.afternote.presentation.receiver.detail
+package com.afternote.core.common.media
import android.content.ActivityNotFoundException
import android.content.Intent
@@ -11,7 +11,7 @@ import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
-class ReceivedMemorialVideoLauncherTest {
+class MemorialVideoLauncherTest {
@Test
fun `http와 https URL은 ACTION_VIEW로 직접 실행한다`() {
val urls =
@@ -24,7 +24,7 @@ class ReceivedMemorialVideoLauncherTest {
var unavailableCount = 0
urls.forEach { url ->
- launchReceivedMemorialVideo(
+ launchMemorialVideo(
videoUrl = url,
startActivity = { startedIntents += it },
onRejected = { rejectedCount += 1 },
@@ -40,6 +40,8 @@ class ReceivedMemorialVideoLauncherTest {
@Test
fun `http와 https가 아닌 URL이나 잘못된 URL은 실행하지 않고 거부로 안내한다`() {
+ // 두 판의 거부 목록 합집합이다. 승격 전에는 이 목록이 갈려 있어, 한쪽에만 있는
+ // 스킴(intent:·tel:)이 다른 쪽에서 회귀해도 아무도 몰랐다 (#1436).
val rejectedUrls =
listOf(
"",
@@ -56,7 +58,7 @@ class ReceivedMemorialVideoLauncherTest {
var unavailableCount = 0
rejectedUrls.forEach { url ->
- launchReceivedMemorialVideo(
+ launchMemorialVideo(
videoUrl = url,
startActivity = { startedCount += 1 },
onRejected = { rejectedCount += 1 },
@@ -82,7 +84,7 @@ class ReceivedMemorialVideoLauncherTest {
var unavailableCount = 0
failures.forEach { failure ->
- launchReceivedMemorialVideo(
+ launchMemorialVideo(
videoUrl = "https://cdn.example.com/memorial.mp4",
startActivity = { throw failure },
onRejected = { rejectedCount += 1 },
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 d94faf3c0a..b62e7f76f9 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,7 +1,5 @@
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
@@ -33,11 +31,11 @@ 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.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.afternote.core.common.media.launchMemorialVideo
import com.afternote.core.ui.loading.LoadingBody
import com.afternote.core.ui.theme.AfternoteDesign
import com.afternote.core.ui.topbar.DetailTopBar
@@ -353,6 +351,14 @@ private fun rememberMemorialVideoClickHandler(snackbarHostState: SnackbarHostSta
launchMemorialVideo(
videoUrl = videoUrl,
startActivity = context::startActivity,
+ // 원인이 다르면 문구도 달라야 한다 — 둘을 한 문장으로 덮으면 스킴이 막힌
+ // 상황에 «재생할 앱이 없습니다» 라는 거짓 안내가 나간다.
+ onRejected = {
+ val message = resources.getString(R.string.feature_afternote_memorial_video_invalid_url)
+ scope.launch {
+ snackbarHostState.showSnackbar(message = message)
+ }
+ },
onUnavailable = {
val message = resources.getString(R.string.feature_afternote_memorial_video_no_app)
scope.launch {
@@ -363,48 +369,3 @@ private fun rememberMemorialVideoClickHandler(snackbarHostState: SnackbarHostSta
}
}
}
-
-/**
- * 서버가 준 추모 영상 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/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 a9a64652d8..630204d7d5 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
@@ -1,7 +1,5 @@
package com.afternote.feature.afternote.presentation.receiver.detail
-import android.content.ActivityNotFoundException
-import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -38,8 +36,8 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.core.net.toUri
import coil3.compose.AsyncImage
+import com.afternote.core.common.media.launchMemorialVideo
import com.afternote.core.ui.ProfileImage
import com.afternote.core.ui.bottombar.BottomBar
import com.afternote.core.ui.bottombar.BottomNavTab
@@ -176,7 +174,7 @@ private fun rememberReceivedMemorialVideoClickHandler(snackbarHostState: Snackba
val scope = rememberCoroutineScope()
return remember(context, resources, scope, snackbarHostState) {
{ videoUrl ->
- launchReceivedMemorialVideo(
+ launchMemorialVideo(
videoUrl = videoUrl,
startActivity = context::startActivity,
onRejected = {
@@ -241,63 +239,6 @@ private fun ReceiverVideoSection(
}
}
-/**
- * 서버가 준 추모 영상 URL을 외부 재생 앱으로 연다.
- *
- * 수신자 화면은 발신자가 저장한 값을 여는 쪽인데 서버는 비관리 URL 을 원문 그대로 반환하므로,
- * http/https 가 아닌 스킴은 실행하지 않는다 (#1394 — 발신자발 위험 스킴 차단). 불합격 URL 은
- * [onRejected] 로 알린다.
- * Android 11+ 패키지 가시성에서는 외부 앱 사전 조회가 실제 처리 가능한 앱이 있어도 실패할 수
- * 있다. 따라서 http/https URL만 선별한 뒤 실행을 직접 시도하고, OS가 명시적으로 거부한 경우에만
- * [onUnavailable] 로 폴백한다.
- *
- * 두 콜백을 나눈 이유는 **원인이 다르면 안내도 달라야 하기 때문**이다. 하나로 합치면 URL 이 막힌
- * 경우에도 «재생할 앱이 없습니다» 가 나가는데, 그건 앱 유무와 무관한 거짓이다.
- *
- * 작성자 쪽 상세의 `launchMemorialVideo`(PR #1336, develop 머지됨)와 같은 패턴이나 — 그쪽은
- * afternote 모듈 `internal` 이라 공유 없이 이식했다 — 콜백 분리는 이 판에만 있어 본문이 갈라져
- * 있다. `core:common` 승격(#1436)은 이 시그니처를 계약으로 삼고, 승격 시 작성자 판도 같이
- * 갈라야 한다.
- */
-internal fun launchReceivedMemorialVideo(
- videoUrl: String,
- startActivity: (Intent) -> Unit,
- onRejected: () -> 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) {
- onRejected()
- return
- }
-
- try {
- startActivity(Intent(Intent.ACTION_VIEW, uri))
- } catch (_: ActivityNotFoundException) {
- onUnavailable()
- } catch (_: SecurityException) {
- onUnavailable()
- } catch (_: IllegalArgumentException) {
- onUnavailable()
- }
-}
-
@Composable
private fun ReceiverMemorialVideoThumbnail(thumbnailUrl: String?) {
Box(
diff --git a/feature/afternote/presentation/src/main/res/values/strings.xml b/feature/afternote/presentation/src/main/res/values/strings.xml
index 448c5303b9..e5ea5d5943 100644
--- a/feature/afternote/presentation/src/main/res/values/strings.xml
+++ b/feature/afternote/presentation/src/main/res/values/strings.xml
@@ -206,6 +206,7 @@
에 대한\n기록
이 추억 노트는 지정된 신뢰할 연락처에게만 공유되며,\n안전하게 보관됩니다.
영상을 재생할 수 있는 앱이 없습니다.
+ 영상 주소가 올바르지 않아 재생할 수 없습니다.
애프터노트 작성하기
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
deleted file mode 100644
index 9af81dc529..0000000000
--- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/navigation/MemorialVideoLauncherTest.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-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)
- }
-}