From 1a79ba4ce263d61ef54456e2e11831c3cb398f5e Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Sun, 16 Aug 2026 11:35:20 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat/#146:=20=EC=95=B1=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=EB=A1=9C=20=ED=94=BC=EB=93=9C=20=EC=83=81=EC=84=B8=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://{host}/feed/{feedId} 앱 링크를 수신해 피드 상세로 이동한다. 인증 게이트를 통과한 뒤 한 번만 소비하도록 pending 저장소를 둔다. 인증 게이트 판정에 `::class.qualifiedName`을 쓰지 않는다. route 문자열은 @Serializable이 컴파일 시점에 박은 원본 FQN인데 R8은 클래스를 난독화하므로 release에서 둘이 절대 같아지지 않는다. 게이트가 항상 열린 상태가 되어 스플래시 위에서 상세로 이동한 뒤 스플래시의 홈 이동이 그것을 덮어써 콜드 스타트 딥링크가 유실됐다. NavDestination.hasRoute로 교체한다. 같은 원인으로 release에서 깨지던 홈 BackHandler, 스플래시·로그인 전체화면 판정, TTFD의 스플래시 제외도 함께 고친다. --- 리베이스 충돌 해결 (#149 병합 이후) --- develop에 머지된 #149가 같은 자리에 푸시 라우팅용 싱글톤 스토어를 도입해, 같은 역할의 스토어가 둘이 되는 충돌이 났다. 하나로 합쳤다. - 스토어를 core/common/deeplink로 통일한다. feature/auth의 스플래시가 hasPending을 읽어야 해서 app 모듈에는 둘 수 없다. - PendingFeedDeepLink(#146) + PendingPushNavigation(#149) → PendingNavigation. destination을 갖고 있어 앱 링크(항상 FEED_DETAIL)와 푸시(FEED_DETAIL·HOME· FEED_CREATE)를 함께 표현한다. - PushDestination enum → NavigationDestination으로 옮긴다. 앱 링크도 쓰는 개념이라 push에 매인 이름이 맞지 않는다. FCM screen 문자열 해석은 FCM 관심사라 pushDestinationOf는 app/notification에 남긴다. - handleFeedDeepLink(#146) + handlePushNavigation(#149) → handleNavigationIntent. Uri를 FCM extras보다 먼저 보는 #146의 순서를 유지해 앱 링크 유입이 push_opened를 오염시키지 않는다. 빌드 검증: assembleDebug / assembleRelease / 단위 테스트 전부 통과. Co-Authored-By: Claude Opus 5 --- app/build.gradle.kts | 17 ++++ app/src/main/AndroidManifest.xml | 22 +++++ .../com/sseotdabwa/buyornot/MainActivity.kt | 99 ++++++++++++++++--- .../notification/PendingPushNavigation.kt | 47 --------- ...PushDestination.kt => PushScreenParser.kt} | 21 ++-- .../com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt | 42 ++++---- ...inationTest.kt => PushScreenParserTest.kt} | 15 +-- .../buyornot/core/analytics/AnalyticsEvent.kt | 23 +++++ .../core/analytics/MixpanelAnalytics.kt | 9 ++ core/common/build.gradle.kts | 7 ++ .../core/common/deeplink/FeedLinkParser.kt | 27 +++++ .../common/deeplink/NavigationDestination.kt | 12 +++ .../core/common/deeplink/PendingNavigation.kt | 54 ++++++++++ .../common/deeplink/FeedLinkParserTest.kt | 69 +++++++++++++ .../deeplink/PendingNavigationStoreTest.kt | 28 +++--- .../feature/auth/ui/SplashViewModel.kt | 9 +- 16 files changed, 383 insertions(+), 118 deletions(-) delete mode 100644 app/src/main/java/com/sseotdabwa/buyornot/notification/PendingPushNavigation.kt rename app/src/main/java/com/sseotdabwa/buyornot/notification/{PushDestination.kt => PushScreenParser.kt} (68%) rename app/src/test/java/com/sseotdabwa/buyornot/notification/{PushDestinationTest.kt => PushScreenParserTest.kt} (69%) create mode 100644 core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt create mode 100644 core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/NavigationDestination.kt create mode 100644 core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt create mode 100644 core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParserTest.kt rename app/src/test/java/com/sseotdabwa/buyornot/notification/PendingPushNavigationStoreTest.kt => core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigationStoreTest.kt (55%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b983023f..680b2e18 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,6 +23,18 @@ val localProperties = fun gradlePropertyOrNull(key: String): String? = providers.gradleProperty(key).orNull?.takeUnless { it.isBlank() } +/** + * 앱 링크로 가로챌 host. 비밀값이 아니고, 누락되면 앱 링크가 «조용히» 안 되는 값이라 + * local.properties가 아니라 빌드 스크립트에 상수로 둔다. + * + * 도메인은 하이픈이 있는 buy-or-not, 패키지는 buyornot으로 표기가 다르다. + * 오타를 내면 빌드는 성공하고 검증만 실패하므로 여기 한 곳에서만 정의한다. + * + * 이 값과 짝이 되는 assetlinks.json이 각 host에 호스팅돼 있어야 autoVerify가 통과한다. + */ +val appLinkHostProd = "buy-or-not.com" +val appLinkHostDev = "dev.buy-or-not.com" + val firebaseDistributionTesters = gradlePropertyOrNull("firebaseAppDistributionTesters") val firebaseDistributionGroups = gradlePropertyOrNull("firebaseAppDistributionGroups") val firebaseDistributionReleaseNotes = gradlePropertyOrNull("firebaseAppDistributionReleaseNotes") @@ -71,6 +83,8 @@ android { signingConfig = signingConfigs.getByName("release") buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"${localProperties.getProperty("kakao.nativeAppKeyDebug", "")}\"") manifestPlaceholders["NATIVE_APP_KEY"] = localProperties.getProperty("kakao.nativeAppKeyDebug", "") + manifestPlaceholders["appLinkHost"] = appLinkHostDev + buildConfigField("String", "APP_LINK_HOST", "\"$appLinkHostDev\"") firebaseAppDistribution { artifactType = "APK" firebaseDistributionReleaseNotes?.let { releaseNotes = it } @@ -86,6 +100,8 @@ android { "proguard-rules.pro", ) signingConfig = signingConfigs.getByName("release") + manifestPlaceholders["appLinkHost"] = appLinkHostProd + buildConfigField("String", "APP_LINK_HOST", "\"$appLinkHostProd\"") firebaseAppDistribution { artifactType = "APK" firebaseDistributionReleaseNotes?.let { releaseNotes = it } @@ -104,6 +120,7 @@ android { dependencies { implementation(projects.domain) implementation(projects.core.analytics) + implementation(projects.core.common) implementation(projects.core.data) implementation(projects.core.network) implementation(projects.core.datastore) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3f97ed6c..46d8e461 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,6 +56,28 @@ + + + + + + + + + + ().descriptor.serialName)), onScreenChanged = { screen -> metricsStateHolder?.state?.putState(FRAME_STATE_SCREEN, screen) }, ) enableEdgeToEdge( @@ -76,13 +83,13 @@ class MainActivity : ComponentActivity() { ), ) setContent { - val pendingNavigation by pendingPushNavigationStore.pending.collectAsStateWithLifecycle() + val pendingNavigation by pendingNavigationStore.pending.collectAsStateWithLifecycle() BuyOrNotTheme { BuyOrNotApp( authEventBus = authEventBus, screenPerformanceTracker = screenPerformanceTracker, - pendingPushNavigation = pendingNavigation, - onPendingPushNavigationConsumed = { pendingPushNavigationStore.consume() }, + pendingNavigation = pendingNavigation, + onPendingNavigationConsumed = { pendingNavigationStore.consume() }, onBackPressed = { finish() }, onFinish = { finishAffinity() }, ) @@ -127,7 +134,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) handlePushOpened(intent) - handlePushNavigation(intent) + handleNavigationIntent(intent) } /** @@ -157,12 +164,18 @@ class MainActivity : ComponentActivity() { } /** - * 알림 payload의 `screen`·`feedId`로 이동 대상을 정해 pending 상태에 넣는다. + * 외부 유입 Intent에서 이동 대상을 정해 pending 상태에 넣는다. * - * 목적지 판정 규칙은 [pushDestinationOf]에 있다 — 여기서는 Intent를 분해하고 소비하는 일만 한다. + * 앱 링크(Uri)를 FCM extras보다 먼저 본다. 한 Intent가 양쪽을 동시에 만족하는 일은 없어야 하고, + * Uri 경로에서는 PushOpened를 절대 발행하지 않는다 (푸시 지표 오염 방지). + * + * FCM 경로의 목적지 판정 규칙은 [pushDestinationOf]에 있다 — 여기서는 Intent를 분해하고 + * 소비하는 일만 한다. */ - private fun handlePushNavigation(intent: Intent?) { + private fun handleNavigationIntent(intent: Intent?) { if (intent == null) return + if (handleAppLink(intent)) return + val screen = intent.getStringExtra(FcmKeys.SCREEN) val feedId = intent.longExtraOrNull(FcmKeys.FEED_ID) val notificationId = intent.longExtraOrNull(FcmKeys.NOTIFICATION_ID) @@ -177,23 +190,77 @@ class MainActivity : ComponentActivity() { val destination = pushDestinationOf(screen, feedId) if (BuildConfig.DEBUG) { - Log.d("FCM", "handlePushNavigation - screen=$screen, feedId=$feedId, destination=$destination") + Log.d("FCM", "handleNavigationIntent - screen=$screen, feedId=$feedId, destination=$destination") } // 알 수 없는 screen이거나 이동할 대상이 없으면 앱만 열린다 — 크래시 금지. if (destination == null) return - pendingPushNavigationStore.set( - PendingPushNavigation( + pendingNavigationStore.set( + PendingNavigation( destination = destination, feedId = feedId, notificationId = notificationId, ), ) } + + /** + * `https://{host}/feed/{feedId}` 앱 링크를 처리한다. + * + * @return 앱 링크 Intent였으면 true (파싱 성공 여부와 무관). FCM extras 경로를 건너뛰는 신호다. + */ + private fun handleAppLink(intent: Intent): Boolean { + val uri = intent.data ?: return false + + // 소비 마커. 이게 없으면 회전·프로세스 재생성 때 onCreate가 같은 Intent를 다시 받아 + // 이벤트가 재발행되고 피드 상세로도 재이동한다. extras 경로의 removeExtra와 같은 역할이다. + intent.data = null + setIntent(intent) + + val feedId = feedIdFromAppLink(uri.host, uri.pathSegments, BuildConfig.APP_LINK_HOST) + if (BuildConfig.DEBUG) { + Log.d("AppLink", "handleAppLink - uri=$uri, resolved feedId=$feedId, referrer=$referrer") + } + + // 파싱에 실패해도 이벤트는 반드시 발행한다. + // 건너뛰면 «링크가 안 온 것»과 «와서 깨진 것»을 구분할 수 없다. + analytics.track( + AnalyticsEvent.AppLinkOpened( + linkStatus = + if (feedId != null) { + AnalyticsEvent.AppLinkOpened.STATUS_RESOLVED + } else { + AnalyticsEvent.AppLinkOpened.STATUS_INVALID + }, + feedId = feedId, + // 발신 앱이 넣어줄 때만 존재한다. 없으면 «referrer 없음»이 하나의 유입 버킷이 된다. + referrer = referrer?.host, + utmSource = uri.queryParameterOrNull("utm_source"), + utmMedium = uri.queryParameterOrNull("utm_medium"), + utmCampaign = uri.queryParameterOrNull("utm_campaign"), + ), + ) + + // 파싱 실패 시 pending을 설정하지 않는다 → 앱만 열리고 홈으로. 크래시 금지. + // (NotificationDetailViewModel이 feedId를 checkNotNull 하므로 잘못된 값을 넘기면 죽는다.) + if (feedId != null) { + pendingNavigationStore.set( + PendingNavigation(destination = NavigationDestination.FEED_DETAIL, feedId = feedId), + ) + } + return true + } } /** 서버가 Long/String 중 무엇으로 보내도 읽히도록 두 형태를 모두 시도한다. */ private fun Intent.longExtraOrNull(key: String): Long? = getStringExtra(key)?.toLongOrNull() ?: getLongExtra(key, -1L).takeIf { it != -1L } + +/** + * 값이 없거나 비어 있으면 null. Mixpanel에서 «속성 자체를 생략»하기 위한 정규화다. + * + * opaque URI(`mailto:` 등)에 [Uri.getQueryParameter]를 호출하면 예외가 나므로 감싼다. + */ +private fun Uri.queryParameterOrNull(key: String): String? = runCatching { getQueryParameter(key) }.getOrNull()?.takeIf { it.isNotBlank() } diff --git a/app/src/main/java/com/sseotdabwa/buyornot/notification/PendingPushNavigation.kt b/app/src/main/java/com/sseotdabwa/buyornot/notification/PendingPushNavigation.kt deleted file mode 100644 index 40617932..00000000 --- a/app/src/main/java/com/sseotdabwa/buyornot/notification/PendingPushNavigation.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.sseotdabwa.buyornot.notification - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject -import javax.inject.Singleton - -/** - * 알림 탭으로 정해진 이동 대상. 인증 게이트를 통과할 때까지 들고 있다가 한 번만 소비된다. - * - * @param feedId [PushDestination.FEED_DETAIL]일 때만 존재한다. - * @param notificationId 읽음 처리에 쓴다. 마케팅 알림에는 없다. - */ -data class PendingPushNavigation( - val destination: PushDestination, - val feedId: Long? = null, - val notificationId: Long? = null, -) - -/** - * 인증 게이트 통과 전까지 이동 대상을 보관하는 저장소. - * - * **Activity가 아니라 여기 두는 이유**: `handlePushNavigation`이 Intent extras를 즉시 소비하므로, - * 이동이 실행되기 전에 Activity가 재생성되면 Intent에도 값이 없고 Activity 필드도 초기화되어 - * 목적지가 양쪽에서 사라진다. 재생성 경로는 회전만이 아니다 — `MainActivity`에 - * `configChanges`가 없어 폴더블 접기·폰트 크기·다크모드·언어 변경이 모두 재생성을 유발한다. - * - * 손실 구간도 짧지 않다. 스플래시가 고정 대기를 하고, 미로그인 사용자는 로그인 화면에서 - * 무한정 머문다 — 마케팅 알림의 대상이 바로 그 이탈 사용자다. - * - * `@Singleton`이라 Activity 수명과 무관하게 살아남는다. 프로세스가 죽으면 함께 사라지지만, - * 그 경우 Intent도 함께 사라지므로 어떤 방식으로도 복원할 수 없는 범위다. - */ -@Singleton -class PendingPushNavigationStore @Inject constructor() { - private val _pending = MutableStateFlow(null) - val pending: StateFlow = _pending.asStateFlow() - - fun set(navigation: PendingPushNavigation) { - _pending.value = navigation - } - - fun consume() { - _pending.value = null - } -} diff --git a/app/src/main/java/com/sseotdabwa/buyornot/notification/PushDestination.kt b/app/src/main/java/com/sseotdabwa/buyornot/notification/PushScreenParser.kt similarity index 68% rename from app/src/main/java/com/sseotdabwa/buyornot/notification/PushDestination.kt rename to app/src/main/java/com/sseotdabwa/buyornot/notification/PushScreenParser.kt index dd563abd..63082b0d 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/notification/PushDestination.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/notification/PushScreenParser.kt @@ -1,18 +1,9 @@ package com.sseotdabwa.buyornot.notification -/** - * 푸시 알림 탭 시 이동할 화면. - * - * 서버가 FCM data의 [FcmKeys.SCREEN]으로 내려주는 값과 이름이 1:1 대응한다. - */ -enum class PushDestination { - FEED_DETAIL, - HOME, - FEED_CREATE, -} +import com.sseotdabwa.buyornot.core.common.deeplink.NavigationDestination /** - * `screen`과 `feedId` 조합에서 이동 대상을 정한다. + * FCM data의 `screen`과 `feedId` 조합에서 이동 대상을 정한다. * * 계측기 없이 단위 테스트로 규칙을 고정하기 위해 Intent가 아니라 이미 분해된 값을 받는다 * (`feedIdFromAppLink`와 같은 이유). @@ -30,10 +21,10 @@ enum class PushDestination { fun pushDestinationOf( screen: String?, feedId: Long?, -): PushDestination? { - if (feedId != null) return PushDestination.FEED_DETAIL +): NavigationDestination? { + if (feedId != null) return NavigationDestination.FEED_DETAIL if (screen == null) return null - return PushDestination.entries + return NavigationDestination.entries .find { it.name == screen } - ?.takeIf { it != PushDestination.FEED_DETAIL } + ?.takeIf { it != NavigationDestination.FEED_DETAIL } } diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt index ea62c36b..824411d4 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt @@ -14,9 +14,12 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.sseotdabwa.buyornot.core.common.deeplink.NavigationDestination +import com.sseotdabwa.buyornot.core.common.deeplink.PendingNavigation import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotSnackBarHost import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.core.network.AuthEventBus @@ -32,8 +35,6 @@ import com.sseotdabwa.buyornot.feature.home.navigation.navigateToHome import com.sseotdabwa.buyornot.feature.notification.navigation.navigateToFeedDetail import com.sseotdabwa.buyornot.feature.upload.navigation.navigateToUpload import com.sseotdabwa.buyornot.navigation.BuyOrNotNavHost -import com.sseotdabwa.buyornot.notification.PendingPushNavigation -import com.sseotdabwa.buyornot.notification.PushDestination import com.sseotdabwa.buyornot.performance.ScreenPerformanceTracker import com.sseotdabwa.buyornot.performance.screenTraceNameOf @@ -41,8 +42,8 @@ import com.sseotdabwa.buyornot.performance.screenTraceNameOf fun BuyOrNotApp( authEventBus: AuthEventBus, screenPerformanceTracker: ScreenPerformanceTracker, - pendingPushNavigation: PendingPushNavigation? = null, - onPendingPushNavigationConsumed: () -> Unit = {}, + pendingNavigation: PendingNavigation? = null, + onPendingNavigationConsumed: () -> Unit = {}, onBackPressed: () -> Unit = {}, onFinish: () -> Unit = {}, viewModel: BuyOrNotViewModel = hiltViewModel(), @@ -54,7 +55,7 @@ fun BuyOrNotApp( val isFirstRun by viewModel.isFirstRun.collectAsStateWithLifecycle() - BackHandler(enabled = currentDestination?.route?.startsWith(HomeRoute::class.qualifiedName ?: "") == true) { + BackHandler(enabled = currentDestination?.hasRoute() == true) { onBackPressed() } @@ -71,24 +72,29 @@ fun BuyOrNotApp( // FCM 알림 탭으로 전달된 pending feedId를 인증 완료(Splash/Auth 통과) 후 한 번만 소비한다. // Splash/로그인 화면에서는 보류하고, 인증된 어떤 화면(Home·MyPage·Upload 등)에서든 즉시 이동한다. + // + // 화면 판정에 `::class.qualifiedName`을 쓰면 안 된다. route 문자열은 @Serializable이 컴파일 + // 시점에 박아넣은 원본 FQN인데, R8은 클래스를 난독화하므로 release에서 qualifiedName이 + // `xh3` 같은 이름을 돌려준다. 둘이 절대 같아지지 않아 게이트가 항상 열린 상태가 되고, + // 스플래시 위에서 상세로 이동한 뒤 스플래시의 홈 이동이 그것을 덮어써 딥링크가 유실된다. val currentRoute = currentDestination?.route val isPastAuthGate = - currentRoute != null && - currentRoute != SplashRoute::class.qualifiedName && - currentRoute != AuthRoute::class.qualifiedName - LaunchedEffect(pendingPushNavigation, isPastAuthGate) { - val navigation = pendingPushNavigation ?: return@LaunchedEffect + currentDestination != null && + !currentDestination.hasRoute() && + !currentDestination.hasRoute() + LaunchedEffect(pendingNavigation, isPastAuthGate) { + val navigation = pendingNavigation ?: return@LaunchedEffect if (!isPastAuthGate) return@LaunchedEffect when (navigation.destination) { - // feedId는 pushDestinationOf가 FEED_DETAIL일 때 존재를 보장한다. - PushDestination.FEED_DETAIL -> + // feedId는 앱 링크·FCM 양쪽 경로에서 FEED_DETAIL일 때 존재가 보장된다. + NavigationDestination.FEED_DETAIL -> navigation.feedId?.let { navController.navigateToFeedDetail(it, navigation.notificationId) } - PushDestination.FEED_CREATE -> navController.navigateToUpload() + NavigationDestination.FEED_CREATE -> navController.navigateToUpload() // 인증 통과 직후엔 이미 홈이지만, 다른 화면에 있다가 탭한 경우엔 홈으로 되돌려야 한다. // 홈이 중복으로 쌓이지 않게 기존 홈을 걷어내고 하나만 남긴다. - PushDestination.HOME -> + NavigationDestination.HOME -> navController.navigateToHome( navOptions { popUpTo { inclusive = true } @@ -96,13 +102,13 @@ fun BuyOrNotApp( }, ) } - onPendingPushNavigationConsumed() + onPendingNavigationConsumed() } val isFullscreen = - currentDestination?.route.let { route -> - route == SplashRoute::class.qualifiedName || route == AuthRoute::class.qualifiedName - } + currentDestination?.let { destination -> + destination.hasRoute() || destination.hasRoute() + } == true // 화면별 렌더링 지표. route에는 패키지 경로와 인자 자리표시자가 섞여 있어 짧은 이름으로 접어서 쓴다. // diff --git a/app/src/test/java/com/sseotdabwa/buyornot/notification/PushDestinationTest.kt b/app/src/test/java/com/sseotdabwa/buyornot/notification/PushScreenParserTest.kt similarity index 69% rename from app/src/test/java/com/sseotdabwa/buyornot/notification/PushDestinationTest.kt rename to app/src/test/java/com/sseotdabwa/buyornot/notification/PushScreenParserTest.kt index fe9b87ad..f5972ab6 100644 --- a/app/src/test/java/com/sseotdabwa/buyornot/notification/PushDestinationTest.kt +++ b/app/src/test/java/com/sseotdabwa/buyornot/notification/PushScreenParserTest.kt @@ -1,31 +1,32 @@ package com.sseotdabwa.buyornot.notification +import com.sseotdabwa.buyornot.core.common.deeplink.NavigationDestination import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -class PushDestinationTest { +class PushScreenParserTest { @Test fun `투표_알림은_피드_상세로_보낸다`() { - assertEquals(PushDestination.FEED_DETAIL, pushDestinationOf("FEED_DETAIL", 169L)) + assertEquals(NavigationDestination.FEED_DETAIL, pushDestinationOf("FEED_DETAIL", 169L)) } @Test fun `마케팅_투표_미등록은_홈으로_보낸다`() { - assertEquals(PushDestination.HOME, pushDestinationOf("HOME", null)) + assertEquals(NavigationDestination.HOME, pushDestinationOf("HOME", null)) } @Test fun `재참여_온보딩_마케팅은_투표_등록_화면으로_보낸다`() { - assertEquals(PushDestination.FEED_CREATE, pushDestinationOf("FEED_CREATE", null)) + assertEquals(NavigationDestination.FEED_CREATE, pushDestinationOf("FEED_CREATE", null)) } @Test fun `feedId가_있으면_screen보다_피드_상세가_이긴다`() { // 서버가 모순된 payload를 보내도 가장 구체적인 목적지로 간다 - assertEquals(PushDestination.FEED_DETAIL, pushDestinationOf("HOME", 169L)) - assertEquals(PushDestination.FEED_DETAIL, pushDestinationOf("FEED_CREATE", 169L)) - assertEquals(PushDestination.FEED_DETAIL, pushDestinationOf(null, 169L)) + assertEquals(NavigationDestination.FEED_DETAIL, pushDestinationOf("HOME", 169L)) + assertEquals(NavigationDestination.FEED_DETAIL, pushDestinationOf("FEED_CREATE", 169L)) + assertEquals(NavigationDestination.FEED_DETAIL, pushDestinationOf(null, 169L)) } @Test diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt index 309efe75..0435241c 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt @@ -43,4 +43,27 @@ sealed class AnalyticsEvent { val feedId: Long?, val notificationId: Long?, ) : AnalyticsEvent() + + /** + * 앱 링크(`https://{host}/feed/{feedId}`) 탭으로 앱이 열림. + * + * [PushOpened]와 절대 섞지 않는다 — 앱 링크 유입을 푸시 이벤트에 태우면 + * 푸시 CTR과 퍼널 전환율이 통째로 오염된다. + * + * 파싱에 실패해도([linkStatus] = `invalid`) 이벤트는 발행한다. + * 발행을 건너뛰면 «링크가 안 온 것»과 «와서 깨진 것»을 구분할 수 없다. + */ + data class AppLinkOpened( + val linkStatus: String, + val feedId: Long?, + val referrer: String?, + val utmSource: String?, + val utmMedium: String?, + val utmCampaign: String?, + ) : AnalyticsEvent() { + companion object { + const val STATUS_RESOLVED = "resolved" + const val STATUS_INVALID = "invalid" + } + } } diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt index 3ec16f39..b265f620 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt @@ -81,6 +81,15 @@ internal fun AnalyticsEvent.toMixpanelEvent(): Pair { if (notificationId != null) props.put("notification_id", notificationId) "push_opened" } + is AnalyticsEvent.AppLinkOpened -> { + props.put("link_status", linkStatus) + if (feedId != null) props.put("feed_id", feedId) + if (referrer != null) props.put("referrer", referrer) + if (utmSource != null) props.put("utm_source", utmSource) + if (utmMedium != null) props.put("utm_medium", utmMedium) + if (utmCampaign != null) props.put("utm_campaign", utmCampaign) + "app_link_opened" + } } return name to props } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index bfda55e6..662a2c6a 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -1,5 +1,7 @@ plugins { id("buyornot.android.library") + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) } android { @@ -8,4 +10,9 @@ android { dependencies { implementation(libs.kotlinx.coroutines.core) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + + testImplementation(libs.junit) } diff --git a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt new file mode 100644 index 00000000..eaf7af5c --- /dev/null +++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt @@ -0,0 +1,27 @@ +package com.sseotdabwa.buyornot.core.common.deeplink + +/** + * `https://{host}/feed/{feedId}` 앱 링크에서 feedId를 뽑는다. + * + * `android.net.Uri`를 받지 않고 이미 분해된 host·pathSegments를 받는 이유는 + * 이 규칙이 앱 링크 동작의 유일한 분기점이라 계측기 없이 단위 테스트로 고정해두기 위해서다. + * + * `?utm_source=` 같은 쿼리가 붙어도 깨지지 않도록 path 기준으로만 판단한다. + * + * @param host 수신 Uri의 host + * @param pathSegments 수신 Uri의 path segments (빈 세그먼트가 제거된 형태) + * @param expectedHost 이 빌드가 가로채기로 등록한 host (`BuildConfig.APP_LINK_HOST`) + * @return 유효한 feedId, 규칙에 맞지 않으면 null + */ +fun feedIdFromAppLink( + host: String?, + pathSegments: List, + expectedHost: String, +): Long? { + // intent-filter가 이미 host를 걸러주지만, adb·타 앱이 임의 Uri로 액티비티를 열 수 있어 한 번 더 본다. + if (host != expectedHost) return null + if (pathSegments.size < 2 || pathSegments[0] != FEED_PATH_SEGMENT) return null + return pathSegments[1].toLongOrNull()?.takeIf { it > 0L } +} + +private const val FEED_PATH_SEGMENT = "feed" diff --git a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/NavigationDestination.kt b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/NavigationDestination.kt new file mode 100644 index 00000000..70cc21d6 --- /dev/null +++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/NavigationDestination.kt @@ -0,0 +1,12 @@ +package com.sseotdabwa.buyornot.core.common.deeplink + +/** + * 외부 유입(푸시 알림·앱 링크)으로 진입했을 때 이동할 화면. + * + * FCM data의 `screen` 값과 이름이 1:1 대응한다. 앱 링크는 항상 [FEED_DETAIL]이다. + */ +enum class NavigationDestination { + FEED_DETAIL, + HOME, + FEED_CREATE, +} diff --git a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt new file mode 100644 index 00000000..fcfbb4ad --- /dev/null +++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt @@ -0,0 +1,54 @@ +package com.sseotdabwa.buyornot.core.common.deeplink + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 외부 유입으로 정해진 이동 대상. 인증 게이트를 통과할 때까지 들고 있다가 한 번만 소비된다. + * + * @param feedId [NavigationDestination.FEED_DETAIL]일 때만 존재한다. + * @param notificationId 읽음 처리에 쓴다. 마케팅 알림과 앱 링크에는 없다. + */ +data class PendingNavigation( + val destination: NavigationDestination, + val feedId: Long? = null, + val notificationId: Long? = null, +) + +/** + * 인증 게이트 통과 전까지 이동 대상을 보관하는 저장소. + * + * **Activity가 아니라 여기 두는 이유**: 진입 Intent의 extras·data는 처리 직후 소비되므로, + * 이동이 실행되기 전에 Activity가 재생성되면 Intent에도 값이 없고 Activity 필드도 초기화되어 + * 목적지가 양쪽에서 사라진다. 재생성 경로는 회전만이 아니다 — `MainActivity`에 `configChanges`가 + * 없어 폴더블 접기·폰트 크기·다크모드·언어 변경이 모두 재생성을 유발한다. + * + * 손실 구간도 짧지 않다. 스플래시가 고정 대기를 하고, 미로그인 사용자는 로그인 화면에서 + * 무한정 머문다 — 마케팅 알림의 대상이 바로 그 이탈 사용자다. + * + * `@Singleton`이라 Activity 수명과 무관하게 살아남는다. 프로세스가 죽으면 함께 사라지지만, + * 그 경우 Intent도 함께 사라지므로 어떤 방식으로도 복원할 수 없는 범위다. + * + * **`app`이 아니라 `core/common`에 두는 이유**: `MainActivity`가 쓰고 `BuyOrNotApp`이 소비하는 게 + * 주 흐름이지만, `feature/auth`의 스플래시도 «외부 유입인지»를 알아야 고정 대기를 건너뛸 수 있다. + * 두 모듈이 함께 보는 상태다. + */ +@Singleton +class PendingNavigationStore @Inject constructor() { + private val _pending = MutableStateFlow(null) + val pending: StateFlow = _pending.asStateFlow() + + /** 외부 유입으로 진입했는가. 스플래시가 고정 대기를 건너뛸지 판단할 때 쓴다. */ + val hasPending: Boolean get() = _pending.value != null + + fun set(navigation: PendingNavigation) { + _pending.value = navigation + } + + fun consume() { + _pending.value = null + } +} diff --git a/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParserTest.kt b/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParserTest.kt new file mode 100644 index 00000000..4dc14249 --- /dev/null +++ b/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParserTest.kt @@ -0,0 +1,69 @@ +package com.sseotdabwa.buyornot.core.common.deeplink + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +private const val HOST = "buy-or-not.com" + +class FeedLinkParserTest { + @Test + fun `feed_경로에서_feedId를_뽑는다`() { + assertEquals(123L, feedIdFromAppLink(HOST, listOf("feed", "123"), HOST)) + } + + @Test + fun `쿼리_파라미터는_path에_영향을_주지_않는다`() { + // ?utm_source=kakao 가 붙어도 pathSegments는 그대로다 + assertEquals(123L, feedIdFromAppLink(HOST, listOf("feed", "123"), HOST)) + } + + @Test + fun `feedId_뒤에_세그먼트가_더_붙어도_뽑는다`() { + assertEquals(123L, feedIdFromAppLink(HOST, listOf("feed", "123", "share"), HOST)) + } + + @Test + fun `host가_다르면_null을_반환한다`() { + assertNull(feedIdFromAppLink("dev.buy-or-not.com", listOf("feed", "123"), HOST)) + } + + @Test + fun `www가_붙은_host는_다른_도메인으로_취급한다`() { + assertNull(feedIdFromAppLink("www.buy-or-not.com", listOf("feed", "123"), HOST)) + } + + @Test + fun `host가_없으면_null을_반환한다`() { + assertNull(feedIdFromAppLink(null, listOf("feed", "123"), HOST)) + } + + @Test + fun `feed가_아닌_경로는_null을_반환한다`() { + assertNull(feedIdFromAppLink(HOST, listOf("terms"), HOST)) + assertNull(feedIdFromAppLink(HOST, listOf("notice", "123"), HOST)) + } + + @Test + fun `세그먼트가_부족하면_null을_반환한다`() { + assertNull(feedIdFromAppLink(HOST, listOf("feed"), HOST)) + assertNull(feedIdFromAppLink(HOST, emptyList(), HOST)) + } + + @Test + fun `숫자가_아닌_feedId는_null을_반환한다`() { + assertNull(feedIdFromAppLink(HOST, listOf("feed", "abc"), HOST)) + } + + @Test + fun `Long_범위를_넘는_feedId는_null을_반환한다`() { + assertNull(feedIdFromAppLink(HOST, listOf("feed", "99999999999999999999"), HOST)) + } + + @Test + fun `0과_음수_feedId는_null을_반환한다`() { + // NotificationDetailViewModel이 feedId를 checkNotNull 하고 서버도 양수만 안다 + assertNull(feedIdFromAppLink(HOST, listOf("feed", "0"), HOST)) + assertNull(feedIdFromAppLink(HOST, listOf("feed", "-1"), HOST)) + } +} diff --git a/app/src/test/java/com/sseotdabwa/buyornot/notification/PendingPushNavigationStoreTest.kt b/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigationStoreTest.kt similarity index 55% rename from app/src/test/java/com/sseotdabwa/buyornot/notification/PendingPushNavigationStoreTest.kt rename to core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigationStoreTest.kt index 72047431..f3dd48ca 100644 --- a/app/src/test/java/com/sseotdabwa/buyornot/notification/PendingPushNavigationStoreTest.kt +++ b/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigationStoreTest.kt @@ -1,33 +1,33 @@ -package com.sseotdabwa.buyornot.notification +package com.sseotdabwa.buyornot.core.common.deeplink import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test -class PendingPushNavigationStoreTest { - private val store = PendingPushNavigationStore() +class PendingNavigationStoreTest { + private val store = PendingNavigationStore() @Test fun `set한_목적지를_그대로_들고_있는다`() { - store.set(PendingPushNavigation(PushDestination.FEED_CREATE)) + store.set(PendingNavigation(NavigationDestination.FEED_CREATE)) - assertEquals(PushDestination.FEED_CREATE, store.pending.value?.destination) + assertEquals(NavigationDestination.FEED_CREATE, store.pending.value?.destination) } @Test fun `여러_번_읽어도_소비되지_않는다`() { // Activity 재생성 시 새 Composition이 같은 값을 다시 읽어야 이동이 살아남는다. // 읽기만으로 지워지면 재생성 구간에서 목적지가 사라진다. - store.set(PendingPushNavigation(PushDestination.HOME)) + store.set(PendingNavigation(NavigationDestination.HOME)) - assertEquals(PushDestination.HOME, store.pending.value?.destination) - assertEquals(PushDestination.HOME, store.pending.value?.destination) - assertEquals(PushDestination.HOME, store.pending.value?.destination) + assertEquals(NavigationDestination.HOME, store.pending.value?.destination) + assertEquals(NavigationDestination.HOME, store.pending.value?.destination) + assertEquals(NavigationDestination.HOME, store.pending.value?.destination) } @Test fun `consume하면_비워진다`() { - store.set(PendingPushNavigation(PushDestination.HOME)) + store.set(PendingNavigation(NavigationDestination.HOME)) store.consume() @@ -45,18 +45,18 @@ class PendingPushNavigationStoreTest { @Test fun `나중에_들어온_알림이_이전_목적지를_덮어쓴다`() { // 인증 대기 중 알림을 연달아 탭하면 마지막 것으로 가는 게 사용자 기대에 맞다. - store.set(PendingPushNavigation(PushDestination.HOME)) + store.set(PendingNavigation(NavigationDestination.HOME)) - store.set(PendingPushNavigation(PushDestination.FEED_DETAIL, feedId = 169L, notificationId = 42L)) + store.set(PendingNavigation(NavigationDestination.FEED_DETAIL, feedId = 169L, notificationId = 42L)) - assertEquals(PushDestination.FEED_DETAIL, store.pending.value?.destination) + assertEquals(NavigationDestination.FEED_DETAIL, store.pending.value?.destination) assertEquals(169L, store.pending.value?.feedId) assertEquals(42L, store.pending.value?.notificationId) } @Test fun `마케팅_알림은_feedId와_notificationId가_없다`() { - store.set(PendingPushNavigation(PushDestination.FEED_CREATE)) + store.set(PendingNavigation(NavigationDestination.FEED_CREATE)) assertNull(store.pending.value?.feedId) assertNull(store.pending.value?.notificationId) diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index 55d3c3c1..3d481ba6 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -6,6 +6,7 @@ import androidx.core.content.pm.PackageInfoCompat import androidx.lifecycle.viewModelScope import com.sseotdabwa.buyornot.core.analytics.performance.Performance import com.sseotdabwa.buyornot.core.analytics.performance.TraceNames +import com.sseotdabwa.buyornot.core.common.deeplink.PendingNavigationStore import com.sseotdabwa.buyornot.core.common.util.runCatchingCancellable import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel import com.sseotdabwa.buyornot.domain.model.AppUpdateInfo @@ -69,6 +70,7 @@ class SplashViewModel @Inject constructor( private val appPreferencesRepository: AppPreferencesRepository, private val userRepository: UserRepository, private val performance: Performance, + private val pendingNavigationStore: PendingNavigationStore, ) : BaseViewModel(SplashUiState()) { // 스플래시 진입부터 홈/로그인 분기가 결정되기까지. SPLASH_TIMEOUT_MILLIS 고정 딜레이가 // 하한이므로, 이 값이 딜레이를 넘어서면 원격 설정 조회가 병목이라는 뜻이다. @@ -105,7 +107,12 @@ class SplashViewModel @Inject constructor( false } - delay(SPLASH_TIMEOUT_MILLIS) + // 딥링크로 들어왔으면 브랜딩용 고정 대기를 건너뛴다. 링크를 누른 사용자는 특정 콘텐츠를 + // 보러 온 것이고, 웹은 같은 URL에서 즉시 보여주므로 2.3초를 세우면 앱만 불친절해진다. + // 업데이트 팝업 판단은 아래에서 그대로 수행하므로 강제 업데이트는 여전히 막힌다. + if (!pendingNavigationStore.hasPending) { + delay(SPLASH_TIMEOUT_MILLIS) + } // 업데이트 다이얼로그 타입 결정 val updateInfo = updateInfoDeferred.await() From c09d7ab19f799040a0add19d96ad2f8fb2a9ff5e Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Mon, 7 Sep 2026 22:38:34 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat/#146:=20=ED=94=BC=EB=93=9C=20=EB=8D=94?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EB=A9=94=EB=89=B4=EC=97=90=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=ED=95=98=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱 링크를 받는 쪽은 만들었지만 링크를 만들어 내보내는 경로가 없었다. 더보기 메뉴에 공유하기를 넣어 시스템 공유 시트로 링크를 내보낸다. 소유자: 공유하기 · 삭제하기 타인: 공유하기 · 신고하기 · 차단하기 - feedShareTextOf를 core/common/deeplink에 둔다. 링크를 만드는 규칙과 읽는 규칙(feedIdFromAppLink)이 어긋나면 공유 링크가 홈으로 떨어지므로 한 파일 옆에 두고, FEED_PATH_SEGMENT를 internal로 올려 같은 상수를 공유한다. 왕복 테스트(만든 링크를 파서가 다시 읽는다)로 두 규칙을 묶어둔다. - 제목을 URL 앞에 싣는다. OG 미리보기가 뜨지 않는 환경(문자·일부 앱)에서도 무엇을 물어보는지 전달돼야 한다. 제목이 비었거나 공백뿐이면 URL만 보낸다 — 따옴표만 남은 텍스트가 나가면 안 된다. - 공유 실행은 BuyOrNotNavHost에서 한다. host가 app 모듈의 BuildConfig에만 있고, onLinkClick·onImageClick이 이미 같은 층을 탄다. - share_clicked(feed_id, is_owner)를 ViewModel에서 발행한다. 시스템 시트의 선택 결과는 앱으로 돌아오지 않으므로 «공유 의도»까지만 측정한다 — 실제 도달은 반대편 app_link_opened로 보고 두 이벤트를 이어 퍼널을 만든다. - feature/notification에 core:analytics 의존성을 추가했다. 검증: 단위 테스트 7건(제목 유무·공백·왕복), assembleDebug/Release, 실기기에서 타인 피드 공유 → 시트에 «'제목' 살까 말까?\n{url}» 표시, share_clicked 1회(is_owner=false). 나머지 조합은 수동 테스트로 확인. Co-Authored-By: Claude Opus 5 --- .../buyornot/navigation/BuyOrNotNavHost.kt | 20 +++++++ .../buyornot/core/analytics/AnalyticsEvent.kt | 16 ++++++ .../core/analytics/MixpanelAnalytics.kt | 5 ++ .../core/common/deeplink/FeedLinkParser.kt | 3 +- .../core/common/deeplink/FeedShareText.kt | 28 ++++++++++ .../core/common/deeplink/FeedShareTextTest.kt | 56 +++++++++++++++++++ .../core/designsystem/components/FeedCard.kt | 10 ++++ .../feature/home/navigation/HomeNavigation.kt | 2 + .../buyornot/feature/home/ui/HomeContract.kt | 5 ++ .../buyornot/feature/home/ui/HomeScreen.kt | 11 ++++ .../buyornot/feature/home/ui/HomeViewModel.kt | 13 +++++ feature/notification/build.gradle.kts | 1 + .../navigation/NotificationNavigation.kt | 2 + .../ui/NotificationDetailContract.kt | 2 + .../ui/NotificationDetailScreen.kt | 7 +++ .../ui/NotificationDetailViewModel.kt | 12 ++++ 16 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedShareText.kt create mode 100644 core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedShareTextTest.kt diff --git a/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt index 26bc3977..f44bb8d1 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt @@ -1,11 +1,14 @@ package com.sseotdabwa.buyornot.navigation +import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.sseotdabwa.buyornot.BuildConfig +import com.sseotdabwa.buyornot.core.common.deeplink.feedShareTextOf import com.sseotdabwa.buyornot.core.network.AuthEvent import com.sseotdabwa.buyornot.core.network.AuthEventBus import com.sseotdabwa.buyornot.core.ui.crop.editScreen @@ -43,6 +46,21 @@ fun BuyOrNotNavHost( modifier: Modifier = Modifier, ) { val snackbarState = LocalSnackbarState.current + val context = LocalContext.current + + // 공유 URL의 host는 buildType별로 주입되는 BuildConfig.APP_LINK_HOST를 쓴다. + // 이 값이 app 모듈에만 있어 실행을 여기서 한다 — onLinkClick·onImageClick과 같은 층이다. + val shareFeed: (Long, String) -> Unit = { feedId, title -> + val sendIntent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + feedShareTextOf(BuildConfig.APP_LINK_HOST, feedId, title), + ) + } + context.startActivity(Intent.createChooser(sendIntent, null)) + } LaunchedEffect(authEventBus) { authEventBus.events.collect { event -> @@ -94,12 +112,14 @@ fun BuyOrNotNavHost( onProfileClick = navController::navigateToMyPage, onUploadClick = navController::navigateToUpload, onLinkClick = { url -> navController.navigateToWebView("", url) }, + onShareClick = shareFeed, onImageClick = { urls, page -> navController.navigateToImageViewer(urls, page) }, ) notificationGraph( onBackClick = navController::popBackStack, onNotificationClick = navController::navigateToNotificationDetail, onLinkClick = { url -> navController.navigateToWebView("", url) }, + onShareClick = shareFeed, onImageClick = { urls, page -> navController.navigateToImageViewer(urls, page) }, ) uploadScreen( diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt index 0435241c..c8e45975 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt @@ -66,4 +66,20 @@ sealed class AnalyticsEvent { const val STATUS_INVALID = "invalid" } } + + /** + * 더보기 메뉴에서 «공유하기»를 탭해 공유 시트를 띄움. + * + * 공유 시트에서 실제로 어떤 앱을 골랐는지, 보내기를 완료했는지는 **알 수 없다** — + * 시스템 시트의 선택 결과가 앱으로 돌아오지 않는다. 이 이벤트는 «공유 의도»까지만 센다. + * 실제 도달은 [AppLinkOpened]로 반대편에서 관측하고, 두 이벤트를 이어 붙여 + * «공유 → 유입» 퍼널을 만든다. + * + * @param isOwner 내 피드를 공유했는가. 표를 모으려는 공유와 남의 피드를 퍼뜨리는 공유는 + * 동기가 달라 분리해서 본다. + */ + data class ShareClicked( + val feedId: Long, + val isOwner: Boolean, + ) : AnalyticsEvent() } diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt index b265f620..f3515246 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt @@ -90,6 +90,11 @@ internal fun AnalyticsEvent.toMixpanelEvent(): Pair { if (utmCampaign != null) props.put("utm_campaign", utmCampaign) "app_link_opened" } + is AnalyticsEvent.ShareClicked -> { + props.put("feed_id", feedId) + props.put("is_owner", isOwner) + "share_clicked" + } } return name to props } diff --git a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt index eaf7af5c..c524d745 100644 --- a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt +++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt @@ -24,4 +24,5 @@ fun feedIdFromAppLink( return pathSegments[1].toLongOrNull()?.takeIf { it > 0L } } -private const val FEED_PATH_SEGMENT = "feed" +/** 링크를 읽는 쪽([feedIdFromAppLink])과 만드는 쪽([feedShareUrlOf])이 함께 보는 경로 세그먼트. */ +internal const val FEED_PATH_SEGMENT = "feed" diff --git a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedShareText.kt b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedShareText.kt new file mode 100644 index 00000000..6edc92ff --- /dev/null +++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedShareText.kt @@ -0,0 +1,28 @@ +package com.sseotdabwa.buyornot.core.common.deeplink + +/** + * 피드 공유 링크를 만든다. [feedIdFromAppLink]가 읽는 것과 같은 규칙이다 — + * 링크를 만드는 쪽과 읽는 쪽이 어긋나지 않도록 한 파일 옆에 둔다. + */ +fun feedShareUrlOf( + host: String, + feedId: Long, +): String = "https://$host/$FEED_PATH_SEGMENT/$feedId" + +/** + * 공유 시트에 실을 텍스트를 만든다. + * + * 제목을 앞에 붙이는 이유는 OG 미리보기가 뜨지 않는 환경(문자·일부 앱)에서도 무엇을 + * 물어보는지 전달되게 하려는 것이다. 카톡·슬랙에서는 OG 카드가 함께 뜬다. + * + * 제목이 비었거나 공백뿐이면 URL만 반환한다. 따옴표만 남은 텍스트가 나가면 안 된다. + */ +fun feedShareTextOf( + host: String, + feedId: Long, + title: String, +): String { + val url = feedShareUrlOf(host, feedId) + val trimmed = title.trim() + return if (trimmed.isEmpty()) url else "'$trimmed' 살까 말까?\n$url" +} diff --git a/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedShareTextTest.kt b/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedShareTextTest.kt new file mode 100644 index 00000000..bafce97b --- /dev/null +++ b/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedShareTextTest.kt @@ -0,0 +1,56 @@ +package com.sseotdabwa.buyornot.core.common.deeplink + +import org.junit.Assert.assertEquals +import org.junit.Test + +private const val HOST = "buy-or-not.com" + +class FeedShareTextTest { + @Test + fun `공유_URL은_앱_링크와_같은_형식이다`() { + val url = feedShareUrlOf(HOST, 169L) + + assertEquals("https://buy-or-not.com/feed/169", url) + // 만든 링크를 파서가 다시 읽을 수 있어야 한다 — 두 규칙이 어긋나면 공유 링크가 홈으로 떨어진다 + assertEquals(169L, feedIdFromAppLink(HOST, listOf("feed", "169"), HOST)) + } + + @Test + fun `제목이_있으면_제목과_URL을_함께_싣는다`() { + val text = feedShareTextOf(HOST, 169L, "부스터 포켓프라") + + assertEquals("'부스터 포켓프라' 살까 말까?\nhttps://buy-or-not.com/feed/169", text) + } + + @Test + fun `제목이_비어_있으면_URL만_싣는다`() { + // 따옴표만 남은 텍스트가 공유되면 안 된다 + assertEquals("https://buy-or-not.com/feed/169", feedShareTextOf(HOST, 169L, "")) + } + + @Test + fun `제목이_공백뿐이면_URL만_싣는다`() { + assertEquals("https://buy-or-not.com/feed/169", feedShareTextOf(HOST, 169L, " ")) + assertEquals("https://buy-or-not.com/feed/169", feedShareTextOf(HOST, 169L, "\n\t ")) + } + + @Test + fun `제목_앞뒤_공백은_다듬는다`() { + val text = feedShareTextOf(HOST, 169L, " 부스터 ") + + assertEquals("'부스터' 살까 말까?\nhttps://buy-or-not.com/feed/169", text) + } + + @Test + fun `제목의_따옴표나_줄바꿈은_그대로_둔다`() { + // 공유 텍스트는 평문이라 이스케이프할 대상이 없다. 임의로 손대면 원문이 훼손된다 + val text = feedShareTextOf(HOST, 169L, "이거 '진짜' 살까") + + assertEquals("'이거 '진짜' 살까' 살까 말까?\nhttps://buy-or-not.com/feed/169", text) + } + + @Test + fun `dev_host도_그대로_쓴다`() { + assertEquals("https://dev.buy-or-not.com/feed/1", feedShareUrlOf("dev.buy-or-not.com", 1L)) + } +} diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt index 145e2089..2ba6c958 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt @@ -83,6 +83,7 @@ fun FeedCard( onDeleteClick: () -> Unit = {}, onReportClick: () -> Unit = {}, onBlockClick: () -> Unit = {}, + onShareClick: () -> Unit = {}, showMoreButton: Boolean = true, productLink: String? = null, onLinkClick: (url: String) -> Unit = {}, @@ -108,6 +109,7 @@ fun FeedCard( onDeleteClick = onDeleteClick, onReportClick = onReportClick, onBlockClick = onBlockClick, + onShareClick = onShareClick, ) Spacer(modifier = Modifier.height(16.dp)) @@ -179,6 +181,7 @@ private fun FeedCardHeader( onDeleteClick: () -> Unit, onReportClick: () -> Unit, onBlockClick: () -> Unit, + onShareClick: () -> Unit, modifier: Modifier = Modifier, ) { val isInPreviewMode = LocalInspectionMode.current @@ -252,8 +255,14 @@ private fun FeedCardHeader( .clickable { showMenu = true }, tint = BuyOrNotTheme.colors.gray500, ) + val shareMenuItem = + "공유하기" to { + showMenu = false + onShareClick() + } val ownerMenuItems = listOf( + shareMenuItem, "삭제하기" to { showMenu = false onDeleteClick() @@ -261,6 +270,7 @@ private fun FeedCardHeader( ) val userMenuItems = listOf( + shareMenuItem, "신고하기" to { showMenu = false onReportClick() diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt index c2e47144..707ce290 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt @@ -26,6 +26,7 @@ fun NavGraphBuilder.homeScreen( onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, onLinkClick: (url: String) -> Unit = {}, + onShareClick: (feedId: Long, title: String) -> Unit = { _, _ -> }, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { composable( @@ -61,6 +62,7 @@ fun NavGraphBuilder.homeScreen( onProfileClick = onProfileClick, onUploadClick = onUploadClick, onLinkClick = onLinkClick, + onShareClick = onShareClick, onImageClick = onImageClick, initialTab = initialTab, ) diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt index b81a677c..87513e90 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt @@ -120,6 +120,11 @@ sealed interface HomeIntent { val feedId: String, ) : HomeIntent + data class OnShareClicked( + val feedId: String, + val isOwner: Boolean, + ) : HomeIntent + data object LoadFeeds : HomeIntent data object RefreshUnreadCount : HomeIntent diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt index 26ead95b..6e33e1b1 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt @@ -112,6 +112,7 @@ fun HomeRoute( onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, onLinkClick: (url: String) -> Unit = {}, + onShareClick: (feedId: Long, title: String) -> Unit = { _, _ -> }, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, initialTab: HomeTab = HomeTab.FEED, viewModel: HomeViewModel = hiltViewModel(), @@ -159,6 +160,7 @@ fun HomeRoute( onProfileClick = onProfileClick, onUploadClick = onUploadClick, onLinkClick = onLinkClick, + onShareClick = onShareClick, onImageClick = onImageClick, onIntent = viewModel::handleIntent, ) @@ -176,6 +178,7 @@ fun HomeScreen( onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, onLinkClick: (url: String) -> Unit = {}, + onShareClick: (feedId: Long, title: String) -> Unit = { _, _ -> }, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { @@ -231,6 +234,7 @@ fun HomeScreen( onProfileClick = onProfileClick, onUploadClick = onUploadClick, onLinkClick = onLinkClick, + onShareClick = onShareClick, onImageClick = onImageClick, ) @@ -369,6 +373,7 @@ private fun HomeFeedList( onProfileClick: () -> Unit, onUploadClick: () -> Unit, onLinkClick: (url: String) -> Unit, + onShareClick: (feedId: Long, title: String) -> Unit, onImageClick: (imageUrls: List, page: Int) -> Unit, modifier: Modifier = Modifier, ) { @@ -491,6 +496,10 @@ private fun HomeFeedList( onReport = { id -> onIntent(HomeIntent.OnReportClicked(id)) }, onBlock = { id -> onIntent(HomeIntent.ShowBlockDialog(id)) }, onLinkClick = onLinkClick, + onShare = { id, title, isOwner -> + onIntent(HomeIntent.OnShareClicked(id, isOwner)) + id.toLongOrNull()?.let { feedId -> onShareClick(feedId, title) } + }, onTooltipDismissed = { onIntent(HomeIntent.DismissTooltip) }, onImageClick = onImageClick, ) @@ -759,6 +768,7 @@ private fun FeedItemCard( onReport: (String) -> Unit, onBlock: (String) -> Unit, onLinkClick: (url: String) -> Unit, + onShare: (feedId: String, title: String, isOwner: Boolean) -> Unit, onTooltipDismissed: () -> Unit = {}, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { @@ -787,6 +797,7 @@ private fun FeedItemCard( onDeleteClick = { onDelete(feed.id) }, onReportClick = { onReport(feed.id) }, onBlockClick = { onBlock(feed.id) }, + onShareClick = { onShare(feed.id, feed.title, feed.isOwner) }, showMoreButton = !isGuest, productLink = feed.productLink, onLinkClick = onLinkClick, diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt index 1eee4aa0..bdd35709 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt @@ -188,6 +188,7 @@ class HomeViewModel @Inject constructor( handleDelete(intent.feedId) } is HomeIntent.OnReportClicked -> handleReport(intent.feedId) + is HomeIntent.OnShareClicked -> handleShareClicked(intent.feedId, intent.isOwner) is HomeIntent.ShowBlockDialog -> handleShowBlockDialog(intent.feedId) is HomeIntent.DismissBlockDialog -> updateState { @@ -525,6 +526,18 @@ class HomeViewModel @Inject constructor( } } + /** + * 공유 의도만 기록한다. 시스템 공유 시트에서 어떤 앱을 골랐는지·보내기를 완료했는지는 + * 앱으로 돌아오지 않으므로 측정할 수 없다. 실제 도달은 반대편의 `app_link_opened`로 본다. + */ + private fun handleShareClicked( + feedId: String, + isOwner: Boolean, + ) { + val id = feedId.toLongOrNull() ?: return + analytics.track(AnalyticsEvent.ShareClicked(feedId = id, isOwner = isOwner)) + } + private fun handleReport(feedId: String) { viewModelScope.launch { runCatchingCancellable { diff --git a/feature/notification/build.gradle.kts b/feature/notification/build.gradle.kts index 972ce8d1..67df59b2 100644 --- a/feature/notification/build.gradle.kts +++ b/feature/notification/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(projects.domain) implementation(projects.core.designsystem) implementation(projects.core.ui) + implementation(projects.core.analytics) implementation(libs.coil.compose) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.ui.unit) diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt index 6160cf6d..13d4ec02 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/navigation/NotificationNavigation.kt @@ -21,6 +21,7 @@ fun NavGraphBuilder.notificationGraph( onBackClick: () -> Unit, onNotificationClick: (Long, Long) -> Unit, onLinkClick: (url: String) -> Unit = {}, + onShareClick: (feedId: Long, title: String) -> Unit = { _, _ -> }, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, ) { composable { @@ -34,6 +35,7 @@ fun NavGraphBuilder.notificationGraph( NotificationDetailScreen( onBackClick = onBackClick, onLinkClick = onLinkClick, + onShareClick = onShareClick, onImageClick = onImageClick, ) } diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailContract.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailContract.kt index e9f6768b..e8741d0e 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailContract.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailContract.kt @@ -32,6 +32,8 @@ sealed interface NotificationDetailIntent { data object DismissBlockDialog : NotificationDetailIntent data object OnBlockConfirmed : NotificationDetailIntent + + data object OnShareClicked : NotificationDetailIntent } sealed interface NotificationDetailSideEffect { diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt index be338ecd..ff75359c 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt @@ -51,6 +51,7 @@ import com.sseotdabwa.buyornot.domain.model.VoteChoice fun NotificationDetailRoute( onBackClick: () -> Unit, onLinkClick: (url: String) -> Unit = {}, + onShareClick: (feedId: Long, title: String) -> Unit = { _, _ -> }, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, viewModel: NotificationDetailViewModel = hiltViewModel(), ) { @@ -78,6 +79,7 @@ fun NotificationDetailRoute( snackbarHostState = snackbarHostState, onBackClick = onBackClick, onLinkClick = onLinkClick, + onShareClick = onShareClick, onImageClick = onImageClick, onIntent = viewModel::handleIntent, ) @@ -89,6 +91,7 @@ fun NotificationDetailScreen( onBackClick: () -> Unit, onIntent: (NotificationDetailIntent) -> Unit, onLinkClick: (url: String) -> Unit = {}, + onShareClick: (feedId: Long, title: String) -> Unit = { _, _ -> }, onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { @@ -189,6 +192,10 @@ fun NotificationDetailScreen( onDeleteClick = { onIntent(NotificationDetailIntent.ShowDeleteDialog) }, onReportClick = { onIntent(NotificationDetailIntent.OnReportClicked) }, onBlockClick = { onIntent(NotificationDetailIntent.ShowBlockDialog) }, + onShareClick = { + onIntent(NotificationDetailIntent.OnShareClicked) + onShareClick(feed.feedId, feed.title) + }, showMoreButton = !uiState.isGuest, productLink = feed.productLink, onLinkClick = onLinkClick, diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailViewModel.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailViewModel.kt index 5fc7a32a..572b2136 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailViewModel.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailViewModel.kt @@ -3,6 +3,8 @@ package com.sseotdabwa.buyornot.feature.notification.ui import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.sseotdabwa.buyornot.core.analytics.Analytics +import com.sseotdabwa.buyornot.core.analytics.AnalyticsEvent import com.sseotdabwa.buyornot.core.common.util.runCatchingCancellable import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel @@ -20,6 +22,7 @@ private const val TAG = "NotificationDetailViewModel" @HiltViewModel class NotificationDetailViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, + private val analytics: Analytics, private val feedRepository: FeedRepository, private val notificationRepository: NotificationRepository, private val userRepository: UserRepository, @@ -65,6 +68,7 @@ class NotificationDetailViewModel @Inject constructor( NotificationDetailIntent.ShowBlockDialog -> updateState { it.copy(showBlockDialog = true) } NotificationDetailIntent.DismissBlockDialog -> updateState { it.copy(showBlockDialog = false) } NotificationDetailIntent.OnBlockConfirmed -> handleBlockConfirmed() + NotificationDetailIntent.OnShareClicked -> handleShareClicked() } } @@ -154,6 +158,14 @@ class NotificationDetailViewModel @Inject constructor( } } + /** + * 공유 의도만 기록한다. 시스템 공유 시트의 선택 결과는 앱으로 돌아오지 않아 측정할 수 없다 — + * 실제 도달은 반대편의 `app_link_opened`로 본다. + */ + private fun handleShareClicked() { + analytics.track(AnalyticsEvent.ShareClicked(feedId = feedId, isOwner = uiState.value.isOwner)) + } + private fun handleReport() { viewModelScope.launch { runCatchingCancellable { From a1abded5e96eb601c8c9d5ec89e36e86bff22b04 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Mon, 7 Sep 2026 22:41:36 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix/#146:=20=EB=B9=84=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EA=B8=80=EC=97=90=EB=8A=94=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=ED=95=98=EA=B8=B0=EB=A5=BC=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 계약 확인 결과(BuyOrNot-BE): - Feed.isGuestPost()는 user == null이고, FeedResponseV2.buildAuthorResponse가 이 글의 작성자를 FeedAuthorResponse(0L, ...)로 내려준다. - 차단은 POST /api/v1/users/blocks/{userId}로 실제 User를 대상으로 한다. userId=0으로는 성립하지 않으므로 항목을 누르면 실패할 수밖에 없다. - 신고는 POST /api/v1/feeds/{feedId}/report로 피드를 대상으로 한다. 비회원 글에도 유효해서 그대로 남긴다. Author.isGuest를 도메인에 두어 판별 규칙(userId == 0)을 한 곳에서 정의하고, FeedCard는 canBlock 플래그만 받는다. designsystem이 서버 계약을 알 필요가 없다. 비회원 글: 공유하기 · 신고하기 회원 글: 공유하기 · 신고하기 · 차단하기 Co-Authored-By: Claude Opus 5 --- .../core/designsystem/components/FeedCard.kt | 17 +++++++++++++---- .../sseotdabwa/buyornot/domain/model/Feed.kt | 14 +++++++++++++- .../buyornot/feature/home/ui/HomeContract.kt | 6 +++++- .../buyornot/feature/home/ui/HomeScreen.kt | 1 + .../notification/ui/NotificationDetailScreen.kt | 1 + 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt index 2ba6c958..8d13477b 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt @@ -84,6 +84,7 @@ fun FeedCard( onReportClick: () -> Unit = {}, onBlockClick: () -> Unit = {}, onShareClick: () -> Unit = {}, + canBlock: Boolean = true, showMoreButton: Boolean = true, productLink: String? = null, onLinkClick: (url: String) -> Unit = {}, @@ -110,6 +111,7 @@ fun FeedCard( onReportClick = onReportClick, onBlockClick = onBlockClick, onShareClick = onShareClick, + canBlock = canBlock, ) Spacer(modifier = Modifier.height(16.dp)) @@ -182,6 +184,7 @@ private fun FeedCardHeader( onReportClick: () -> Unit, onBlockClick: () -> Unit, onShareClick: () -> Unit, + canBlock: Boolean, modifier: Modifier = Modifier, ) { val isInPreviewMode = LocalInspectionMode.current @@ -268,16 +271,22 @@ private fun FeedCardHeader( onDeleteClick() }, ) + // 비회원 글은 차단할 대상(유저)이 없어 항목을 아예 노출하지 않는다. + // 신고는 유저가 아니라 피드를 대상으로 하므로 그대로 남긴다. val userMenuItems = - listOf( + listOfNotNull( shareMenuItem, "신고하기" to { showMenu = false onReportClick() }, - "차단하기" to { - showMenu = false - onBlockClick() + if (canBlock) { + "차단하기" to { + showMenu = false + onBlockClick() + } + } else { + null }, ) if (showMenu) { diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt index 1fdb7f21..5f6fe10f 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt @@ -40,7 +40,19 @@ data class Author( val userId: Long, val nickname: String, val profileImage: String?, -) +) { + /** + * 비회원이 작성한 글인가. + * + * 서버는 작성자가 없는 글(`Feed.user == null`)의 작성자를 `userId = 0`으로 내려준다 + * (`FeedResponseV2.buildAuthorResponse`). 차단 API는 실제 유저를 대상으로 하므로 + * 이 글은 차단할 수 없다. 신고는 유저가 아니라 피드를 대상으로 해서 그대로 가능하다. + */ + val isGuest: Boolean get() = userId == GUEST_AUTHOR_USER_ID +} + +/** 서버가 비회원 작성 글의 작성자 id로 쓰는 값. */ +const val GUEST_AUTHOR_USER_ID = 0L /** * 투표 선택 diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt index 87513e90..c6e37703 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.sseotdabwa.buyornot.core.designsystem.components.ImageAspectRatio import com.sseotdabwa.buyornot.core.designsystem.icon.IconResource import com.sseotdabwa.buyornot.domain.model.FeedCategory +import com.sseotdabwa.buyornot.domain.model.GUEST_AUTHOR_USER_ID import com.sseotdabwa.buyornot.domain.model.UserType /** @@ -45,7 +46,10 @@ data class FeedItem( val isOwner: Boolean, val authorUserId: Long, val productLink: String? = null, -) +) { + /** 비회원 작성 글. 차단 대상 유저가 없다 — [GUEST_AUTHOR_USER_ID] 참고. */ + val isGuestAuthor: Boolean get() = authorUserId == GUEST_AUTHOR_USER_ID +} /** * 홈 화면의 UI 상태 (MVI State) diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt index 6e33e1b1..2bf22994 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt @@ -798,6 +798,7 @@ private fun FeedItemCard( onReportClick = { onReport(feed.id) }, onBlockClick = { onBlock(feed.id) }, onShareClick = { onShare(feed.id, feed.title, feed.isOwner) }, + canBlock = !feed.isGuestAuthor, showMoreButton = !isGuest, productLink = feed.productLink, onLinkClick = onLinkClick, diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt index ff75359c..98760e3e 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt @@ -196,6 +196,7 @@ fun NotificationDetailScreen( onIntent(NotificationDetailIntent.OnShareClicked) onShareClick(feed.feedId, feed.title) }, + canBlock = !feed.author.isGuest, showMoreButton = !uiState.isGuest, productLink = feed.productLink, onLinkClick = onLinkClick, From e3b6a5cd37c9cab68ed7375a3735e67094387e83 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Mon, 7 Sep 2026 22:52:05 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat/#146:=20=EB=B9=84=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EA=B2=BD=EB=A1=9C=EC=99=80=20=EA=B2=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20TopBar=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그인 화면의 «비회원으로 시작하기»와, 게스트에게만 보이던 홈 TopBar를 없앤다. skipLogin()이 UserType.GUEST를 저장하고 홈으로 보내는 유일한 게스트 진입 경로였다. 이 버튼을 없애면 스플래시가 GUEST를 로그인 화면으로 보내므로 게스트는 홈에 도달할 수 없다 — 따라서 홈의 GuestTopBar 분기는 죽은 코드가 된다. 함께 걷어냈다. - LoginScreen: 버튼 + onGuestStartClick 체인 제거 - LoginContract/ViewModel: LoginIntent.SkipLogin과 skipLogin() 제거 - HomeScreen: HomeTopBarSection의 userType 분기를 없애고 HomeTopBar만 남긴다. 이제 죽은 onLoginClick 콜백 체인(HomeRoute → HomeScreen → content)과 HomeNavigation·BuyOrNotNavHost의 배선도 제거 - designsystem: GuestTopBar 컴포넌트와 프리뷰 삭제, BuyOrNotErrorView의 프리뷰 2곳은 HomeTopBar로 교체 UserType.GUEST 자체는 남겨둔다 — 게스트 투표 API(voteGuestFeed)와 탭 제한, 비회원 글 판별(Author.isGuest)에 계속 쓰인다. 검증: assembleDebug / assembleRelease / 단위 테스트 통과. Co-Authored-By: Claude Opus 5 --- .../buyornot/navigation/BuyOrNotNavHost.kt | 1 - .../components/BuyOrNotErrorView.kt | 4 +- .../core/designsystem/components/TopBar.kt | 60 ------------------- .../buyornot/feature/auth/ui/LoginContract.kt | 2 - .../buyornot/feature/auth/ui/LoginScreen.kt | 19 ------ .../feature/auth/ui/LoginViewModel.kt | 9 --- .../feature/home/navigation/HomeNavigation.kt | 2 - .../buyornot/feature/home/ui/HomeScreen.kt | 28 +++------ 8 files changed, 9 insertions(+), 116 deletions(-) diff --git a/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt index f44bb8d1..3ba18169 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt @@ -107,7 +107,6 @@ fun BuyOrNotNavHost( ) homeScreen( - onLoginClick = navController::navigateForceToLogin, onNotificationClick = navController::navigateToNotification, onProfileClick = navController::navigateToMyPage, onUploadClick = navController::navigateToUpload, diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/BuyOrNotErrorView.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/BuyOrNotErrorView.kt index 8b8dd9b7..2c3d938d 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/BuyOrNotErrorView.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/BuyOrNotErrorView.kt @@ -77,7 +77,7 @@ private fun SubPageErrorPreview() { private fun GuestErrorPreview() { BuyOrNotTheme { Column(modifier = Modifier.fillMaxSize()) { - GuestTopBar(onLoginClick = {}) + HomeTopBar(onNotificationClick = {}, onProfileClick = {}, unreadCount = 0) BuyOrNotDivider(size = BuyOrNotDividerSize.Small) BuyOrNotErrorView( message = "로그인 후 이용할 수 있는 서비스입니다", @@ -107,7 +107,7 @@ private fun BackOnlyErrorPreview() { private fun DoubleErrorPreview() { BuyOrNotTheme { Column(modifier = Modifier.fillMaxSize()) { - GuestTopBar(onLoginClick = {}) + HomeTopBar(onNotificationClick = {}, onProfileClick = {}, unreadCount = 0) BuyOrNotDivider(size = BuyOrNotDividerSize.Small) BuyOrNotTabRow( selectedTabIndex = 0, diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/TopBar.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/TopBar.kt index 18188456..cfbf40aa 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/TopBar.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/TopBar.kt @@ -1,6 +1,5 @@ package com.sseotdabwa.buyornot.core.designsystem.components -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,7 +12,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -21,10 +19,8 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.sseotdabwa.buyornot.core.designsystem.R import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme @@ -207,48 +203,6 @@ fun HomeTopBar( ) } -/** - * 5. 게스트/로그인 유도용 TopBar (로고 + 로그인 버튼) - */ -@Composable -fun GuestTopBar(onLoginClick: () -> Unit) { - BaseTopBar( - navigationIcon = { - Icon( - imageVector = BuyOrNotIcons.AppLogo.asImageVector(), - contentDescription = "App Logo", - modifier = Modifier.padding(start = 12.dp), - ) - }, - actions = { - TextButton( - onClick = onLoginClick, - shape = RoundedCornerShape(10.dp), - colors = - ButtonDefaults.buttonColors( - containerColor = BuyOrNotTheme.colors.gray0, - contentColor = BuyOrNotTheme.colors.gray800, - ), - border = - BorderStroke( - width = 1.dp, - color = BuyOrNotTheme.colors.gray300, - ), - contentPadding = - PaddingValues( - horizontal = 12.dp, - vertical = 12.dp, - ), - ) { - Text( - stringResource(R.string.login_signup), - style = BuyOrNotTheme.typography.subTitleS5SemiBold, - ) - } - }, - ) -} - @Preview(name = "BackTopBar", showBackground = true) @Composable private fun BackTopBarWithoutTitlePreview() { @@ -292,17 +246,3 @@ private fun HomeTopBarPreview() { ) } } - -@Preview(name = "GuestTopBar", showBackground = true) -@Composable -private fun GuestTopBarPreview() { - BuyOrNotTheme { - Box( - modifier = Modifier.background(BuyOrNotTheme.colors.gray0), - ) { - GuestTopBar( - onLoginClick = {}, - ) - } - } -} diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginContract.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginContract.kt index 3ee248bb..e62935cb 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginContract.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginContract.kt @@ -16,8 +16,6 @@ sealed interface LoginIntent { data class KakaoLogin( val context: Context, ) : LoginIntent - - data object SkipLogin : LoginIntent } sealed interface LoginSideEffect { diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginScreen.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginScreen.kt index b415fe00..23512e09 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginScreen.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginScreen.kt @@ -3,7 +3,6 @@ package com.sseotdabwa.buyornot.feature.auth.ui import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -87,7 +86,6 @@ fun AuthRoute( isLoading = uiState.isLoading, onGoogleLoginClick = { viewModel.handleIntent(LoginIntent.GoogleLogin(context)) }, onKakaoLoginClick = { viewModel.handleIntent(LoginIntent.KakaoLogin(context)) }, - onGuestStartClick = { viewModel.handleIntent(LoginIntent.SkipLogin) }, onTermsClick = onTermsClick, onPrivacyClick = onPrivacyClick, ) @@ -105,7 +103,6 @@ private fun LoginScreen( isLoading: Boolean, onGoogleLoginClick: () -> Unit, onKakaoLoginClick: () -> Unit, - onGuestStartClick: () -> Unit, onTermsClick: () -> Unit, onPrivacyClick: () -> Unit, ) { @@ -121,7 +118,6 @@ private fun LoginScreen( isLoading = isLoading, onGoogleLoginClick = onGoogleLoginClick, onKakaoLoginClick = onKakaoLoginClick, - onGuestStartClick = onGuestStartClick, onTermsClick = onTermsClick, onPrivacyClick = onPrivacyClick, ) @@ -145,7 +141,6 @@ private fun LoginInteractionSection( isLoading: Boolean, onGoogleLoginClick: () -> Unit, onKakaoLoginClick: () -> Unit, - onGuestStartClick: () -> Unit, onTermsClick: () -> Unit, onPrivacyClick: () -> Unit, ) { @@ -191,19 +186,6 @@ private fun LoginInteractionSection( Spacer(modifier = Modifier.height(28.dp)) - Text( - text = "비회원으로 시작하기", - modifier = - Modifier.clickable(enabled = !isLoading) { - onGuestStartClick() - }, - style = BuyOrNotTheme.typography.captionC2Medium, - color = BuyOrNotTheme.colors.gray700, - textDecoration = TextDecoration.Underline, - ) - - Spacer(modifier = Modifier.height(28.dp)) - LoginFooterTermsText( onTermsClick = onTermsClick, onPrivacyClick = onPrivacyClick, @@ -328,7 +310,6 @@ private fun LoginScreenPreview() { isLoading = false, onGoogleLoginClick = {}, onKakaoLoginClick = {}, - onGuestStartClick = {}, onTermsClick = {}, onPrivacyClick = {}, ) diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginViewModel.kt index 979f8977..26dccb96 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginViewModel.kt @@ -16,7 +16,6 @@ import com.kakao.sdk.common.model.ClientErrorCause import com.kakao.sdk.user.UserApiClient import com.sseotdabwa.buyornot.core.common.util.runCatchingCancellable import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel -import com.sseotdabwa.buyornot.domain.model.UserType import com.sseotdabwa.buyornot.domain.repository.AuthRepository import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import com.sseotdabwa.buyornot.domain.repository.UserRepository @@ -40,14 +39,6 @@ class LoginViewModel @Inject constructor( when (intent) { is LoginIntent.GoogleLogin -> googleLogin(intent.context) is LoginIntent.KakaoLogin -> kakaoLogin(intent.context) - LoginIntent.SkipLogin -> skipLogin() - } - } - - private fun skipLogin() { - viewModelScope.launch { - userPreferencesRepository.updateUserType(UserType.GUEST) - sendSideEffect(LoginSideEffect.NavigateToHome) } } diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt index 707ce290..388f6b80 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt @@ -21,7 +21,6 @@ data class HomeRoute( ) fun NavGraphBuilder.homeScreen( - onLoginClick: () -> Unit = {}, onNotificationClick: () -> Unit = {}, onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, @@ -57,7 +56,6 @@ fun NavGraphBuilder.homeScreen( } HomeScreen( - onLoginClick = onLoginClick, onNotificationClick = onNotificationClick, onProfileClick = onProfileClick, onUploadClick = onUploadClick, diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt index 2bf22994..bc92b7f8 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt @@ -77,7 +77,6 @@ import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotTabRow import com.sseotdabwa.buyornot.core.designsystem.components.ExpandableFloatingActionButton import com.sseotdabwa.buyornot.core.designsystem.components.FabOption import com.sseotdabwa.buyornot.core.designsystem.components.FeedCard -import com.sseotdabwa.buyornot.core.designsystem.components.GuestTopBar import com.sseotdabwa.buyornot.core.designsystem.components.HomeTopBar import com.sseotdabwa.buyornot.core.designsystem.components.NeutralButton import com.sseotdabwa.buyornot.core.designsystem.components.OptionSheet @@ -98,7 +97,6 @@ import kotlinx.coroutines.launch * 홈 화면 루트 컴포저블 * MVI 패턴을 적용하여 ViewModel을 통해 상태를 관리합니다. * - * @param onLoginClick 비회원일 때 로그인 버튼 클릭 콜백 * @param onNotificationClick 알림 아이콘 클릭 콜백 * @param onProfileClick 프로필 아이콘 클릭 콜백 * @param onUploadClick 업로드 화면으로 이동 콜백 @@ -107,7 +105,6 @@ import kotlinx.coroutines.launch */ @Composable fun HomeRoute( - onLoginClick: () -> Unit = {}, onNotificationClick: () -> Unit = {}, onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, @@ -155,7 +152,6 @@ fun HomeRoute( HomeScreen( uiState = uiState, snackbarHostState = snackbarHostState, - onLoginClick = onLoginClick, onNotificationClick = onNotificationClick, onProfileClick = onProfileClick, onUploadClick = onUploadClick, @@ -173,7 +169,6 @@ fun HomeRoute( fun HomeScreen( uiState: HomeUiState, onIntent: (HomeIntent) -> Unit, - onLoginClick: () -> Unit = {}, onNotificationClick: () -> Unit = {}, onProfileClick: () -> Unit = {}, onUploadClick: () -> Unit = {}, @@ -229,7 +224,6 @@ fun HomeScreen( uiState = uiState, onIntent = onIntent, contentPadding = innerPadding, - onLoginClick = onLoginClick, onNotificationClick = onNotificationClick, onProfileClick = onProfileClick, onUploadClick = onUploadClick, @@ -248,22 +242,17 @@ fun HomeScreen( @Composable private fun HomeTopBarSection( - userType: UserType, unreadNotificationCount: Int, - onLoginClick: () -> Unit, onNotificationClick: () -> Unit, onProfileClick: () -> Unit, ) { - when (userType) { - UserType.GUEST -> GuestTopBar(onLoginClick = onLoginClick) - UserType.SOCIAL -> { - HomeTopBar( - onNotificationClick = onNotificationClick, - onProfileClick = onProfileClick, - unreadCount = unreadNotificationCount, - ) - } - } + // 비회원 진입 경로를 없앤 뒤로는 홈에 도달한 사용자가 곧 로그인 사용자다. + // 스플래시가 UserType.GUEST를 로그인 화면으로 보내므로 게스트는 여기까지 오지 못한다. + HomeTopBar( + onNotificationClick = onNotificationClick, + onProfileClick = onProfileClick, + unreadCount = unreadNotificationCount, + ) } /** @@ -368,7 +357,6 @@ private fun HomeFeedList( uiState: HomeUiState, onIntent: (HomeIntent) -> Unit, contentPadding: PaddingValues, - onLoginClick: () -> Unit, onNotificationClick: () -> Unit, onProfileClick: () -> Unit, onUploadClick: () -> Unit, @@ -580,9 +568,7 @@ private fun HomeFeedList( exit = shrinkVertically(tween(200, easing = EaseInCubic), shrinkTowards = Alignment.Top) + fadeOut(tween(200)), ) { HomeTopBarSection( - userType = uiState.userType, unreadNotificationCount = uiState.unreadNotificationCount, - onLoginClick = onLoginClick, onNotificationClick = onNotificationClick, onProfileClick = onProfileClick, ) From bde40bd43ca99571e6c9e0a26eeee845c0bb8808 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Mon, 7 Sep 2026 23:17:49 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor/#146:=20EntryDestination=20?= =?UTF-8?q?=EA=B0=9C=EB=AA=85=20=EB=B0=8F=20=EC=8A=A4=ED=94=8C=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=EB=8C=80=EA=B8=B0=20=EC=A4=91=20=EB=94=A5=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=A6=89=EC=8B=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **1. NavigationDestination → EntryDestination** #149(푸시 라우팅)와 합친 뒤 이 enum이 앱 링크와 푸시 양쪽에서 쓰이게 됐다. PushDestination은 앱 링크 유입에 맞지 않았고, NavigationDestination은 androidx의 NavDestination과 한 파일에 공존해 혼동을 만들었다. BuyOrNotApp.kt import androidx.navigation.NavDestination.Companion.hasRoute import ...deeplink.NavigationDestination ← 둘 중 어느 쪽인지 매번 확인해야 했다 «외부에서 앱에 진입할 때의 목적지»라는 실제 의미를 담고 androidx와 충돌하지 않는 이름으로 바꿨다. HOME(마케팅 푸시)처럼 딥링크가 아닌 값도 포괄한다. FCM screen 문자열 해석은 푸시 관심사라 pushDestinationOf는 그대로 둔다. **2. 스플래시 고정 대기 중 도착한 딥링크를 즉시 처리 (리뷰 반영)** hasPending을 진입 시점에 한 번만 읽어서, 대기 중에 onNewIntent로 딥링크가 도착해도 delay가 끊기지 않았다. 이동이 유실되지는 않지만 최대 SPLASH_TIMEOUT_MILLIS(2.3초)만큼 늦어진다. «pending 도착»과 타임아웃을 경쟁시킨다. withTimeoutOrNull(SPLASH_TIMEOUT_MILLIS) { pendingNavigationStore.pending.filterNotNull().first() } 이미 도착해 있으면 first()가 즉시 반환하므로 기존의 «딥링크면 고정 대기 건너뛰기»도 이 한 줄에 포함된다. 유일한 소비자가 사라진 hasPending은 제거했다. 검증: ktlintCheck / assembleDebug / 단위 테스트 통과. Co-Authored-By: Claude Opus 5 --- .../com/sseotdabwa/buyornot/MainActivity.kt | 4 ++-- .../buyornot/notification/PushScreenParser.kt | 10 ++++----- .../com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt | 8 +++---- .../notification/PushScreenParserTest.kt | 14 ++++++------ ...tionDestination.kt => EntryDestination.kt} | 2 +- .../core/common/deeplink/PendingNavigation.kt | 7 ++---- .../deeplink/PendingNavigationStoreTest.kt | 22 +++++++++---------- .../feature/auth/ui/SplashViewModel.kt | 10 ++++++--- 8 files changed, 39 insertions(+), 38 deletions(-) rename core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/{NavigationDestination.kt => EntryDestination.kt} (89%) diff --git a/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt b/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt index 7b97a689..74f383df 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/MainActivity.kt @@ -16,7 +16,7 @@ import androidx.metrics.performance.PerformanceMetricsState import com.sseotdabwa.buyornot.core.analytics.Analytics import com.sseotdabwa.buyornot.core.analytics.AnalyticsEvent import com.sseotdabwa.buyornot.core.analytics.performance.Performance -import com.sseotdabwa.buyornot.core.common.deeplink.NavigationDestination +import com.sseotdabwa.buyornot.core.common.deeplink.EntryDestination import com.sseotdabwa.buyornot.core.common.deeplink.PendingNavigation import com.sseotdabwa.buyornot.core.common.deeplink.PendingNavigationStore import com.sseotdabwa.buyornot.core.common.deeplink.feedIdFromAppLink @@ -246,7 +246,7 @@ class MainActivity : ComponentActivity() { // (NotificationDetailViewModel이 feedId를 checkNotNull 하므로 잘못된 값을 넘기면 죽는다.) if (feedId != null) { pendingNavigationStore.set( - PendingNavigation(destination = NavigationDestination.FEED_DETAIL, feedId = feedId), + PendingNavigation(destination = EntryDestination.FEED_DETAIL, feedId = feedId), ) } return true diff --git a/app/src/main/java/com/sseotdabwa/buyornot/notification/PushScreenParser.kt b/app/src/main/java/com/sseotdabwa/buyornot/notification/PushScreenParser.kt index 63082b0d..fdc2fa2c 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/notification/PushScreenParser.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/notification/PushScreenParser.kt @@ -1,6 +1,6 @@ package com.sseotdabwa.buyornot.notification -import com.sseotdabwa.buyornot.core.common.deeplink.NavigationDestination +import com.sseotdabwa.buyornot.core.common.deeplink.EntryDestination /** * FCM data의 `screen`과 `feedId` 조합에서 이동 대상을 정한다. @@ -21,10 +21,10 @@ import com.sseotdabwa.buyornot.core.common.deeplink.NavigationDestination fun pushDestinationOf( screen: String?, feedId: Long?, -): NavigationDestination? { - if (feedId != null) return NavigationDestination.FEED_DETAIL +): EntryDestination? { + if (feedId != null) return EntryDestination.FEED_DETAIL if (screen == null) return null - return NavigationDestination.entries + return EntryDestination.entries .find { it.name == screen } - ?.takeIf { it != NavigationDestination.FEED_DETAIL } + ?.takeIf { it != EntryDestination.FEED_DETAIL } } diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt index 824411d4..7138633c 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotApp.kt @@ -18,7 +18,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions -import com.sseotdabwa.buyornot.core.common.deeplink.NavigationDestination +import com.sseotdabwa.buyornot.core.common.deeplink.EntryDestination import com.sseotdabwa.buyornot.core.common.deeplink.PendingNavigation import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotSnackBarHost import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme @@ -87,14 +87,14 @@ fun BuyOrNotApp( if (!isPastAuthGate) return@LaunchedEffect when (navigation.destination) { // feedId는 앱 링크·FCM 양쪽 경로에서 FEED_DETAIL일 때 존재가 보장된다. - NavigationDestination.FEED_DETAIL -> + EntryDestination.FEED_DETAIL -> navigation.feedId?.let { navController.navigateToFeedDetail(it, navigation.notificationId) } - NavigationDestination.FEED_CREATE -> navController.navigateToUpload() + EntryDestination.FEED_CREATE -> navController.navigateToUpload() // 인증 통과 직후엔 이미 홈이지만, 다른 화면에 있다가 탭한 경우엔 홈으로 되돌려야 한다. // 홈이 중복으로 쌓이지 않게 기존 홈을 걷어내고 하나만 남긴다. - NavigationDestination.HOME -> + EntryDestination.HOME -> navController.navigateToHome( navOptions { popUpTo { inclusive = true } diff --git a/app/src/test/java/com/sseotdabwa/buyornot/notification/PushScreenParserTest.kt b/app/src/test/java/com/sseotdabwa/buyornot/notification/PushScreenParserTest.kt index f5972ab6..1b4a73f2 100644 --- a/app/src/test/java/com/sseotdabwa/buyornot/notification/PushScreenParserTest.kt +++ b/app/src/test/java/com/sseotdabwa/buyornot/notification/PushScreenParserTest.kt @@ -1,6 +1,6 @@ package com.sseotdabwa.buyornot.notification -import com.sseotdabwa.buyornot.core.common.deeplink.NavigationDestination +import com.sseotdabwa.buyornot.core.common.deeplink.EntryDestination import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -8,25 +8,25 @@ import org.junit.Test class PushScreenParserTest { @Test fun `투표_알림은_피드_상세로_보낸다`() { - assertEquals(NavigationDestination.FEED_DETAIL, pushDestinationOf("FEED_DETAIL", 169L)) + assertEquals(EntryDestination.FEED_DETAIL, pushDestinationOf("FEED_DETAIL", 169L)) } @Test fun `마케팅_투표_미등록은_홈으로_보낸다`() { - assertEquals(NavigationDestination.HOME, pushDestinationOf("HOME", null)) + assertEquals(EntryDestination.HOME, pushDestinationOf("HOME", null)) } @Test fun `재참여_온보딩_마케팅은_투표_등록_화면으로_보낸다`() { - assertEquals(NavigationDestination.FEED_CREATE, pushDestinationOf("FEED_CREATE", null)) + assertEquals(EntryDestination.FEED_CREATE, pushDestinationOf("FEED_CREATE", null)) } @Test fun `feedId가_있으면_screen보다_피드_상세가_이긴다`() { // 서버가 모순된 payload를 보내도 가장 구체적인 목적지로 간다 - assertEquals(NavigationDestination.FEED_DETAIL, pushDestinationOf("HOME", 169L)) - assertEquals(NavigationDestination.FEED_DETAIL, pushDestinationOf("FEED_CREATE", 169L)) - assertEquals(NavigationDestination.FEED_DETAIL, pushDestinationOf(null, 169L)) + assertEquals(EntryDestination.FEED_DETAIL, pushDestinationOf("HOME", 169L)) + assertEquals(EntryDestination.FEED_DETAIL, pushDestinationOf("FEED_CREATE", 169L)) + assertEquals(EntryDestination.FEED_DETAIL, pushDestinationOf(null, 169L)) } @Test diff --git a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/NavigationDestination.kt b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/EntryDestination.kt similarity index 89% rename from core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/NavigationDestination.kt rename to core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/EntryDestination.kt index 70cc21d6..9ebc7552 100644 --- a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/NavigationDestination.kt +++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/EntryDestination.kt @@ -5,7 +5,7 @@ package com.sseotdabwa.buyornot.core.common.deeplink * * FCM data의 `screen` 값과 이름이 1:1 대응한다. 앱 링크는 항상 [FEED_DETAIL]이다. */ -enum class NavigationDestination { +enum class EntryDestination { FEED_DETAIL, HOME, FEED_CREATE, diff --git a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt index fcfbb4ad..87cfe9c1 100644 --- a/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt +++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt @@ -9,11 +9,11 @@ import javax.inject.Singleton /** * 외부 유입으로 정해진 이동 대상. 인증 게이트를 통과할 때까지 들고 있다가 한 번만 소비된다. * - * @param feedId [NavigationDestination.FEED_DETAIL]일 때만 존재한다. + * @param feedId [EntryDestination.FEED_DETAIL]일 때만 존재한다. * @param notificationId 읽음 처리에 쓴다. 마케팅 알림과 앱 링크에는 없다. */ data class PendingNavigation( - val destination: NavigationDestination, + val destination: EntryDestination, val feedId: Long? = null, val notificationId: Long? = null, ) @@ -41,9 +41,6 @@ class PendingNavigationStore @Inject constructor() { private val _pending = MutableStateFlow(null) val pending: StateFlow = _pending.asStateFlow() - /** 외부 유입으로 진입했는가. 스플래시가 고정 대기를 건너뛸지 판단할 때 쓴다. */ - val hasPending: Boolean get() = _pending.value != null - fun set(navigation: PendingNavigation) { _pending.value = navigation } diff --git a/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigationStoreTest.kt b/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigationStoreTest.kt index f3dd48ca..58be6a27 100644 --- a/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigationStoreTest.kt +++ b/core/common/src/test/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigationStoreTest.kt @@ -9,25 +9,25 @@ class PendingNavigationStoreTest { @Test fun `set한_목적지를_그대로_들고_있는다`() { - store.set(PendingNavigation(NavigationDestination.FEED_CREATE)) + store.set(PendingNavigation(EntryDestination.FEED_CREATE)) - assertEquals(NavigationDestination.FEED_CREATE, store.pending.value?.destination) + assertEquals(EntryDestination.FEED_CREATE, store.pending.value?.destination) } @Test fun `여러_번_읽어도_소비되지_않는다`() { // Activity 재생성 시 새 Composition이 같은 값을 다시 읽어야 이동이 살아남는다. // 읽기만으로 지워지면 재생성 구간에서 목적지가 사라진다. - store.set(PendingNavigation(NavigationDestination.HOME)) + store.set(PendingNavigation(EntryDestination.HOME)) - assertEquals(NavigationDestination.HOME, store.pending.value?.destination) - assertEquals(NavigationDestination.HOME, store.pending.value?.destination) - assertEquals(NavigationDestination.HOME, store.pending.value?.destination) + assertEquals(EntryDestination.HOME, store.pending.value?.destination) + assertEquals(EntryDestination.HOME, store.pending.value?.destination) + assertEquals(EntryDestination.HOME, store.pending.value?.destination) } @Test fun `consume하면_비워진다`() { - store.set(PendingNavigation(NavigationDestination.HOME)) + store.set(PendingNavigation(EntryDestination.HOME)) store.consume() @@ -45,18 +45,18 @@ class PendingNavigationStoreTest { @Test fun `나중에_들어온_알림이_이전_목적지를_덮어쓴다`() { // 인증 대기 중 알림을 연달아 탭하면 마지막 것으로 가는 게 사용자 기대에 맞다. - store.set(PendingNavigation(NavigationDestination.HOME)) + store.set(PendingNavigation(EntryDestination.HOME)) - store.set(PendingNavigation(NavigationDestination.FEED_DETAIL, feedId = 169L, notificationId = 42L)) + store.set(PendingNavigation(EntryDestination.FEED_DETAIL, feedId = 169L, notificationId = 42L)) - assertEquals(NavigationDestination.FEED_DETAIL, store.pending.value?.destination) + assertEquals(EntryDestination.FEED_DETAIL, store.pending.value?.destination) assertEquals(169L, store.pending.value?.feedId) assertEquals(42L, store.pending.value?.notificationId) } @Test fun `마케팅_알림은_feedId와_notificationId가_없다`() { - store.set(PendingNavigation(NavigationDestination.FEED_CREATE)) + store.set(PendingNavigation(EntryDestination.FEED_CREATE)) assertNull(store.pending.value?.feedId) assertNull(store.pending.value?.notificationId) diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt index 3d481ba6..d56ca1b6 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/SplashViewModel.kt @@ -19,9 +19,10 @@ import com.sseotdabwa.buyornot.domain.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.async -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException @@ -110,8 +111,11 @@ class SplashViewModel @Inject constructor( // 딥링크로 들어왔으면 브랜딩용 고정 대기를 건너뛴다. 링크를 누른 사용자는 특정 콘텐츠를 // 보러 온 것이고, 웹은 같은 URL에서 즉시 보여주므로 2.3초를 세우면 앱만 불친절해진다. // 업데이트 팝업 판단은 아래에서 그대로 수행하므로 강제 업데이트는 여전히 막힌다. - if (!pendingNavigationStore.hasPending) { - delay(SPLASH_TIMEOUT_MILLIS) + // 대기 중에 딥링크가 도착할 수도 있다(이미 실행 중인 앱에 링크를 탭하면 onNewIntent로 온다). + // 진입 시점에 한 번만 확인하면 그 사용자는 고정 대기를 끝까지 기다리게 되므로, + // «pending 도착»과 타임아웃을 경쟁시킨다. 이미 도착해 있으면 first()가 즉시 반환한다. + withTimeoutOrNull(SPLASH_TIMEOUT_MILLIS) { + pendingNavigationStore.pending.filterNotNull().first() } // 업데이트 다이얼로그 타입 결정