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 {