Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ android {

dependencies {
implementation(libs.androidx.work.runtime.ktx)

// launchMemorialVideo 의 URL 검증이 android.net.Uri 파싱에 기대므로 JVM 테스트에 Android
// 구현이 필요하다. 매니페스트는 읽지 않으므로 isIncludeAndroidResources 는 켜지 않는다.
testImplementation(libs.robolectric)
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 =
Expand All @@ -24,7 +24,7 @@ class ReceivedMemorialVideoLauncherTest {
var unavailableCount = 0

urls.forEach { url ->
launchReceivedMemorialVideo(
launchMemorialVideo(
videoUrl = url,
startActivity = { startedIntents += it },
onRejected = { rejectedCount += 1 },
Expand All @@ -40,6 +40,8 @@ class ReceivedMemorialVideoLauncherTest {

@Test
fun `http와 https가 아닌 URL이나 잘못된 URL은 실행하지 않고 거부로 안내한다`() {
// 두 판의 거부 목록 합집합이다. 승격 전에는 이 목록이 갈려 있어, 한쪽에만 있는
// 스킴(intent:·tel:)이 다른 쪽에서 회귀해도 아무도 몰랐다 (#1436).
val rejectedUrls =
listOf(
"",
Expand All @@ -56,7 +58,7 @@ class ReceivedMemorialVideoLauncherTest {
var unavailableCount = 0

rejectedUrls.forEach { url ->
launchReceivedMemorialVideo(
launchMemorialVideo(
videoUrl = url,
startActivity = { startedCount += 1 },
onRejected = { rejectedCount += 1 },
Expand All @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
<string name="feature_afternote_memorial_detail_title_suffix_no_name">에 대한\n기록</string>
<string name="feature_afternote_memorial_detail_sharing_notice">이 추억 노트는 지정된 신뢰할 연락처에게만 공유되며,\n안전하게 보관됩니다.</string>
<string name="feature_afternote_memorial_video_no_app">영상을 재생할 수 있는 앱이 없습니다.</string>
<string name="feature_afternote_memorial_video_invalid_url">영상 주소가 올바르지 않아 재생할 수 없습니다.</string>

<!-- Afternote editor (author) — UI strings -->
<string name="afternote_editor_screen_title">애프터노트 작성하기</string>
Expand Down

This file was deleted.

Loading