package com.bigcrunch.ads.core import android.content.Context import android.os.Build import com.bigcrunch.ads.BigCrunchAds import com.bigcrunch.ads.internal.BCLogger import com.bigcrunch.ads.internal.HttpClient import com.bigcrunch.ads.models.* import com.squareup.moshi.Moshi import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** * Analytics client for tracking ad events * * AnalyticsClient sends all ad-related events to the BigCrunch pipeline: * - Page/screen views → `/pageviews` * - Ad impressions (includes request + auction + revenue) → `/impressions` * - Ad clicks → `/clicks` * - Ad viewability → `/viewability` * - User engagement → `/engagement` * * All events are fire-and-forget (non-blocking) and sent asynchronously. * Failures are logged but do not propagate to the caller. */ internal class AnalyticsClient( private val context: Context, private val httpClient: HttpClient, private val moshi: Moshi, private val baseUrl: String, dispatcher: CoroutineDispatcher = Dispatchers.IO ) { companion object { private const val TAG = "AnalyticsClient" // Nil UUID for fields that require UUID format but have no value private const val NIL_UUID = "00000000-0000-0000-0000-000000000000" } private val scope = CoroutineScope(dispatcher) private val pageViewAdapter = moshi.adapter(PageViewEvent::class.java) private val impressionBatchAdapter = moshi.adapter(ImpressionBatchEvent::class.java) private val clickAdapter = moshi.adapter(ClickEvent::class.java) private val viewabilityAdapter = moshi.adapter(ViewabilityEvent::class.java) private val engagementAdapter = moshi.adapter(EngagementEvent::class.java) /** Track impression contexts for correlating events */ private val impressionContexts = ConcurrentHashMap() /** * Auction data from S2S responses, keyed by per-load ID (not placementId) so * concurrent loads and banner refreshes for the same placement can't clobber * each other's attribution (set by AdOrchestrator, consumed by trackAdImpression) */ private val auctionDataByLoadId = ConcurrentHashMap() /** Batching for impression events */ private val impressionBatch = mutableListOf() private var impressionBatchHandler: android.os.Handler? = null private val impressionBatchLock = Any() /** Batching for viewability events */ private val viewabilityBatch = mutableListOf() private var viewabilityBatchHandler: android.os.Handler? = null private val viewabilityBatchLock = Any() private val BATCH_DELAY_MS = 250L /** Pending impressions waiting for ILRD revenue data (keyed by placementId) */ private val pendingImpressions = ConcurrentHashMap() private val ILRD_TIMEOUT_MS = 500L /** * ILRD revenue that arrived before the impression-record callback (keyed by placementId). * GMA does not guarantee paid-event vs. impression-record ordering, so we buffer briefly. * `googleAuctionWon` indicates whether AdX/Google demand outbid the S2S line item. */ private val earlyIlrdRevenue = ConcurrentHashMap() private val EARLY_ILRD_TTL_MS = 2_000L /** * Bidder/demand-channel labels used when GMA's paid event reports that Google's * auction (AdX/Open Bidding/etc.) outbid the S2S line item we sent via `hb_pb` targeting. */ private val GOOGLE_AUCTION_BIDDER = "google_ad_exchange" private val GOOGLE_AUCTION_DEMAND_CHANNEL = "Google Ad Exchange" private data class PendingImpression( val event: ImpressionBatchEvent, val record: ImpressionRecord, val runnable: Runnable ) private data class EarlyIlrd( val revenueCpm: Double, val googleAuctionWon: Boolean, val expiresAtMs: Long ) /** Previous screen name for referrer tracking */ @Volatile private var previousScreenName: String? = null /** Current page URL (set by trackScreenView, used by all events) */ @Volatile private var currentPageUrl: String = "" /** Current custom dimensions (set by trackScreenView, used by all events) */ @Volatile private var currentCustomDimensions: Map = emptyMap() /** Current account type (set by setAccountType, used by all events) */ @Volatile private var currentAcctType: String = "guest" // MARK: - Session Context private val sessionManager: SessionManager get() = SessionManager.getInstance() private val propertyId: String get() = BigCrunchAds.propertyId private val configVersion: String? get() = null // TODO: Get from ConfigManager when available // MARK: - Event Sending /** * Send an event to a specific endpoint (fire-and-forget) * Wraps single event in an array as required by server */ private fun sendEvent(event: T, adapter: com.squareup.moshi.JsonAdapter, endpoint: String) { scope.launch { try { // Wrap single event in array as server expects arrays val eventArray = listOf(event) val listAdapter = moshi.adapter>( com.squareup.moshi.Types.newParameterizedType(List::class.java, event!!::class.java) ) val json = listAdapter.toJson(eventArray) val url = "$baseUrl/$endpoint" BCLogger.d(TAG, "Sending event to: $endpoint") val result = httpClient.post(url, json) if (result.isSuccess) { BCLogger.v(TAG, "Event sent successfully: $endpoint") } else { BCLogger.w(TAG, "Failed to send event: $endpoint - ${result.exceptionOrNull()?.message}") } } catch (e: Exception) { BCLogger.e(TAG, "Error sending event", e) } } } /** * Send a batch of events to a specific endpoint (fire-and-forget) */ private fun sendEventBatch(events: List, endpoint: String) { scope.launch { try { if (events.isEmpty()) return@launch // Get the type from the first event val listAdapter = moshi.adapter>( com.squareup.moshi.Types.newParameterizedType(List::class.java, events.first()!!::class.java) ) val json = listAdapter.toJson(events) val url = "$baseUrl/$endpoint" BCLogger.d(TAG, "Sending ${events.size} events to: $endpoint") val result = httpClient.post(url, json) if (result.isSuccess) { BCLogger.v(TAG, "Event batch sent successfully: $endpoint") } else { BCLogger.w(TAG, "Failed to send event batch: $endpoint - ${result.exceptionOrNull()?.message}") } } catch (e: Exception) { BCLogger.e(TAG, "Error sending event batch", e) } } } // MARK: - Common Event Fields /** * Get common web schema fields (device context, attribution, etc.) * * Returns a data class with all the common fields needed for events */ private data class CommonEventFields( val browser: String, val device: String, val os: String, val country: String, val region: String, val sessionSource: String, val sessionMedium: String, val utmSource: String?, val utmMedium: String?, val utmCampaign: String?, val utmTerm: String?, val utmContent: String? ) private fun getCommonEventFields(): CommonEventFields { val deviceContext = DeviceContext.getInstance() val webFields = deviceContext.getWebSchemaFields() return CommonEventFields( browser = webFields["browser"] as String, device = webFields["device"] as String, os = webFields["os"] as String, country = webFields["country"] as String, region = webFields["region"] as String, sessionSource = sessionManager.sessionSource, sessionMedium = sessionManager.sessionMedium, utmSource = sessionManager.utmSource, utmMedium = sessionManager.utmMedium, utmCampaign = sessionManager.utmCampaign, utmTerm = sessionManager.utmTerm, utmContent = sessionManager.utmContent ) } // MARK: - Page View Tracking /** * Generate the default page URL from screen name */ private fun generatePageUrl(screenName: String): String { val appName = try { BigCrunchAds.getAppConfig()?.appName ?: "app" } catch (e: Exception) { "app" } val sanitizedAppName = appName.lowercase() .replace(" ", "-") .let { java.net.URLEncoder.encode(it, "UTF-8") } val encodedScreen = java.net.URLEncoder.encode(screenName, "UTF-8") return "https://$sanitizedAppName.mobile.app/$encodedScreen" } /** * Track a screen view * * @param screenName Name of the screen being viewed * @param options Optional overrides for page URL, metadata, and custom dimensions */ fun trackScreenView(screenName: String, options: ScreenViewOptions? = null) { // Start new page view and get IDs (pass screen name for impression tracking) val pageId = sessionManager.startPageView(screenName) // Resolve page URL: explicit override > auto-generated val pageUrl = options?.pageUrl ?: generatePageUrl(screenName) currentPageUrl = pageUrl currentCustomDimensions = options?.customDimensions ?: emptyMap() // Get common event fields val common = getCommonEventFields() // Get GAM network code from BigCrunchAds if available val gamNetworkCode = try { BigCrunchAds.getConfigManager().getGamNetworkCode() ?: "" } catch (e: Exception) { "" } // region must be 2 chars or empty per backend validation val regionCode = common.region.takeIf { it.length == 2 } ?: "" val event = PageViewEvent( payloadVersion = DeviceContext.SDK_VERSION, configVersion = 1, browserTimestamp = sessionManager.getCurrentTimestamp(), sessionId = sessionManager.sessionId, userId = sessionManager.userId, propertyId = propertyId, newUser = sessionManager.isNewUser, pageId = pageId, sessionDepth = sessionManager.sessionDepth, pageUrl = pageUrl, pageSearch = "", pageReferrer = "", browser = common.browser, device = common.device, os = common.os, country = common.country, region = regionCode, sessionSource = common.sessionSource, sessionMedium = common.sessionMedium, utmSource = common.utmSource ?: "", utmMedium = common.utmMedium ?: "", utmCampaign = common.utmCampaign ?: "", utmTerm = common.utmTerm ?: "", utmContent = common.utmContent ?: "", gclid = "", fbclid = "", acctType = currentAcctType, diiSource = "", gamNetworkCode = gamNetworkCode, amznPubId = NIL_UUID, customDimensions = currentCustomDimensions, pageMetaData = options?.pageMeta ) sendEvent(event, pageViewAdapter, "pageviews") // Update previous screen for next referrer previousScreenName = screenName } // MARK: - Impression Context Management /** * Create and store an impression context for tracking * * Call this when starting to load an ad to generate an impression ID. */ fun createImpressionContext( placementId: String, slotId: String, gamAdUnit: String, format: String, width: Int? = null, height: Int? = null ): ImpressionContext { val context = ImpressionContext( placementId = placementId, slotId = slotId, gamAdUnit = gamAdUnit, format = format, width = width, height = height ) impressionContexts[context.impressionId] = context BCLogger.d(TAG, "Created impression context: ${context.impressionId} for $placementId") return context } /** * Update auction data for an impression context */ fun updateAuctionData(impressionId: String, auctionData: AuctionData) { impressionContexts[impressionId]?.let { context -> impressionContexts[impressionId] = context.copy(auctionData = auctionData) } BCLogger.d(TAG, "Updated auction data for impression: $impressionId") } /** * Get impression context by ID */ fun getImpressionContext(impressionId: String): ImpressionContext? { return impressionContexts[impressionId] } // MARK: - Auction Data Storage /** * Store auction data for a placement (called by AdOrchestrator after S2S demand fetch) */ fun setAccountType(accountType: String) { currentAcctType = accountType } fun setAuctionData(loadId: String, auctionData: AuctionData) { auctionDataByLoadId[loadId] = auctionData BCLogger.d(TAG, "Stored auction data for load: $loadId (channel: ${auctionData.demandChannel ?: "unknown"})") } /** * Consume auction data for a load (returns and removes stored data) */ private fun consumeAuctionData(loadId: String): AuctionData? { return auctionDataByLoadId.remove(loadId) } /** * Discard stored auction data for a load that will never produce an impression * (failed load or failed presentation). Without this, failed loads would leak entries. */ fun discardAuctionData(loadId: String) { if (auctionDataByLoadId.remove(loadId) != null) { BCLogger.d(TAG, "Discarded auction data for load: $loadId") } } /** * Compute min_bid_to_win: $0.01 above the higher of second-highest bid or floor price */ private fun computeMinBidToWin(auctionData: AuctionData?): Double { if (auctionData == null) return 0.0 val baseline = maxOf(auctionData.secondHighestBid ?: 0.0, auctionData.floorPrice ?: 0.0) return if (baseline > 0) baseline + 0.01 else 0.0 } // MARK: - Enhanced Impression Tracking /** * Get GAM network code from config (helper method) */ private fun getGamNetworkCode(): String { return try { BigCrunchAds.getConfigManager().getGamNetworkCode() ?: "" } catch (e: Exception) { "" } } /** * Track an ad impression with auction data * * @param context The impression context with all tracking data */ fun trackAdImpression(context: ImpressionContext) { val adSize = if (context.width != null && context.height != null) { "${context.width}x${context.height}" } else "" // Get common event fields val common = getCommonEventFields() // auction_id is required and must be a valid UUID - generate one if not provided val auctionId = context.auctionData.auctionId?.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString() val effectiveAuctionData = context.auctionData // Create impression record with auction data fields populated val impressionRecord = ImpressionRecord( slotId = context.slotId, gamUnit = context.gamAdUnit, gamPriceBucket = effectiveAuctionData.gamPriceBucket ?: "", impressionId = context.impressionId, auctionId = auctionId, adBidder = effectiveAuctionData.bidder ?: "", adSize = adSize, adPrice = effectiveAuctionData.bidPriceCpm ?: 0.0, adFloorPrice = effectiveAuctionData.floorPrice ?: 0.0, minBidToWin = computeMinBidToWin(effectiveAuctionData), creativeId = effectiveAuctionData.creativeId ?: "", adDemandType = context.format, demandChannel = effectiveAuctionData.demandChannel ?: "Google Ad Exchange" ) // region must be 2 chars or empty per backend validation val regionCode = common.region.takeIf { it.length == 2 } ?: "" // Create batch event with session/page context val batchEvent = ImpressionBatchEvent( payloadVersion = DeviceContext.SDK_VERSION, configVersion = 1, browserTimestamp = sessionManager.getCurrentTimestamp(), sessionId = sessionManager.sessionId, userId = sessionManager.userId, propertyId = propertyId, newUser = sessionManager.isNewUser, pageId = sessionManager.getOrCreatePageId(), sessionDepth = sessionManager.sessionDepth, pageUrl = currentPageUrl, pageSearch = "", pageReferrer = "", browser = common.browser, device = common.device, os = common.os, country = common.country, region = regionCode, sessionSource = common.sessionSource, sessionMedium = common.sessionMedium, utmSource = common.utmSource ?: "", utmMedium = common.utmMedium ?: "", utmCampaign = common.utmCampaign ?: "", utmTerm = common.utmTerm ?: "", utmContent = common.utmContent ?: "", gclid = "", fbclid = "", acctType = currentAcctType, diiSource = "", gamNetworkCode = getGamNetworkCode(), amznPubId = NIL_UUID, customDimensions = currentCustomDimensions, impressions = listOf(impressionRecord) ) deferImpression(context.placementId, impressionRecord, batchEvent) } /** * Track an ad impression (simple version with auction data lookup) */ fun trackAdImpression( placementId: String, slotId: String, format: String, gamAdUnit: String = "", adSize: String = "", advertiserId: String? = null, campaignId: String? = null, lineItemId: String? = null, creativeId: String? = null, refreshCount: Int = 0, loadId: String? = null ) { // Increment session impression counter SessionManager.incrementAdImpressionCount() // Get common event fields val common = getCommonEventFields() // Look up stored auction data from S2S response for this specific load val auctionData = loadId?.let { consumeAuctionData(it) } // auction_id: use S2S auction ID if available, otherwise generate one val auctionId = auctionData?.auctionId?.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString() // Create impression record with auction data fields populated val impressionRecord = ImpressionRecord( slotId = slotId, gamUnit = gamAdUnit, gamPriceBucket = auctionData?.gamPriceBucket ?: "", impressionId = UUID.randomUUID().toString(), auctionId = auctionId, adBidder = auctionData?.bidder ?: "", adSize = adSize, adPrice = auctionData?.bidPriceCpm ?: 0.0, adFloorPrice = auctionData?.floorPrice ?: 0.0, minBidToWin = computeMinBidToWin(auctionData), advertiserId = advertiserId ?: "", campaignId = campaignId ?: "", lineItemId = lineItemId ?: "", creativeId = auctionData?.creativeId ?: creativeId ?: "", adDemandType = format, demandChannel = auctionData?.demandChannel ?: "Google Ad Exchange", refreshCount = refreshCount ) // region must be 2 chars or empty per backend validation val regionCode = common.region.takeIf { it.length == 2 } ?: "" // Create batch event with session/page context val batchEvent = ImpressionBatchEvent( payloadVersion = DeviceContext.SDK_VERSION, configVersion = 1, browserTimestamp = sessionManager.getCurrentTimestamp(), sessionId = sessionManager.sessionId, userId = sessionManager.userId, propertyId = propertyId, newUser = sessionManager.isNewUser, pageId = sessionManager.getOrCreatePageId(), sessionDepth = sessionManager.sessionDepth, pageUrl = currentPageUrl, pageSearch = "", pageReferrer = "", browser = common.browser, device = common.device, os = common.os, country = common.country, region = regionCode, sessionSource = common.sessionSource, sessionMedium = common.sessionMedium, utmSource = common.utmSource ?: "", utmMedium = common.utmMedium ?: "", utmCampaign = common.utmCampaign ?: "", utmTerm = common.utmTerm ?: "", utmContent = common.utmContent ?: "", gclid = "", fbclid = "", acctType = currentAcctType, diiSource = "", gamNetworkCode = getGamNetworkCode(), amznPubId = NIL_UUID, customDimensions = currentCustomDimensions, impressions = listOf(impressionRecord) ) deferImpression(placementId, impressionRecord, batchEvent) } // MARK: - Deferred Impression Logic (ILRD) /** * Hold an impression for up to 500ms waiting for ILRD revenue. * If ILRD already arrived before this impression (rare but possible due to GMA * not guaranteeing callback order), apply the buffered value and flush immediately. */ private fun deferImpression(placementId: String, record: ImpressionRecord, event: ImpressionBatchEvent) { // If there's already a pending impression for this placement (e.g., banner refresh), // flush the old one immediately pendingImpressions.remove(placementId)?.let { existing -> val handler = impressionBatchHandler ?: android.os.Handler(android.os.Looper.getMainLooper()).also { impressionBatchHandler = it } handler.removeCallbacks(existing.runnable) batchImpressionEvent(existing.event) } // Fast path: did ILRD already arrive for this placement? val buffered = earlyIlrdRevenue.remove(placementId) if (buffered != null && buffered.expiresAtMs > System.currentTimeMillis()) { val updatedRecord = applyIlrd(record, buffered.revenueCpm, buffered.googleAuctionWon) val updatedEvent = event.copy(impressions = listOf(updatedRecord)) batchImpressionEvent(updatedEvent) BCLogger.d(TAG, "Applied buffered early ILRD for $placementId: ${buffered.revenueCpm} (googleAuctionWon=${buffered.googleAuctionWon})") return } // Slow path: defer waiting for ILRD val runnable = Runnable { flushPendingImpression(placementId) } pendingImpressions[placementId] = PendingImpression( event = event, record = record, runnable = runnable ) // Schedule timeout on main handler val handler = impressionBatchHandler ?: android.os.Handler(android.os.Looper.getMainLooper()).also { impressionBatchHandler = it } handler.postDelayed(runnable, ILRD_TIMEOUT_MS) BCLogger.d(TAG, "Deferred impression for $placementId, waiting for ILRD (500ms timeout)") } /** * Flush a pending impression (called by timeout or ILRD arrival) */ private fun flushPendingImpression(placementId: String) { val pending = pendingImpressions.remove(placementId) ?: return val handler = impressionBatchHandler handler?.removeCallbacks(pending.runnable) batchImpressionEvent(pending.event) BCLogger.d(TAG, "Flushed pending impression for $placementId (ad_price: ${pending.record.adPrice})") } /** * Handle ILRD revenue data from Google's paid event handler. * If a pending impression exists, update it; otherwise buffer the value briefly * in case the impression-record callback hasn't fired yet. * * @param googleAuctionWon True when GMA reports PRECISE/ESTIMATED precision, meaning * Google's auction (AdX/Open Bidding) provided the value. False when the value came * from publisher-provided `hb_pb` targeting (S2S line item won) or is UNKNOWN precision. */ fun handleIlrdRevenue(placementId: String, revenueCpm: Double, googleAuctionWon: Boolean) { BCLogger.d(TAG, "ILRD revenue received for $placementId: $revenueCpm (googleAuctionWon=$googleAuctionWon)") val pending = pendingImpressions.remove(placementId) if (pending != null) { val handler = impressionBatchHandler handler?.removeCallbacks(pending.runnable) val updatedRecord = applyIlrd(pending.record, revenueCpm, googleAuctionWon) val updatedEvent = pending.event.copy(impressions = listOf(updatedRecord)) batchImpressionEvent(updatedEvent) BCLogger.d(TAG, "Flushed impression for $placementId with ILRD revenue: $revenueCpm bidder=${updatedRecord.adBidder}") return } // No pending impression yet — buffer for the impression-record callback that will arrive shortly earlyIlrdRevenue[placementId] = EarlyIlrd( revenueCpm = revenueCpm, googleAuctionWon = googleAuctionWon, expiresAtMs = System.currentTimeMillis() + EARLY_ILRD_TTL_MS ) BCLogger.d(TAG, "Buffered early ILRD for $placementId: $revenueCpm (waiting for impression callback)") } /** * Apply ILRD price + winner attribution to an impression record. * When Google's auction won, override the bidder/demand-channel that was pre-set * from the S2S response (which would have been the S2S bidder name). */ private fun applyIlrd(record: ImpressionRecord, revenueCpm: Double, googleAuctionWon: Boolean): ImpressionRecord { return if (googleAuctionWon) { record.copy( adPrice = revenueCpm, adBidder = GOOGLE_AUCTION_BIDDER, demandChannel = GOOGLE_AUCTION_DEMAND_CHANNEL ) } else { record.copy(adPrice = revenueCpm) } } // MARK: - Unfilled Impression Tracking /** * Track an unfilled impression (ad failed to load) * Sends to the same /impressions endpoint with bidder="unfilled" and price=0 */ fun trackUnfilledImpression( placementId: String, slotId: String, format: String, gamAdUnit: String = "" ) { // Get common event fields val common = getCommonEventFields() // region must be 2 chars or empty per backend validation val regionCode = common.region.takeIf { it.length == 2 } ?: "" val impressionRecord = ImpressionRecord( slotId = slotId, gamUnit = gamAdUnit, gamPriceBucket = "", impressionId = UUID.randomUUID().toString(), auctionId = UUID.randomUUID().toString(), adBidder = "unfilled", adSize = "", adPrice = 0.0, adFloorPrice = 0.0, minBidToWin = 0.0, creativeId = "", adDemandType = format, demandChannel = "" ) val batchEvent = ImpressionBatchEvent( payloadVersion = DeviceContext.SDK_VERSION, configVersion = 1, browserTimestamp = sessionManager.getCurrentTimestamp(), sessionId = sessionManager.sessionId, userId = sessionManager.userId, propertyId = propertyId, newUser = sessionManager.isNewUser, pageId = sessionManager.getOrCreatePageId(), sessionDepth = sessionManager.sessionDepth, pageUrl = currentPageUrl, pageSearch = "", pageReferrer = "", browser = common.browser, device = common.device, os = common.os, country = common.country, region = regionCode, sessionSource = common.sessionSource, sessionMedium = common.sessionMedium, utmSource = common.utmSource ?: "", utmMedium = common.utmMedium ?: "", utmCampaign = common.utmCampaign ?: "", utmTerm = common.utmTerm ?: "", utmContent = common.utmContent ?: "", gclid = "", fbclid = "", acctType = currentAcctType, diiSource = "", gamNetworkCode = getGamNetworkCode(), amznPubId = NIL_UUID, customDimensions = currentCustomDimensions, impressions = listOf(impressionRecord) ) // Unfilled impressions go directly to the batch (no ILRD deferral needed) batchImpressionEvent(batchEvent) BCLogger.d(TAG, "Tracked unfilled impression for $placementId") } // MARK: - Click Tracking /** * Track an ad click with impression context * * @param impressionId The impression that was clicked * @param placementId Placement ID * @param format Ad format */ fun trackAdClick(impressionId: String, placementId: String, slotId: String, format: String) { // Get common event fields val common = getCommonEventFields() // region must be 2 chars or empty per backend validation val regionCode = common.region.takeIf { it.length == 2 } ?: "" // Create nested click data - impressionId must be valid UUID val clickData = ClickData( clickId = UUID.randomUUID().toString(), slotId = slotId, impressionId = impressionId.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString() ) val event = ClickEvent( payloadVersion = DeviceContext.SDK_VERSION, configVersion = 1, browserTimestamp = sessionManager.getCurrentTimestamp(), sessionId = sessionManager.sessionId, userId = sessionManager.userId, propertyId = propertyId, newUser = sessionManager.isNewUser, pageId = sessionManager.getOrCreatePageId(), sessionDepth = sessionManager.sessionDepth, pageUrl = currentPageUrl, pageSearch = "", pageReferrer = "", browser = common.browser, device = common.device, os = common.os, country = common.country, region = regionCode, sessionSource = common.sessionSource, sessionMedium = common.sessionMedium, utmSource = common.utmSource ?: "", utmMedium = common.utmMedium ?: "", utmCampaign = common.utmCampaign ?: "", utmTerm = common.utmTerm ?: "", utmContent = common.utmContent ?: "", gclid = "", fbclid = "", acctType = currentAcctType, diiSource = "", gamNetworkCode = getGamNetworkCode(), amznPubId = NIL_UUID, customDimensions = currentCustomDimensions, click = clickData ) sendEvent(event, clickAdapter, "clicks") } /** * Track an ad click (simple version without impression ID) */ fun trackAdClick(placementId: String, slotId: String, format: String) { trackAdClick("", placementId, slotId, format) } // MARK: - Viewability Tracking /** * Track ad viewability with impression context * * @param impressionId The impression that became viewable * @param placementId Placement ID * @param format Ad format * @param viewableTimeMs Time the ad was viewable in milliseconds (unused, kept for API compatibility) * @param percentVisible Percentage of ad that was visible (unused, kept for API compatibility) */ fun trackAdViewable( impressionId: String, placementId: String, slotId: String, format: String, viewableTimeMs: Long, percentVisible: Int ) { // Get common event fields val common = getCommonEventFields() // region must be 2 chars or empty per backend validation val regionCode = common.region.takeIf { it.length == 2 } ?: "" // Create nested viewability data - impressionId must be valid UUID val viewabilityData = ViewabilityData( slotId = slotId, impressionId = impressionId.takeIf { it.isNotEmpty() } ?: UUID.randomUUID().toString() ) val event = ViewabilityEvent( payloadVersion = DeviceContext.SDK_VERSION, configVersion = 1, browserTimestamp = sessionManager.getCurrentTimestamp(), sessionId = sessionManager.sessionId, userId = sessionManager.userId, propertyId = propertyId, newUser = sessionManager.isNewUser, pageId = sessionManager.getOrCreatePageId(), sessionDepth = sessionManager.sessionDepth, pageUrl = currentPageUrl, pageSearch = "", pageReferrer = "", browser = common.browser, device = common.device, os = common.os, country = common.country, region = regionCode, sessionSource = common.sessionSource, sessionMedium = common.sessionMedium, utmSource = common.utmSource ?: "", utmMedium = common.utmMedium ?: "", utmCampaign = common.utmCampaign ?: "", utmTerm = common.utmTerm ?: "", utmContent = common.utmContent ?: "", gclid = "", fbclid = "", acctType = currentAcctType, diiSource = "", gamNetworkCode = getGamNetworkCode(), amznPubId = NIL_UUID, customDimensions = currentCustomDimensions, viewability = listOf(viewabilityData) ) batchViewabilityEvent(event) } /** * Track ad viewability (simple version without metrics) */ fun trackAdViewable(placementId: String, slotId: String, format: String) { trackAdViewable("", placementId, slotId, format, 0, 0) } // MARK: - Engagement Tracking /** * Track user engagement with content * * @param engagedTime Time actively engaged in seconds * @param timeOnPage Total time on page/screen in seconds * @param scrollDepth Maximum scroll depth percentage (0-100) */ fun trackEngagement(engagedTime: Int, timeOnPage: Int, scrollDepth: Int = 0) { // Get common event fields val common = getCommonEventFields() // region must be 2 chars or empty per backend validation val regionCode = common.region.takeIf { it.length == 2 } ?: "" val event = EngagementEvent( payloadVersion = DeviceContext.SDK_VERSION, configVersion = 1, browserTimestamp = sessionManager.getCurrentTimestamp(), sessionId = sessionManager.sessionId, userId = sessionManager.userId, propertyId = propertyId, newUser = sessionManager.isNewUser, pageId = sessionManager.getOrCreatePageId(), sessionDepth = sessionManager.sessionDepth, pageUrl = currentPageUrl, pageSearch = "", pageReferrer = "", browser = common.browser, device = common.device, os = common.os, country = common.country, region = regionCode, sessionSource = common.sessionSource, sessionMedium = common.sessionMedium, utmSource = common.utmSource ?: "", utmMedium = common.utmMedium ?: "", utmCampaign = common.utmCampaign ?: "", utmTerm = common.utmTerm ?: "", utmContent = common.utmContent ?: "", gclid = "", fbclid = "", acctType = currentAcctType, diiSource = "", gamNetworkCode = getGamNetworkCode(), amznPubId = NIL_UUID, customDimensions = emptyMap(), // Engagement uses array-valued custom dimensions per backend schema engagedTime = engagedTime, timeOnPage = timeOnPage, scrollDepth = scrollDepth ) sendEvent(event, engagementAdapter, "engagement") } /** * Track an ad request * * @param placementId The placement ID being requested * @param format The ad format (banner, interstitial, etc.) */ fun trackAdRequest(placementId: String, format: String) { SessionManager.incrementAdRequestCount() BCLogger.d("AnalyticsClient", "Tracked ad request: $placementId ($format)") // TODO: Send actual event to backend when endpoint is available } // Revenue is tracked via handleIlrdRevenue() which updates the deferred impression's adPrice // MARK: - Cleanup /** * Remove an impression context (call when ad is destroyed) */ fun removeImpressionContext(impressionId: String) { impressionContexts.remove(impressionId) } /** * Clear all impression contexts */ fun clearImpressionContexts() { impressionContexts.clear() } // MARK: - Batching Helpers /** * Batch an impression event (250ms delay) */ private fun batchImpressionEvent(event: ImpressionBatchEvent) { synchronized(impressionBatchLock) { impressionBatch.add(event) // Cancel existing timer impressionBatchHandler?.removeCallbacksAndMessages(null) // Create new handler if needed if (impressionBatchHandler == null) { impressionBatchHandler = android.os.Handler(android.os.Looper.getMainLooper()) } // Schedule flush after 250ms impressionBatchHandler?.postDelayed({ flushImpressionBatch() }, BATCH_DELAY_MS) } } /** * Flush impression batch to backend * * Merges events with the same pageId into a single object with combined impressions array. */ private fun flushImpressionBatch() { synchronized(impressionBatchLock) { if (impressionBatch.isEmpty()) return val events = impressionBatch.toList() impressionBatch.clear() // Group by pageId and merge impressions into single objects val merged = mutableMapOf() for (event in events) { val existing = merged[event.pageId] if (existing != null) { merged[event.pageId] = existing.copy( impressions = existing.impressions + event.impressions ) } else { merged[event.pageId] = event } } val mergedEvents = merged.values.toList() BCLogger.d(TAG, "Flushing ${mergedEvents.size} impression groups (${events.size} total impressions)") sendEventBatch(mergedEvents, "impressions") } } /** * Batch a viewability event (250ms delay) */ private fun batchViewabilityEvent(event: ViewabilityEvent) { synchronized(viewabilityBatchLock) { viewabilityBatch.add(event) // Cancel existing timer viewabilityBatchHandler?.removeCallbacksAndMessages(null) // Create new handler if needed if (viewabilityBatchHandler == null) { viewabilityBatchHandler = android.os.Handler(android.os.Looper.getMainLooper()) } // Schedule flush after 250ms viewabilityBatchHandler?.postDelayed({ flushViewabilityBatch() }, BATCH_DELAY_MS) } } /** * Flush viewability batch to backend * * Merges events with the same pageId into a single object with combined viewability array. */ private fun flushViewabilityBatch() { synchronized(viewabilityBatchLock) { if (viewabilityBatch.isEmpty()) return val events = viewabilityBatch.toList() viewabilityBatch.clear() // Group by pageId and merge viewability into single objects val merged = mutableMapOf() for (event in events) { val existing = merged[event.pageId] if (existing != null) { merged[event.pageId] = existing.copy( viewability = existing.viewability + event.viewability ) } else { merged[event.pageId] = event } } val mergedEvents = merged.values.toList() BCLogger.d(TAG, "Flushing ${mergedEvents.size} viewability groups (${events.size} total)") sendEventBatch(mergedEvents, "viewability") } } }