From b832502a73c14ee4752476d36fcc3427509299ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Sun, 30 Aug 2026 02:58:28 +0900 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=EC=B6=94=EB=AA=A8=20?= =?UTF-8?q?=EC=98=81=EC=83=81=20=EC=8B=A4=ED=96=89=20=EA=B0=80=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20core:common=20=ED=95=9C=20=EB=B2=8C=EB=A1=9C=20?= =?UTF-8?q?=EC=8A=B9=EA=B2=A9=ED=95=9C=EB=8B=A4=20(#1436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추모 영상 URL 을 외부 앱으로 여는 실행 가드가 작성자·수신자 2벌로 있었다. 도입 당시엔 본문 37줄이 문자 단위로 같았고, 이건 스타일 중복이 아니라 보안 가드다 — 허용 스킴·host 검증 규칙이 두 곳에 흩어져 있으면 한쪽만 고칠 때 다른 쪽이 뚫린다. 그리고 이미 갈라져 있었다. PR #1395 가 수신자 판의 안내 콜백을 원인별로 나눴는데 (onRejected = URL 불합격 / onUnavailable = 실행 거부) 작성자 판은 단일 콜백이라, URL 이 막힌 경우에도 «재생할 앱이 없습니다» 라는 거짓 안내가 develop 에 남아 있었다. core:common 에 한 벌을 세우고 두 정의를 지운다. Compose 비의존이고 Intent·Uri 만 쓰며 두 소비처가 이미 core:common 에 의존한다. 계약은 #1395 가 확정한 2콜백을 그대로 쓰고, 승격과 함께 작성자 호출처도 두 갈래로 갈라 거짓 안내를 없앤다 — URL 불합격용 문구 리소스를 1건 추가했다. 테스트도 1벌로 합친다. 거부 목록은 두 판의 합집합이다. 승격 전에는 목록이 갈릴 수 있어 한쪽에만 있는 스킴이 다른 쪽에서 회귀해도 아무도 몰랐다. core:common 에 robolectric 을 testImplementation 으로 더한다 — URL 검증이 Uri 파싱에 기대기 때문이다. 매니페스트는 읽지 않으므로 isIncludeAndroidResources 는 켜지 않는다. --- core/common/build.gradle.kts | 4 + .../common/media/MemorialVideoLauncher.kt | 61 +++++++++++++ .../common/media/MemorialVideoLauncherTest.kt | 12 +-- .../navigation/AfternoteNavGraphDetail.kt | 57 ++---------- .../detail/MemorialReceivedDetailScreen.kt | 63 +------------- .../src/main/res/values/strings.xml | 1 + .../navigation/MemorialVideoLauncherTest.kt | 86 ------------------- 7 files changed, 84 insertions(+), 200 deletions(-) create mode 100644 core/common/src/main/kotlin/com/afternote/core/common/media/MemorialVideoLauncher.kt rename feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/receiver/detail/ReceivedMemorialVideoLauncherTest.kt => core/common/src/test/java/com/afternote/core/common/media/MemorialVideoLauncherTest.kt (89%) delete mode 100644 feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/author/navigation/MemorialVideoLauncherTest.kt 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 d1d35e51b7..c415a443d3 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 android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -34,8 +32,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 @@ -171,7 +169,7 @@ private fun ReceiverVideoSection( // clip 을 clickable 앞에: 눌림 피드백이 InfoCard 의 12dp 둥근 모서리 안에서만 그려지게 (모서리 밖 사각 번짐 방지) .clip(RoundedCornerShape(12.dp)) .clickable { - launchReceivedMemorialVideo( + launchMemorialVideo( videoUrl = memorialVideoUrl, startActivity = context::startActivity, // 원인이 다르면 문구도 달라야 한다 — 아래 둘을 한 문장으로 덮으면 @@ -227,63 +225,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 04f38832ce..1e55447325 100644 --- a/feature/afternote/presentation/src/main/res/values/strings.xml +++ b/feature/afternote/presentation/src/main/res/values/strings.xml @@ -205,6 +205,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) - } -}