package com.bigcrunch.ads.core import com.bigcrunch.ads.internal.BCLogger import com.bigcrunch.ads.internal.KeyValueStore import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone import java.util.UUID import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference /** * SessionManager handles session and user identification for analytics * * Manages: * - user_id: Persistent UUID stored across app launches (identifies a user/device) * - session_id: New UUID generated per app launch (identifies a session) * - session_depth: Counter incremented per screen view within a session * - page_id: New UUID generated per screen/page view * * Thread-safe implementation ensures consistent IDs across all analytics events. */ internal class SessionManager private constructor(private val storage: KeyValueStore) { companion object { private const val TAG = "SessionManager" private const val KEY_USER_ID = "user_id" private const val KEY_SESSION_COUNT = "session_count" private const val KEY_SESSION_START_TIME = "session_start_time" private const val KEY_UTM_SOURCE = "utm_source" private const val KEY_UTM_MEDIUM = "utm_medium" private const val KEY_UTM_CAMPAIGN = "utm_campaign" private const val KEY_UTM_TERM = "utm_term" private const val KEY_UTM_CONTENT = "utm_content" @Volatile private var instance: SessionManager? = null /** * Initialize the SessionManager singleton * * Must be called once during SDK initialization. * * @param storage Key-value storage for persistence */ fun initialize(storage: KeyValueStore) { if (instance == null) { synchronized(this) { if (instance == null) { instance = SessionManager(storage) } } } } /** * Get the SessionManager instance * * @return The singleton instance * @throws IllegalStateException if not initialized */ fun getInstance(): SessionManager { return instance ?: throw IllegalStateException( "SessionManager not initialized. Call SessionManager.initialize() first." ) } // Static accessors for tracking counters fun getSessionId(): String = getInstance().sessionId fun getSessionStartTime(): String = getInstance().sessionStartTime fun getSessionDepth(): Int = getInstance().sessionDepth fun getScreenViewCount(): Int = getInstance().screenViewCounter.get() fun getAdRequestCount(): Int = getInstance().adRequestCounter.get() fun getAdImpressionCount(): Int = getInstance().adImpressionCounter.get() fun getTotalRevenueMicros(): Long = getInstance().totalRevenueMicrosCounter.get() fun incrementScreenViewCount() { getInstance().screenViewCounter.incrementAndGet() } fun incrementAdRequestCount() { getInstance().adRequestCounter.incrementAndGet() } fun incrementAdImpressionCount() { getInstance().adImpressionCounter.incrementAndGet() } fun addRevenueMicros(micros: Long) { getInstance().totalRevenueMicrosCounter.addAndGet(micros) } fun startNewSession() { getInstance().resetSessionCounters() } /** * Reset the singleton instance (for testing only) */ internal fun resetForTesting() { synchronized(this) { instance?.resetState() instance = null } } } // MARK: - Properties /** Persistent user ID (stored across app launches) */ val userId: String /** Current session ID (new per app launch) */ val sessionId: String /** Session start time (ISO 8601 string) */ val sessionStartTime: String /** Total number of sessions for this user (across all time) */ val totalSessionCount: Int /** True if this is the user's first session */ val isNewUser: Boolean get() = totalSessionCount == 1 /** UTM parameters (from deep links or attribution) */ var utmSource: String? = null private set var utmMedium: String? = null private set var utmCampaign: String? = null private set var utmTerm: String? = null private set var utmContent: String? = null private set /** Session attribution source (derived from UTM or defaults to "direct") */ val sessionSource: String get() = utmSource ?: "direct" /** Session attribution medium (derived from UTM or defaults to "none") */ val sessionMedium: String get() = utmMedium ?: "none" /** Number of page views in current session */ private val sessionDepthCounter = AtomicInteger(0) val sessionDepth: Int get() = sessionDepthCounter.get() /** Current page ID (new per page/screen view) */ private val currentPageIdRef = AtomicReference(null) val currentPageId: String? get() = currentPageIdRef.get() /** Current screen name (for use in impression events) */ private val currentScreenNameRef = AtomicReference(null) val currentScreenName: String? get() = currentScreenNameRef.get() // Analytics tracking counters internal val screenViewCounter = AtomicInteger(0) internal val adRequestCounter = AtomicInteger(0) internal val adImpressionCounter = AtomicInteger(0) internal val totalRevenueMicrosCounter = AtomicLong(0) init { // Load or generate user_id val existingUserId = storage.getString(KEY_USER_ID) if (existingUserId != null) { userId = existingUserId BCLogger.d(TAG, "Loaded existing user_id: $existingUserId") } else { val newUserId = UUID.randomUUID().toString() storage.putString(KEY_USER_ID, newUserId) userId = newUserId BCLogger.d(TAG, "Generated new user_id: $newUserId") } // Load session count and increment val sessionCountStr = storage.getString(KEY_SESSION_COUNT) ?: "0" val previousCount = sessionCountStr.toIntOrNull() ?: 0 totalSessionCount = previousCount + 1 storage.putString(KEY_SESSION_COUNT, totalSessionCount.toString()) // Generate new session_id for this app launch sessionId = UUID.randomUUID().toString() // Set session start time (ISO 8601 format) sessionStartTime = getCurrentTimestamp() storage.putString(KEY_SESSION_START_TIME, sessionStartTime) // Load UTM parameters (if set from deep link) utmSource = storage.getString(KEY_UTM_SOURCE) utmMedium = storage.getString(KEY_UTM_MEDIUM) utmCampaign = storage.getString(KEY_UTM_CAMPAIGN) utmTerm = storage.getString(KEY_UTM_TERM) utmContent = storage.getString(KEY_UTM_CONTENT) BCLogger.d(TAG, "Started session $totalSessionCount with session_id: $sessionId") } // MARK: - Page Tracking /** * Start a new page view * * Generates a new page_id and increments session_depth. * Should be called at the start of each screen/page view. * * @param screenName Optional screen name to store for impression tracking * @return The new page_id */ fun startPageView(screenName: String? = null): String { val depth = sessionDepthCounter.incrementAndGet() val pageId = UUID.randomUUID().toString() currentPageIdRef.set(pageId) screenName?.let { currentScreenNameRef.set(it) } BCLogger.d(TAG, "Started page view $depth with page_id: $pageId, screenName: $screenName") return pageId } /** * Get the current page ID, generating one if needed * * @return The current page_id (creates one if none exists) */ fun getOrCreatePageId(): String { var pageId = currentPageIdRef.get() if (pageId == null) { // Auto-generate if no page view has been started sessionDepthCounter.incrementAndGet() pageId = UUID.randomUUID().toString() currentPageIdRef.set(pageId) } return pageId } // MARK: - Reset /** Reset session counters (when starting a new session) */ internal fun resetSessionCounters() { screenViewCounter.set(0) adRequestCounter.set(0) adImpressionCounter.set(0) totalRevenueMicrosCounter.set(0) sessionDepthCounter.set(0) currentPageIdRef.set(null) BCLogger.d(TAG, "Session counters reset") } /** Reset all session state (for testing only) */ private fun resetState() { storage.clear() sessionDepthCounter.set(0) currentPageIdRef.set(null) resetSessionCounters() } // MARK: - UTM Parameter Management /** * Set UTM parameters (typically from deep link) * * This persists UTM parameters for attribution tracking. These will be * loaded for future sessions until cleared. * * @param source UTM source (e.g., "google", "facebook") * @param medium UTM medium (e.g., "cpc", "email") * @param campaign UTM campaign (e.g., "summer_sale") * @param term UTM term (optional keyword) * @param content UTM content (optional content variant) */ fun setUTMParameters( source: String? = null, medium: String? = null, campaign: String? = null, term: String? = null, content: String? = null ) { utmSource = source utmMedium = medium utmCampaign = campaign utmTerm = term utmContent = content // Persist to storage source?.let { storage.putString(KEY_UTM_SOURCE, it) } medium?.let { storage.putString(KEY_UTM_MEDIUM, it) } campaign?.let { storage.putString(KEY_UTM_CAMPAIGN, it) } term?.let { storage.putString(KEY_UTM_TERM, it) } content?.let { storage.putString(KEY_UTM_CONTENT, it) } BCLogger.d(TAG, "Set UTM parameters - source: $source, medium: $medium, campaign: $campaign") } /** * Clear UTM parameters */ fun clearUTMParameters() { utmSource = null utmMedium = null utmCampaign = null utmTerm = null utmContent = null storage.remove(KEY_UTM_SOURCE) storage.remove(KEY_UTM_MEDIUM) storage.remove(KEY_UTM_CAMPAIGN) storage.remove(KEY_UTM_TERM) storage.remove(KEY_UTM_CONTENT) } // MARK: - Timestamp Utilities /** * Get current timestamp in ISO 8601 format with milliseconds * * @return ISO 8601 timestamp string (e.g., "2025-01-15T10:30:00.000Z") */ fun getCurrentTimestamp(): String { val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) dateFormat.timeZone = TimeZone.getTimeZone("UTC") return dateFormat.format(Date()) } }