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 = EntryDestination.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/navigation/BuyOrNotNavHost.kt b/app/src/main/java/com/sseotdabwa/buyornot/navigation/BuyOrNotNavHost.kt
index 26bc3977..3ba18169 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 ->
@@ -89,17 +107,18 @@ fun BuyOrNotNavHost(
)
homeScreen(
- onLoginClick = navController::navigateForceToLogin,
onNotificationClick = navController::navigateToNotification,
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/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..fdc2fa2c 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.EntryDestination
/**
- * `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
+): EntryDestination? {
+ if (feedId != null) return EntryDestination.FEED_DETAIL
if (screen == null) return null
- return PushDestination.entries
+ return EntryDestination.entries
.find { it.name == screen }
- ?.takeIf { it != PushDestination.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 ea62c36b..7138633c 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.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
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일 때 존재가 보장된다.
+ EntryDestination.FEED_DETAIL ->
navigation.feedId?.let { navController.navigateToFeedDetail(it, navigation.notificationId) }
- PushDestination.FEED_CREATE -> navController.navigateToUpload()
+ EntryDestination.FEED_CREATE -> navController.navigateToUpload()
// 인증 통과 직후엔 이미 홈이지만, 다른 화면에 있다가 탭한 경우엔 홈으로 되돌려야 한다.
// 홈이 중복으로 쌓이지 않게 기존 홈을 걷어내고 하나만 남긴다.
- PushDestination.HOME ->
+ EntryDestination.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 70%
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..1b4a73f2 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.EntryDestination
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(EntryDestination.FEED_DETAIL, pushDestinationOf("FEED_DETAIL", 169L))
}
@Test
fun `마케팅_투표_미등록은_홈으로_보낸다`() {
- assertEquals(PushDestination.HOME, pushDestinationOf("HOME", null))
+ assertEquals(EntryDestination.HOME, pushDestinationOf("HOME", null))
}
@Test
fun `재참여_온보딩_마케팅은_투표_등록_화면으로_보낸다`() {
- assertEquals(PushDestination.FEED_CREATE, pushDestinationOf("FEED_CREATE", null))
+ assertEquals(EntryDestination.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(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/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..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
@@ -43,4 +43,43 @@ 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"
+ }
+ }
+
+ /**
+ * 더보기 메뉴에서 «공유하기»를 탭해 공유 시트를 띄움.
+ *
+ * 공유 시트에서 실제로 어떤 앱을 골랐는지, 보내기를 완료했는지는 **알 수 없다** —
+ * 시스템 시트의 선택 결과가 앱으로 돌아오지 않는다. 이 이벤트는 «공유 의도»까지만 센다.
+ * 실제 도달은 [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 3ec16f39..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
@@ -81,6 +81,20 @@ 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"
+ }
+ is AnalyticsEvent.ShareClicked -> {
+ props.put("feed_id", feedId)
+ props.put("is_owner", isOwner)
+ "share_clicked"
+ }
}
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/EntryDestination.kt b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/EntryDestination.kt
new file mode 100644
index 00000000..9ebc7552
--- /dev/null
+++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/EntryDestination.kt
@@ -0,0 +1,12 @@
+package com.sseotdabwa.buyornot.core.common.deeplink
+
+/**
+ * 외부 유입(푸시 알림·앱 링크)으로 진입했을 때 이동할 화면.
+ *
+ * FCM data의 `screen` 값과 이름이 1:1 대응한다. 앱 링크는 항상 [FEED_DETAIL]이다.
+ */
+enum class EntryDestination {
+ FEED_DETAIL,
+ HOME,
+ FEED_CREATE,
+}
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..c524d745
--- /dev/null
+++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/FeedLinkParser.kt
@@ -0,0 +1,28 @@
+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 }
+}
+
+/** 링크를 읽는 쪽([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/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..87cfe9c1
--- /dev/null
+++ b/core/common/src/main/java/com/sseotdabwa/buyornot/core/common/deeplink/PendingNavigation.kt
@@ -0,0 +1,51 @@
+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 [EntryDestination.FEED_DETAIL]일 때만 존재한다.
+ * @param notificationId 읽음 처리에 쓴다. 마케팅 알림과 앱 링크에는 없다.
+ */
+data class PendingNavigation(
+ val destination: EntryDestination,
+ 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()
+
+ 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/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/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 56%
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..58be6a27 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(EntryDestination.FEED_CREATE))
- assertEquals(PushDestination.FEED_CREATE, store.pending.value?.destination)
+ assertEquals(EntryDestination.FEED_CREATE, store.pending.value?.destination)
}
@Test
fun `여러_번_읽어도_소비되지_않는다`() {
// Activity 재생성 시 새 Composition이 같은 값을 다시 읽어야 이동이 살아남는다.
// 읽기만으로 지워지면 재생성 구간에서 목적지가 사라진다.
- store.set(PendingPushNavigation(PushDestination.HOME))
+ store.set(PendingNavigation(EntryDestination.HOME))
- assertEquals(PushDestination.HOME, store.pending.value?.destination)
- assertEquals(PushDestination.HOME, store.pending.value?.destination)
- assertEquals(PushDestination.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(PendingPushNavigation(PushDestination.HOME))
+ store.set(PendingNavigation(EntryDestination.HOME))
store.consume()
@@ -45,18 +45,18 @@ class PendingPushNavigationStoreTest {
@Test
fun `나중에_들어온_알림이_이전_목적지를_덮어쓴다`() {
// 인증 대기 중 알림을 연달아 탭하면 마지막 것으로 가는 게 사용자 기대에 맞다.
- store.set(PendingPushNavigation(PushDestination.HOME))
+ store.set(PendingNavigation(EntryDestination.HOME))
- store.set(PendingPushNavigation(PushDestination.FEED_DETAIL, feedId = 169L, notificationId = 42L))
+ store.set(PendingNavigation(EntryDestination.FEED_DETAIL, feedId = 169L, notificationId = 42L))
- assertEquals(PushDestination.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(PendingPushNavigation(PushDestination.FEED_CREATE))
+ store.set(PendingNavigation(EntryDestination.FEED_CREATE))
assertNull(store.pending.value?.feedId)
assertNull(store.pending.value?.notificationId)
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/FeedCard.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt
index 145e2089..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
@@ -83,6 +83,8 @@ fun FeedCard(
onDeleteClick: () -> Unit = {},
onReportClick: () -> Unit = {},
onBlockClick: () -> Unit = {},
+ onShareClick: () -> Unit = {},
+ canBlock: Boolean = true,
showMoreButton: Boolean = true,
productLink: String? = null,
onLinkClick: (url: String) -> Unit = {},
@@ -108,6 +110,8 @@ fun FeedCard(
onDeleteClick = onDeleteClick,
onReportClick = onReportClick,
onBlockClick = onBlockClick,
+ onShareClick = onShareClick,
+ canBlock = canBlock,
)
Spacer(modifier = Modifier.height(16.dp))
@@ -179,6 +183,8 @@ private fun FeedCardHeader(
onDeleteClick: () -> Unit,
onReportClick: () -> Unit,
onBlockClick: () -> Unit,
+ onShareClick: () -> Unit,
+ canBlock: Boolean,
modifier: Modifier = Modifier,
) {
val isInPreviewMode = LocalInspectionMode.current
@@ -252,22 +258,35 @@ private fun FeedCardHeader(
.clickable { showMenu = true },
tint = BuyOrNotTheme.colors.gray500,
)
+ val shareMenuItem =
+ "공유하기" to {
+ showMenu = false
+ onShareClick()
+ }
val ownerMenuItems =
listOf(
+ shareMenuItem,
"삭제하기" to {
showMenu = false
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/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/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/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/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..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
@@ -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
@@ -18,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
@@ -69,6 +71,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 +108,15 @@ class SplashViewModel @Inject constructor(
false
}
- delay(SPLASH_TIMEOUT_MILLIS)
+ // 딥링크로 들어왔으면 브랜딩용 고정 대기를 건너뛴다. 링크를 누른 사용자는 특정 콘텐츠를
+ // 보러 온 것이고, 웹은 같은 URL에서 즉시 보여주므로 2.3초를 세우면 앱만 불친절해진다.
+ // 업데이트 팝업 판단은 아래에서 그대로 수행하므로 강제 업데이트는 여전히 막힌다.
+ // 대기 중에 딥링크가 도착할 수도 있다(이미 실행 중인 앱에 링크를 탭하면 onNewIntent로 온다).
+ // 진입 시점에 한 번만 확인하면 그 사용자는 고정 대기를 끝까지 기다리게 되므로,
+ // «pending 도착»과 타임아웃을 경쟁시킨다. 이미 도착해 있으면 first()가 즉시 반환한다.
+ withTimeoutOrNull(SPLASH_TIMEOUT_MILLIS) {
+ pendingNavigationStore.pending.filterNotNull().first()
+ }
// 업데이트 다이얼로그 타입 결정
val updateInfo = updateInfoDeferred.await()
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..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,11 +21,11 @@ data class HomeRoute(
)
fun NavGraphBuilder.homeScreen(
- onLoginClick: () -> Unit = {},
onNotificationClick: () -> Unit = {},
onProfileClick: () -> Unit = {},
onUploadClick: () -> Unit = {},
onLinkClick: (url: String) -> Unit = {},
+ onShareClick: (feedId: Long, title: String) -> Unit = { _, _ -> },
onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> },
) {
composable(
@@ -56,11 +56,11 @@ fun NavGraphBuilder.homeScreen(
}
HomeScreen(
- onLoginClick = onLoginClick,
onNotificationClick = onNotificationClick,
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..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)
@@ -120,6 +124,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..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,11 +105,11 @@ import kotlinx.coroutines.launch
*/
@Composable
fun HomeRoute(
- onLoginClick: () -> Unit = {},
onNotificationClick: () -> Unit = {},
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(),
@@ -154,11 +152,11 @@ fun HomeRoute(
HomeScreen(
uiState = uiState,
snackbarHostState = snackbarHostState,
- onLoginClick = onLoginClick,
onNotificationClick = onNotificationClick,
onProfileClick = onProfileClick,
onUploadClick = onUploadClick,
onLinkClick = onLinkClick,
+ onShareClick = onShareClick,
onImageClick = onImageClick,
onIntent = viewModel::handleIntent,
)
@@ -171,11 +169,11 @@ fun HomeRoute(
fun HomeScreen(
uiState: HomeUiState,
onIntent: (HomeIntent) -> Unit,
- onLoginClick: () -> Unit = {},
onNotificationClick: () -> Unit = {},
onProfileClick: () -> Unit = {},
onUploadClick: () -> Unit = {},
onLinkClick: (url: String) -> Unit = {},
+ onShareClick: (feedId: Long, title: String) -> Unit = { _, _ -> },
onImageClick: (imageUrls: List, page: Int) -> Unit = { _, _ -> },
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
@@ -226,11 +224,11 @@ fun HomeScreen(
uiState = uiState,
onIntent = onIntent,
contentPadding = innerPadding,
- onLoginClick = onLoginClick,
onNotificationClick = onNotificationClick,
onProfileClick = onProfileClick,
onUploadClick = onUploadClick,
onLinkClick = onLinkClick,
+ onShareClick = onShareClick,
onImageClick = onImageClick,
)
@@ -244,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,
+ )
}
/**
@@ -364,11 +357,11 @@ private fun HomeFeedList(
uiState: HomeUiState,
onIntent: (HomeIntent) -> Unit,
contentPadding: PaddingValues,
- onLoginClick: () -> Unit,
onNotificationClick: () -> Unit,
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 +484,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,
)
@@ -571,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,
)
@@ -759,6 +754,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 +783,8 @@ private fun FeedItemCard(
onDeleteClick = { onDelete(feed.id) },
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/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..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
@@ -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,11 @@ fun NotificationDetailScreen(
onDeleteClick = { onIntent(NotificationDetailIntent.ShowDeleteDialog) },
onReportClick = { onIntent(NotificationDetailIntent.OnReportClicked) },
onBlockClick = { onIntent(NotificationDetailIntent.ShowBlockDialog) },
+ onShareClick = {
+ onIntent(NotificationDetailIntent.OnShareClicked)
+ onShareClick(feed.feedId, feed.title)
+ },
+ canBlock = !feed.author.isGuest,
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 {