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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<!-- Amplitude SDK 가 네트워크 상태를 감지해 오프라인 구간의 이벤트를 큐잉하는 데 필요하다.
없으면 연결이 끊긴 동안 발생한 이벤트가 유실될 수 있다. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<application
android:name=".FlintApplication"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package com.flint.android.core.analytics

import android.os.SystemClock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import kotlinx.coroutines.delay

/**
* 화면 진입 이벤트를 컴포저블에서 바로 보낼 수 있도록 [AnalyticsTracker] 를 트리에 흘려준다.
*
* 온보딩처럼 여러 화면이 ViewModel 하나를 공유하는 구간에서는 화면별 진입 시점을
* ViewModel 로 표현할 수 없어서, 진입 이벤트만 UI 계층에서 처리한다.
* 완료·성공처럼 비즈니스 로직에 붙는 이벤트는 ViewModel 에서 보낸다.
*/
val LocalAnalyticsTracker =
staticCompositionLocalOf<AnalyticsTracker> {
error("LocalAnalyticsTracker 가 제공되지 않았습니다. FlintTheme 하위에서 사용하세요.")
}

/**
* 화면 진입 이벤트를 이 컴포저블이 살아 있는 동안 한 번만 보낸다.
*
* 화면 회전이나 프로세스 종료 후 복귀로 컴포지션이 다시 만들어져도 중복 전송되지 않도록
* 전송 여부를 [rememberSaveable] 로 남긴다. 뒤로 갔다가 다시 들어오는 경우는
* 컴포저블이 새로 생성되므로 의도대로 다시 집계된다.
*/
@Composable
fun TrackScreenView(event: FlintEvent) {
val tracker = LocalAnalyticsTracker.current
var isTracked by rememberSaveable(event.eventName) { mutableStateOf(false) }

LaunchedEffect(event.eventName) {
if (isTracked) return@LaunchedEffect
tracker.track(event)
isTracked = true
}
}

/**
* 화면에 머문 시간을 재고, 벗어날 때 [onExit] 으로 초 단위 값을 넘긴다.
*
* 기획 정의상 탭 전환·상세 진입·백그라운드 전환을 모두 이탈로 본다.
* 백그라운드로 나간 순간 이탈로 처리하므로 백그라운드에 머문 시간은 애초에 누적되지 않고,
* 돌아오면 새 체류로 다시 센다.
*
* 시간은 기기 시각 변경에 영향받지 않도록 [SystemClock.elapsedRealtime] 을 쓴다.
* 한 번의 체류에 대해 이벤트가 두 번 나가지 않도록 전송 여부를 함께 관리한다.
*
* 앱이 강제 종료되면 이탈을 알릴 기회가 없다. SDK 가 로컬 큐에 쌓아 다음 실행에 보내주지만,
* 앱을 다시 열지 않는 사용자의 기록은 남지 않는다.
*/
@Composable
fun TrackDwellTime(onExit: (durationSec: Long) -> Unit) {
val currentOnExit by rememberUpdatedState(onExit)
val lifecycleOwner = LocalLifecycleOwner.current

DisposableEffect(lifecycleOwner) {
var startedAt = SystemClock.elapsedRealtime()
var isSent = false

fun sendOnce() {
if (isSent) return
isSent = true
currentOnExit((SystemClock.elapsedRealtime() - startedAt) / MILLIS_PER_SECOND)
}

val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> sendOnce()
Lifecycle.Event.ON_START -> {
// 돌아왔으면 새로운 체류의 시작이다.
startedAt = SystemClock.elapsedRealtime()
isSent = false
}
else -> Unit
}
}

lifecycleOwner.lifecycle.addObserver(observer)

onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
// 탭 전환·상세 진입·뒤로가기로 화면을 벗어난 경우.
sendOnce()
}
}
}

private const val MILLIS_PER_SECOND = 1_000L

