diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index e8ae2064..73a4ac73 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -38,6 +38,7 @@ jobs: echo "google.webClientId=${{ secrets.GOOGLE_WEB_CLIENT_ID }}" >> local.properties echo "kakao.nativeAppKey=${{ secrets.KAKAO_NATIVE_APP_KEY }}" >> local.properties echo "kakao.nativeAppKeyDebug=${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }}" >> local.properties + echo "mixpanel.token=${{ secrets.MIXPANEL_TOKEN }}" >> local.properties - name: Decode Keystore run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml index 75f22483..72ec5cec 100644 --- a/.github/workflows/distribute.yml +++ b/.github/workflows/distribute.yml @@ -56,6 +56,7 @@ jobs: echo "google.webClientId=${{ secrets.GOOGLE_WEB_CLIENT_ID }}" >> local.properties echo "kakao.nativeAppKey=${{ secrets.KAKAO_NATIVE_APP_KEY }}" >> local.properties echo "kakao.nativeAppKeyDebug=${{ secrets.KAKAO_NATIVE_APP_KEY_DEBUG }}" >> local.properties + echo "mixpanel.token=${{ secrets.MIXPANEL_TOKEN }}" >> local.properties - name: Decode Keystore run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > app/keystore.jks diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55c4ad9a..423ffeae 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,7 @@ android { dependencies { implementation(projects.domain) + implementation(projects.core.analytics) implementation(projects.core.data) implementation(projects.core.network) implementation(projects.core.datastore) diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts new file mode 100644 index 00000000..4205f166 --- /dev/null +++ b/core/analytics/build.gradle.kts @@ -0,0 +1,43 @@ +import java.util.Properties + +plugins { + id("buyornot.android.library") + alias(libs.plugins.hilt) + alias(libs.plugins.ksp) +} + +val localProperties = + Properties().apply { + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { load(it) } + } + } + +android { + namespace = "com.sseotdabwa.buyornot.core.analytics" + + buildFeatures { + buildConfig = true + } + + buildTypes { + debug { + buildConfigField("String", "MIXPANEL_TOKEN", "\"\"") + } + release { + buildConfigField( + "String", + "MIXPANEL_TOKEN", + "\"${localProperties.getProperty("mixpanel.token", "")}\"", + ) + } + } +} + +dependencies { + implementation(libs.mixpanel.android) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt new file mode 100644 index 00000000..5c22ae9b --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt @@ -0,0 +1,5 @@ +package com.sseotdabwa.buyornot.core.analytics + +interface Analytics { + fun track(event: AnalyticsEvent) +} 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 new file mode 100644 index 00000000..5423d3c7 --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/AnalyticsEvent.kt @@ -0,0 +1,34 @@ +package com.sseotdabwa.buyornot.core.analytics + +sealed class AnalyticsEvent { + data class FeedViewed( + val firstVisibleItemIndex: Int, + ) : AnalyticsEvent() + + data class FeedExited( + val timeSpentSeconds: Float, + val lastVisibleItemIndex: Int, + ) : AnalyticsEvent() + + data class VoteSubmitted( + val feedId: Long, + val voteChoice: String, + val feedCategory: String, + ) : AnalyticsEvent() + + data class VoteCreateStarted( + val entrySource: String, + val isLoggedIn: Boolean, + ) : AnalyticsEvent() + + data class VoteCreateCompleted( + val itemId: Long, + val voteTitle: String, + val optionCount: Int, + ) : AnalyticsEvent() + + data class VoteCreateAbandoned( + val filledFields: List, + val lastStep: String?, + ) : AnalyticsEvent() +} diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt new file mode 100644 index 00000000..e14a5910 --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt @@ -0,0 +1,9 @@ +package com.sseotdabwa.buyornot.core.analytics + +import android.util.Log + +class DebugAnalytics : Analytics { + override fun track(event: AnalyticsEvent) { + Log.d("Analytics", event.toString()) + } +} 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 new file mode 100644 index 00000000..792777cc --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt @@ -0,0 +1,53 @@ +package com.sseotdabwa.buyornot.core.analytics + +import com.mixpanel.android.mpmetrics.MixpanelAPI +import org.json.JSONArray +import org.json.JSONObject + +class MixpanelAnalytics( + private val mixpanel: MixpanelAPI, +) : Analytics { + override fun track(event: AnalyticsEvent) { + val (name, props) = event.toMixpanel() + mixpanel.track(name, props) + } + + private fun AnalyticsEvent.toMixpanel(): Pair { + val props = JSONObject() + val name = + when (this) { + is AnalyticsEvent.FeedViewed -> { + props.put("first_visible_item_index", firstVisibleItemIndex) + "feed_viewed" + } + is AnalyticsEvent.FeedExited -> { + props.put("time_spent_seconds", timeSpentSeconds) + props.put("last_visible_item_index", lastVisibleItemIndex) + "feed_exited" + } + is AnalyticsEvent.VoteSubmitted -> { + props.put("feed_id", feedId) + props.put("vote_choice", voteChoice) + props.put("feed_category", feedCategory) + "vote_submitted" + } + is AnalyticsEvent.VoteCreateStarted -> { + props.put("entry_source", entrySource) + props.put("is_logged_in", isLoggedIn) + "vote_create_started" + } + is AnalyticsEvent.VoteCreateCompleted -> { + props.put("item_id", itemId) + props.put("vote_title", voteTitle) + props.put("option_count", optionCount) + "vote_create_completed" + } + is AnalyticsEvent.VoteCreateAbandoned -> { + props.put("filled_fields", JSONArray(filledFields)) + if (lastStep != null) props.put("last_step", lastStep) + "vote_create_abandoned" + } + } + return name to props + } +} diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt new file mode 100644 index 00000000..591cc443 --- /dev/null +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt @@ -0,0 +1,35 @@ +package com.sseotdabwa.buyornot.core.analytics.di + +import android.content.Context +import com.mixpanel.android.mpmetrics.MixpanelAPI +import com.sseotdabwa.buyornot.core.analytics.Analytics +import com.sseotdabwa.buyornot.core.analytics.BuildConfig +import com.sseotdabwa.buyornot.core.analytics.DebugAnalytics +import com.sseotdabwa.buyornot.core.analytics.MixpanelAnalytics +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AnalyticsModule { + @Provides + @Singleton + fun provideAnalytics( + @ApplicationContext context: Context, + ): Analytics = + if (BuildConfig.DEBUG) { + DebugAnalytics() + } else { + val mixpanel = + MixpanelAPI.getInstance( + context, + BuildConfig.MIXPANEL_TOKEN, + true, + ) + MixpanelAnalytics(mixpanel) + } +} diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 32ab4eb6..8ab29cbb 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.core.analytics) implementation(projects.domain) implementation(projects.core.common) implementation(projects.core.designsystem) 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 cb08b082..2f0a66fd 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 @@ -144,6 +144,15 @@ sealed interface HomeIntent { data object DismissSortSheet : HomeIntent data object DismissTooltip : HomeIntent + + data class OnFeedScreenEntered( + val firstVisibleItemIndex: Int, + ) : HomeIntent + + data class OnFeedScreenExited( + val lastVisibleItemIndex: Int, + val timeSpentSeconds: Float, + ) : 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 85316d49..6a293e42 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 @@ -34,6 +34,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -363,6 +364,19 @@ private fun HomeFeedList( val listState = rememberLazyListState() val isEmptyViewVisible = filteredFeeds.isEmpty() && !uiState.isLoading && !uiState.hasError val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible + + val enterTimeMs = remember { System.currentTimeMillis() } + DisposableEffect(Unit) { + onIntent(HomeIntent.OnFeedScreenEntered(firstVisibleItemIndex = listState.firstVisibleItemIndex)) + onDispose { + onIntent( + HomeIntent.OnFeedScreenExited( + lastVisibleItemIndex = listState.firstVisibleItemIndex, + timeSpentSeconds = (System.currentTimeMillis() - enterTimeMs) / 1000f, + ), + ) + } + } var headerHeightPx by remember { mutableIntStateOf(0) } val density = LocalDensity.current val isAtTop by remember { 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 3ff2d522..f56f4b13 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 @@ -2,6 +2,8 @@ package com.sseotdabwa.buyornot.feature.home.ui import android.util.Log 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.TimeUtils import com.sseotdabwa.buyornot.core.common.util.runCatchingCancellable import com.sseotdabwa.buyornot.core.designsystem.components.ImageAspectRatio @@ -28,6 +30,7 @@ class HomeViewModel @Inject constructor( private val userPreferencesRepository: UserPreferencesRepository, private val feedRepository: FeedRepository, private val userRepository: UserRepository, + private val analytics: Analytics, ) : BaseViewModel(HomeUiState()) { private var currentUserId: Long? = null private var isUserIdLoaded = false @@ -133,6 +136,19 @@ class HomeViewModel @Inject constructor( is HomeIntent.ShowSortSheet -> updateState { it.copy(showSortSheet = true) } is HomeIntent.DismissSortSheet -> updateState { it.copy(showSortSheet = false) } is HomeIntent.DismissTooltip -> updateState { it.copy(isTooltipDismissed = true) } + is HomeIntent.OnFeedScreenEntered -> + analytics.track( + AnalyticsEvent.FeedViewed( + firstVisibleItemIndex = intent.firstVisibleItemIndex, + ), + ) + is HomeIntent.OnFeedScreenExited -> + analytics.track( + AnalyticsEvent.FeedExited( + timeSpentSeconds = intent.timeSpentSeconds, + lastVisibleItemIndex = intent.lastVisibleItemIndex, + ), + ) } } @@ -288,6 +304,17 @@ class HomeViewModel @Inject constructor( feeds = applyCategories(newAllFeeds, state.selectedCategories), ) } + analytics.track( + AnalyticsEvent.VoteSubmitted( + feedId = targetFeed.id.toLong(), + voteChoice = if (optionIndex == 0) "YES" else "NO", + feedCategory = + FeedCategory.entries + .find { it.displayName == targetFeed.category } + ?.name + ?: targetFeed.category, + ), + ) }.onFailure { e -> Log.e("HomeViewModel", "Failed to vote feed: $feedId", e) // 3. 롤백 (Rollback): 해당 피드만 원복, 나머지 동시 변경사항 보존 diff --git a/feature/upload/build.gradle.kts b/feature/upload/build.gradle.kts index ef861942..84fd97e5 100644 --- a/feature/upload/build.gradle.kts +++ b/feature/upload/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(projects.core.analytics) implementation(projects.core.common) implementation(projects.domain) implementation(projects.core.designsystem) diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt index 8a9eee49..e3bc9291 100644 --- a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt @@ -18,6 +18,7 @@ data class UploadUiState( val showExitDialog: Boolean = false, val showPhotoPickerSheet: Boolean = false, val categories: List = FeedCategory.entries, + val lastTouchedField: String? = null, ) { val hasInput: Boolean get() = @@ -27,6 +28,17 @@ data class UploadUiState( link.isNotEmpty() || title.isNotEmpty() || content.isNotEmpty() + + val filledFields: List + get() = + buildList { + if (selectedImageUris.isNotEmpty()) add("images") + if (category != null) add("category") + if (price.isNotEmpty()) add("price") + if (link.isNotEmpty()) add("link") + if (title.isNotEmpty()) add("title") + if (content.isNotEmpty()) add("content") + } } sealed interface UploadIntent { diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt index 33d30979..084445c8 100644 --- a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadViewModel.kt @@ -5,19 +5,38 @@ import android.graphics.BitmapFactory import android.net.Uri import android.provider.OpenableColumns 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.ui.base.BaseViewModel import com.sseotdabwa.buyornot.domain.model.FeedImage +import com.sseotdabwa.buyornot.domain.model.UserType import com.sseotdabwa.buyornot.domain.repository.FeedRepository +import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import com.sseotdabwa.buyornot.feature.upload.util.LinkValidator import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class UploadViewModel @Inject constructor( private val feedRepository: FeedRepository, + private val userPreferencesRepository: UserPreferencesRepository, + private val analytics: Analytics, ) : BaseViewModel(UploadUiState()) { + init { + viewModelScope.launch { + val userType = userPreferencesRepository.userType.first() + analytics.track( + AnalyticsEvent.VoteCreateStarted( + entrySource = "home", + isLoggedIn = userType == UserType.SOCIAL, + ), + ) + } + } + companion object { private const val MAX_IMAGE_COUNT = 3 private const val MAX_TITLE_LENGTH = 40 @@ -28,29 +47,29 @@ class UploadViewModel @Inject constructor( when (intent) { is UploadIntent.UpdateCategory -> updateState { - it.copy(category = intent.category, showCategorySheet = false) + it.copy(category = intent.category, showCategorySheet = false, lastTouchedField = "category") } is UploadIntent.UpdatePrice -> updateState { - it.copy(price = intent.digits, priceFieldValue = intent.textFieldValue) + it.copy(price = intent.digits, priceFieldValue = intent.textFieldValue, lastTouchedField = "price") } is UploadIntent.UpdateLink -> - updateState { it.copy(link = intent.link) } + updateState { it.copy(link = intent.link, lastTouchedField = "link") } is UploadIntent.UpdateTitle -> { if (intent.title.length <= MAX_TITLE_LENGTH) { - updateState { it.copy(title = intent.title) } + updateState { it.copy(title = intent.title, lastTouchedField = "title") } } } is UploadIntent.UpdateContent -> { if (intent.content.length <= MAX_CONTENT_LENGTH) { - updateState { it.copy(content = intent.content) } + updateState { it.copy(content = intent.content, lastTouchedField = "content") } } } is UploadIntent.AddImages -> { val remaining = MAX_IMAGE_COUNT - currentState.selectedImageUris.size val toAdd = intent.uris.take(remaining) val hasOverflow = toAdd.size < intent.uris.size - updateState { it.copy(selectedImageUris = it.selectedImageUris + toAdd) } + updateState { it.copy(selectedImageUris = it.selectedImageUris + toAdd, lastTouchedField = "images") } if (hasOverflow) sendSideEffect(UploadSideEffect.ShowSnackbar("최대 ${MAX_IMAGE_COUNT}장까지 추가할 수 있어요")) } is UploadIntent.RemoveImage -> @@ -75,6 +94,14 @@ class UploadViewModel @Inject constructor( it.copy(showExitDialog = false, showCategorySheet = false) } sendSideEffect(UploadSideEffect.NavigateBack) + if (currentState.hasInput) { + analytics.track( + AnalyticsEvent.VoteCreateAbandoned( + filledFields = currentState.filledFields, + lastStep = currentState.lastTouchedField, + ), + ) + } } } } @@ -133,9 +160,16 @@ class UploadViewModel @Inject constructor( title = title, link = link, ) - }.onSuccess { + }.onSuccess { feedId -> updateState { it.copy(isLoading = false) } sendSideEffect(UploadSideEffect.NavigateToHomeReview) + analytics.track( + AnalyticsEvent.VoteCreateCompleted( + itemId = feedId, + voteTitle = currentState.title, + optionCount = currentState.selectedImageUris.size, + ), + ) }.onFailure { throwable -> updateState { it.copy(isLoading = false) } sendSideEffect(UploadSideEffect.ShowSnackbar("업로드에 실패했습니다.")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dfd88eb5..3c61a291 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,9 @@ googleid = "1.2.0" lottie = "6.7.1" +# Analytics +mixpanel = "7.5.3" + # Firebase firebaseBom = "34.9.0" googleServices = "4.4.2" @@ -128,6 +131,9 @@ firebase-config = { group = "com.google.firebase", name = "firebase-config" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } +# Analytics +mixpanel-android = { group = "com.mixpanel.android", name = "mixpanel-android", version.ref = "mixpanel" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 56110bef..7f1ed466 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ dependencyResolutionManagement { rootProject.name = "BuyOrNot" include(":app") include(":domain") +include(":core:analytics") include(":core:data") include(":core:network") include(":core:datastore")