/**
* 목록 항목이 화면에 충분히 오래 보였을 때 노출로 집계한다.
*
* 기준은 기획 확정값이다 — 항목 높이의 [minVisibleFraction] 이상이 화면에 드러난 채로
* [minVisibleMillis] 이상 유지되어야 한다. 스크롤로 빠르게 지나친 항목은 제외된다.
*
* 한 번 보낸 항목은 [alreadyTracked] 로 걸러 같은 방문 안에서 다시 보내지 않는다.
* 위아래로 오르내리며 같은 항목을 여러 번 지나쳐도 한 번만 집계된다.
*
* 가시 비율은 잘려서 실제로 보이는 높이를 항목 전체 높이로 나눠 구한다.
*/
@Composable
fun Modifier.onItemImpression(
key: Any,
alreadyTracked: (Any) -> Boolean,
onImpression: (Any) -> Unit,
minVisibleFraction: Float = 0.5f,
minVisibleMillis: Long = 1_000L,
): Modifier {
val currentOnImpression by rememberUpdatedState(onImpression)
val currentAlreadyTracked by rememberUpdatedState(alreadyTracked)
var isSufficientlyVisible by remember(key) { mutableStateOf(false) }

LaunchedEffect(key, isSufficientlyVisible) {
if (!isSufficientlyVisible || currentAlreadyTracked(key)) return@LaunchedEffect
// 이 시간이 지나기 전에 화면에서 벗어나면 코루틴이 취소되어 집계되지 않는다.
delay(minVisibleMillis)
if (!currentAlreadyTracked(key)) currentOnImpression(key)
}

return onGloballyPositioned { coordinates ->
val height = coordinates.size.height
val visibleHeight = coordinates.boundsInWindow().height
isSufficientlyVisible = height > 0 && visibleHeight / height >= minVisibleFraction
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ sealed class FlintEvent(

// ────────── 탐색 ──────────

/**
* 탐색 페이지 진입.
*
* 정의서에는 없던 이벤트로, 진입 수와 이탈 수를 대조해 기록 유실을 확인하기 위해
* 기획 확인 후 추가했다. view_home 과 같은 기준으로 데이터 로딩이 끝나고
* 콘텐츠가 실제로 표시된 시점에 보낸다.
*/
data object ViewExplore : FlintEvent("view_explore")

/**
* 탐색에서 개별 작품 노출.
*
Expand Down Expand Up @@ -134,23 +143,21 @@ sealed class FlintEvent(
/** 사용자 키워드 업데이트 완료 */
data object UpdateKeyword : FlintEvent("update_keyword")

/** 저장한 작품 목록에서 이전에 저장한 작품을 다시 확인 */
/**
* 저장한 작품 목록에서 이전에 저장한 작품을 다시 확인.
*
* 저장함에서 어떤 컬렉션을 다시 열어봤는지는 view_collection 의 source(my_saved)로
* 알 수 있어, 컬렉션 쪽 대응 이벤트는 중복이므로 두지 않는다.
*/
data class ViewSavedContent(
val contentId: String,
) : FlintEvent("view_saved_content", mapOf(CONTENT_ID to contentId))

/** 저장한 컬렉션 목록에서 이전에 저장한 컬렉션을 다시 확인 */
data class ViewSavedCollection(
val collectionId: String,
) : FlintEvent("view_saved_collection", mapOf(COLLECTION_ID to collectionId))
}

/** 홈에서 클릭한 콘텐츠 영역 */
enum class HomeContentType(
val value: String,
) {
// 정의서의 content_type 은 "fliner", source 는 "home_flinner" 로 철자가 다르다.
// 어느 쪽이 맞는지 기획 확인 후 한쪽으로 통일할 것.
FLINER("fliner"),
RECENTLY_SAVED("recently_saved"),
POPULAR("popular"),
Expand All @@ -169,11 +176,22 @@ enum class BottomNavigationTab(
enum class CollectionSource(
val value: String,
) {
HOME_FLINNER("home_flinner"),
HOME_FLINER("home_fliner"),
HOME_POPULAR("home_popular"),
EXPLORE("explore"),
MY_SAVED("my_saved"),
MY_CREATED("my_created"),
;

companion object {
/**
* 네비게이션 인자로 실어 보낸 문자열을 되돌린다.
*
* 타입 안전 라우트가 enum 을 그대로 담지 못해 value 문자열로 오간다.
* 알 수 없는 값이면 잘못된 경로로 집계하는 대신 null 을 반환해 이벤트를 생략한다.
*/
fun from(value: String): CollectionSource? = entries.find { it.value == value }
}
}

private const val DURATION_SEC = "duration_sec"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ object DataStoreKey {
const val USER_ID = "userId"
const val USER_NAME = "userName"
const val USER_EMAIL = "userEmail"

/** 온보딩 소요시간 측정용 시작 시각(밀리초). 온보딩 완료 시 지운다. */
const val ONBOARDING_STARTED_AT = "onboardingStartedAt"
}

object ExternalLinks {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ interface Route {
val collectionId: String,
val targetImageUrl: String? = null,
val showEditSuccessToast: Boolean = false,
/** 분석용 진입 경로. CollectionSource.value 문자열을 담는다. */
val source: String = "",
) : Route

@Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
package com.flint.android.core.navigation.model

import com.flint.android.core.analytics.CollectionSource

enum class CollectionListRouteType(
val title: String
) {
CREATED(title = "전체 컬렉션"),
SAVED(title = "저장 컬렉션"),
FAMOUS(title = "인기 컬렉션")
}
}

/**
* 목록을 거쳐 상세로 들어가도 원래 어느 지면에서 출발했는지를 유지한다.
* 정의서에 정의된 5개 값 안에서 표현한다.
*/
fun CollectionListRouteType.toCollectionSource(): CollectionSource =
when (this) {
CollectionListRouteType.CREATED -> CollectionSource.MY_CREATED
CollectionListRouteType.SAVED -> CollectionSource.MY_SAVED
CollectionListRouteType.FAMOUS -> CollectionSource.HOME_POPULAR
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.flint.android.data.analytics

import com.flint.android.core.common.util.DataStoreKey
import com.flint.android.data.local.PreferencesManager
import kotlinx.coroutines.flow.first
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

/**
* 온보딩 시작 시각을 보관해 `complete_onboarding` 의 소요시간을 계산한다.
*
* 온보딩은 약관·작품선택·닉네임까지 여러 화면에 걸쳐 있어 도중에 앱이 종료될 수 있다.
* 메모리에만 두면 그때 시작 시각이 사라지므로 DataStore 에 남긴다.
*
* 시간 기준은 프로세스가 죽어도 이어져야 해서 [System.currentTimeMillis] 를 쓴다.
* 사용자가 기기 시각을 바꾸면 값이 튈 수 있어, 음수이거나 비상식적으로 큰 값은 버린다.
*/
@Singleton
class OnboardingDurationStore @Inject constructor(
private val preferencesManager: PreferencesManager,
) {
/**
* 온보딩 시작 시각을 기록한다.
*
* 이미 기록이 있으면 덮어쓰지 않는다. 온보딩 도중 앱이 종료됐다가 다시 들어온 경우
* 처음 시작한 시점을 유지해야 실제 소요시간이 나오기 때문이다.
*/
suspend fun startIfAbsent() {
if (readStartedAt() != null) return
preferencesManager.saveString(DataStoreKey.ONBOARDING_STARTED_AT, System.currentTimeMillis().toString())
}

/** 시작 시각부터 지금까지의 초. 기록이 없거나 값이 이상하면 null 을 반환한다. */
suspend fun elapsedSecondsOrNull(): Long? {
val startedAt = readStartedAt() ?: return null
val elapsedMillis = System.currentTimeMillis() - startedAt

if (elapsedMillis < 0 || elapsedMillis > MAX_REASONABLE_DURATION_MILLIS) {
Timber.w("온보딩 소요시간이 비정상이라 전송하지 않습니다: ${elapsedMillis}ms")
return null
}
return elapsedMillis / MILLIS_PER_SECOND
}

suspend fun clear() {
preferencesManager.removeString(DataStoreKey.ONBOARDING_STARTED_AT)
}

private suspend fun readStartedAt(): Long? =
preferencesManager
.getString(DataStoreKey.ONBOARDING_STARTED_AT)
.first()
.toLongOrNull()
}

private const val MILLIS_PER_SECOND = 1_000L

/** 하루를 넘긴 값은 기기 시각 변경이나 며칠 뒤 재개로 보고 지표에서 제외한다. */
private const val MAX_REASONABLE_DURATION_MILLIS = 24 * 60 * 60 * 1_000L
Loading
Loading