diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 18a266ce..c030d62f 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -4,8 +4,9 @@ - + \ No newline at end of file diff --git a/NRMediaTailorTracker/build.gradle b/NRMediaTailorTracker/build.gradle new file mode 100644 index 00000000..23f23ce1 --- /dev/null +++ b/NRMediaTailorTracker/build.gradle @@ -0,0 +1,60 @@ +plugins { + id 'com.android.library' + id 'maven-publish' +} +android { + compileSdkVersion 33 + defaultConfig { + minSdkVersion 16 + targetSdkVersion 33 + versionCode 1 + versionName "1.0.0" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + buildTypes { + debug { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}") + buildConfigField("String","VERSION_NAME","\"${defaultConfig.versionName}\"") + } + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + buildConfigField("long", "VERSION_CODE", "${defaultConfig.versionCode}") + buildConfigField("String","VERSION_NAME","\"${defaultConfig.versionName}\"") + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + namespace 'com.newrelic.videoagent.mediatailor' +} +dependencies { + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.2.1' + implementation project(path: ':NewRelicVideoCore') + implementation 'androidx.media3:media3-exoplayer:1.1.0' + implementation 'androidx.media3:media3-exoplayer-hls:1.1.0' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.10.3' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test:runner:1.5.2' + androidTestImplementation 'androidx.test:rules:1.5.0' +} +afterEvaluate { + publishing { + publications { + release(MavenPublication) { + from components.release + groupId = 'com.github.newrelic' + artifactId = 'NRMediaTailorTracker' + version = "${android.defaultConfig.versionName}" + } + } + } + +} diff --git a/NRMediaTailorTracker/src/main/AndroidManifest.xml b/NRMediaTailorTracker/src/main/AndroidManifest.xml new file mode 100644 index 00000000..3cb3262d --- /dev/null +++ b/NRMediaTailorTracker/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/tracker/NRTrackerMediaTailor.java b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/tracker/NRTrackerMediaTailor.java new file mode 100644 index 00000000..88cea89a --- /dev/null +++ b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/tracker/NRTrackerMediaTailor.java @@ -0,0 +1,903 @@ +package com.newrelic.videoagent.mediatailor.tracker; + +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.media3.common.Player; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.hls.HlsManifest; +import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; + +import com.newrelic.videoagent.core.tracker.NRVideoTracker; +import com.newrelic.videoagent.mediatailor.utils.ManifestParser; +import com.newrelic.videoagent.mediatailor.utils.MediaTailorConstants; +import com.newrelic.videoagent.mediatailor.utils.NetworkUtils; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * AWS MediaTailor Ad Tracker for ExoPlayer + * + * Tracks ads from AWS MediaTailor SSAI streams (HLS/DASH) + * + * Features: + * - Client-side ad detection from manifest markers (CUE-OUT/CUE-IN) + * - Pod-level tracking (multiple ads within one break) + * - VOD and Live stream support + * - Tracking API metadata enrichment + */ +public class NRTrackerMediaTailor extends NRVideoTracker implements Player.Listener { + + private static final String TAG = "NRMediaTailorTracker"; + + // Import constants from utility class + private static final String STREAM_TYPE_VOD = MediaTailorConstants.STREAM_TYPE_VOD; + private static final String STREAM_TYPE_LIVE = MediaTailorConstants.STREAM_TYPE_LIVE; + private static final String MANIFEST_TYPE_HLS = MediaTailorConstants.MANIFEST_TYPE_HLS; + private static final String MANIFEST_TYPE_DASH = MediaTailorConstants.MANIFEST_TYPE_DASH; + private static final long LIVE_POLL_INTERVAL_MS = MediaTailorConstants.DEFAULT_LIVE_TRACKING_POLL_INTERVAL_MS; + private static final int TRACKING_TIMEOUT_MS = MediaTailorConstants.DEFAULT_TRACKING_API_TIMEOUT_MS; + + protected ExoPlayer player; + + // Stream properties + private String streamType; + private String manifestType; + private String mediaTailorEndpoint; + private String trackingUrl; + private String sessionId; + + // Ad tracking state + private List adSchedule = new ArrayList<>(); + private AdBreak currentAdBreak; + private AdPod currentAdPod; + private boolean hasEndedContent = false; + + // Configuration + private boolean enableManifestParsing = true; + private boolean enableTrackingAPI = true; + + // Handlers and timers + private Handler handler; + private Runnable pollManifestRunnable; + private Runnable pollTrackingRunnable; + private Runnable timeUpdateRunnable; + + // Time update monitoring (equivalent to videoJS timeupdate event) + private static final long TIME_UPDATE_INTERVAL_MS = MediaTailorConstants.DEFAULT_TIME_UPDATE_INTERVAL_MS; + + /** + * Checks if tracker should be used for this player source + */ + public static boolean isUsing(ExoPlayer player) { + if (player == null || player.getCurrentMediaItem() == null) { + return false; + } + String uri = player.getCurrentMediaItem().localConfiguration.uri.toString(); + return uri != null && uri.contains(".mediatailor."); + } + + /** + * Constructor + */ + public NRTrackerMediaTailor() { + handler = new Handler(Looper.getMainLooper()); + } + + /** + * Constructor with player + */ + public NRTrackerMediaTailor(ExoPlayer player) { + this(); + setPlayer(player); + } + + /** + * Set player and initialize tracking + */ + @Override + public void setPlayer(Object player) { + Log.d(TAG, "setPlayer() called"); + this.player = (ExoPlayer) player; + + if (this.player.getCurrentMediaItem() != null) { + this.mediaTailorEndpoint = this.player.getCurrentMediaItem().localConfiguration.uri.toString(); + this.manifestType = detectManifestType(this.mediaTailorEndpoint); + this.trackingUrl = extractTrackingUrl(this.mediaTailorEndpoint); + this.sessionId = extractSessionId(this.mediaTailorEndpoint); + + Log.d(TAG, "MediaTailor tracker initialized"); + Log.d(TAG, "Manifest type: " + manifestType); + Log.d(TAG, "Session ID: " + sessionId); + Log.d(TAG, "Tracking URL: " + trackingUrl); + Log.d(TAG, "Current player state: " + this.player.getPlaybackState()); + + registerListeners(); + + // If player is already ready, initialize tracking immediately + if (this.player.getPlaybackState() == Player.STATE_READY && streamType == null) { + Log.d(TAG, "Player already in READY state, initializing tracking now"); + long duration = this.player.getDuration(); + streamType = (duration == androidx.media3.common.C.TIME_UNSET) ? STREAM_TYPE_LIVE : STREAM_TYPE_VOD; + Log.d(TAG, "Stream type detected: " + streamType); + initializeTracking(); + } + } else { + Log.w(TAG, "No current media item available when setPlayer() called"); + } + + super.setPlayer(player); + } + + /** + * Register player event listeners + */ + @Override + public void registerListeners() { + if (player != null) { + player.addListener(this); + Log.d(TAG, "Event listeners registered"); + + // Start time update monitoring (equivalent to videoJS timeupdate event) + startTimeUpdateMonitoring(); + } + } + + /** + * Unregister player event listeners + */ + @Override + public void unregisterListeners() { + if (player != null) { + player.removeListener(this); + stopTimeUpdateMonitoring(); + stopLivePolling(); + } + } + + /** + * Detect manifest type from URL + */ + private String detectManifestType(String url) { + if (url.contains(".m3u8")) { + return MANIFEST_TYPE_HLS; + } else if (url.contains(".mpd") || url.contains("/dash")) { + return MANIFEST_TYPE_DASH; + } + return MANIFEST_TYPE_HLS; // Default to HLS + } + + /** + * Extract tracking URL from sessionized MediaTailor URL + */ + private String extractTrackingUrl(String url) { + // Extract sessionId from URL + Pattern pattern = Pattern.compile("aws\\.sessionId=([^&]+)"); + Matcher matcher = pattern.matcher(url); + + if (matcher.find()) { + String sessionId = matcher.group(1); + + // Extract base URL and construct tracking URL + // Format: https://{cloudfront-id}.mediatailor.{region}.amazonaws.com/v1/tracking/{account-id}/{config}/{sessionId} + Pattern urlPattern = Pattern.compile("(https://[^/]+)/v1/(?:master|dash)/([^/]+)/([^/]+)/"); + Matcher urlMatcher = urlPattern.matcher(url); + + if (urlMatcher.find()) { + String baseUrl = urlMatcher.group(1); + String accountId = urlMatcher.group(2); + String config = urlMatcher.group(3); + + return baseUrl + "/v1/tracking/" + accountId + "/" + config + "/" + sessionId; + } + } + + return null; + } + + /** + * Extract sessionId from URL + */ + private String extractSessionId(String url) { + Pattern pattern = Pattern.compile("aws\\.sessionId=([^&]+)"); + Matcher matcher = pattern.matcher(url); + + if (matcher.find()) { + return matcher.group(1); + } + + return null; + } + + /** + * Player listener: On playback state changed + */ + @Override + public void onPlaybackStateChanged(int playbackState) { + Log.d(TAG, "onPlaybackStateChanged: " + playbackState + " (streamType=" + streamType + ")"); + + if (playbackState == Player.STATE_READY && streamType == null) { + // Detect stream type based on duration + long duration = player.getDuration(); + streamType = (duration == androidx.media3.common.C.TIME_UNSET) ? STREAM_TYPE_LIVE : STREAM_TYPE_VOD; + + Log.d(TAG, "Stream type detected: " + streamType + " (duration=" + duration + ")"); + + // Initialize tracking based on stream type + initializeTracking(); + } else if (playbackState == Player.STATE_ENDED) { + // Player reached end - ad break should have already been exited in onTimeUpdate + // This is a safety fallback in case the timing check missed it + if (currentAdBreak != null) { + Log.d(TAG, "Player ended with active ad break (fallback) - exiting now"); + exitAdBreak(); + } + } + } + + /** + * Initialize tracking based on stream type + */ + private void initializeTracking() { + Log.d(TAG, "Initializing " + manifestType.toUpperCase() + " " + streamType.toUpperCase() + " tracking"); + + if (streamType.equals(STREAM_TYPE_VOD)) { + setupVODTracking(); + } else { + setupLiveTracking(); + } + } + + /** + * Setup VOD tracking (single parse, no polling) + */ + private void setupVODTracking() { + Log.d(TAG, "VOD mode: Single manifest parse"); + + // Parse manifest for ad breaks + parseManifestForAds(); + + // Fetch tracking metadata if available + if (trackingUrl != null && enableTrackingAPI) { + fetchTrackingMetadata(); + } + } + + /** + * Setup Live tracking (continuous polling) + */ + private void setupLiveTracking() { + Log.d(TAG, "Live mode: Continuous polling"); + + // Initial parse + parseManifestForAds(); + + // Start polling for new ads + pollManifestRunnable = new Runnable() { + @Override + public void run() { + parseManifestForAds(); + handler.postDelayed(this, LIVE_POLL_INTERVAL_MS); + } + }; + handler.postDelayed(pollManifestRunnable, LIVE_POLL_INTERVAL_MS); + + // Poll tracking API if available + if (trackingUrl != null && enableTrackingAPI) { + pollTrackingRunnable = new Runnable() { + @Override + public void run() { + fetchTrackingMetadata(); + handler.postDelayed(this, LIVE_POLL_INTERVAL_MS); + } + }; + handler.postDelayed(pollTrackingRunnable, LIVE_POLL_INTERVAL_MS); + } + } + + /** + * Stop live polling timers + */ + private void stopLivePolling() { + if (pollManifestRunnable != null) { + handler.removeCallbacks(pollManifestRunnable); + pollManifestRunnable = null; + } + + if (pollTrackingRunnable != null) { + handler.removeCallbacks(pollTrackingRunnable); + pollTrackingRunnable = null; + } + } + + /** + * Start time update monitoring (equivalent to videoJS timeupdate event) + * This is the PRIMARY ad detection mechanism - checks currentTime vs ad schedule + */ + private void startTimeUpdateMonitoring() { + if (timeUpdateRunnable != null) { + return; // Already started + } + + Log.d(TAG, "Starting time update monitoring (checking every " + TIME_UPDATE_INTERVAL_MS + "ms)"); + + timeUpdateRunnable = new Runnable() { + @Override + public void run() { + if (player != null && player.isPlaying()) { + onTimeUpdate(); + } + handler.postDelayed(this, TIME_UPDATE_INTERVAL_MS); + } + }; + + handler.postDelayed(timeUpdateRunnable, TIME_UPDATE_INTERVAL_MS); + } + + /** + * Stop time update monitoring + */ + private void stopTimeUpdateMonitoring() { + if (timeUpdateRunnable != null) { + handler.removeCallbacks(timeUpdateRunnable); + timeUpdateRunnable = null; + Log.d(TAG, "Stopped time update monitoring"); + } + } + + /** + * Called periodically to check for ad break transitions (like videoJS timeupdate) + * This is the CORE ad detection logic + */ + private void onTimeUpdate() { + double currentTime = player.getCurrentPosition() / 1000.0; + AdBreak activeBreak = findActiveAdBreak(currentTime); + + // Debug logging every 5 seconds + if (adSchedule.size() > 0 && Math.floor(currentTime) % 5 == 0 && Math.floor(currentTime * 10) % 10 == 0) { + Log.d(TAG, String.format("TimeUpdate: %.2fs, Active break: %s, Schedule count: %d", + currentTime, + activeBreak != null ? activeBreak.id : "none", + adSchedule.size())); + } + + // Check if we're near the end of the stream and in an ad break + // Exit ad break BEFORE content ends to maintain correct viewId + if (currentAdBreak != null && streamType.equals(STREAM_TYPE_VOD)) { + long duration = player.getDuration(); + long currentPosition = player.getCurrentPosition(); + // If we're within 100ms of the end, exit the ad break now + if (duration != androidx.media3.common.C.TIME_UNSET && + currentPosition >= duration - 100) { + Log.d(TAG, "Near end of stream - exiting ad break before CONTENT_END"); + exitAdBreak(); + return; + } + } + + if (activeBreak != null) { + // === INSIDE AD BREAK === + if (!activeBreak.hasFiredStart) { + enterAdBreak(activeBreak); + } + + // Check for pod transitions within break + if (currentAdBreak != null) { + checkPodTransition(activeBreak, currentTime); + } + } else if (currentAdBreak != null) { + // === EXITING AD BREAK === + exitAdBreak(); + } + } + + /** + * Parse HLS manifest for ad breaks using SCTE-35 markers + * Delegates to ManifestParser utility class + */ + private void parseManifestForAds() { + if (!enableManifestParsing || manifestType == null || !manifestType.equals(MANIFEST_TYPE_HLS)) { + Log.d(TAG, "Manifest parsing disabled or not HLS"); + return; + } + + if (mediaTailorEndpoint == null) { + Log.w(TAG, "No manifest URL available for parsing"); + return; + } + + new Thread(() -> { + try { + Log.d(TAG, "Fetching and parsing HLS manifest: " + mediaTailorEndpoint); + + // Fetch manifest using NetworkUtils + String manifestText = NetworkUtils.fetchText(mediaTailorEndpoint, TRACKING_TIMEOUT_MS); + + if (manifestText != null) { + // Check if this is a master playlist (contains variant streams) + if (ManifestParser.isMasterPlaylist(manifestText)) { + Log.d(TAG, "Master playlist detected - fetching variant stream for SCTE-35 markers"); + + // Extract first variant stream URL + String variantUrl = ManifestParser.extractFirstVariantUrl(manifestText, mediaTailorEndpoint); + + if (variantUrl != null) { + // Fetch the variant playlist (contains SCTE-35 markers) + String variantText = NetworkUtils.fetchText(variantUrl, TRACKING_TIMEOUT_MS); + + if (variantText != null) { + // Parse the variant playlist for ads + parseMediaPlaylist(variantText); + } else { + Log.w(TAG, "Failed to fetch variant playlist"); + } + } else { + Log.w(TAG, "Could not extract variant URL from master playlist"); + } + } else { + // This is already a media playlist - parse directly + Log.d(TAG, "Media playlist detected - parsing for SCTE-35 markers"); + parseMediaPlaylist(manifestText); + } + } else { + Log.w(TAG, "Failed to fetch manifest"); + } + + } catch (Exception e) { + Log.e(TAG, "Error parsing manifest", e); + } + }).start(); + } + + /** + * Parse media playlist for SCTE-35 markers + */ + private void parseMediaPlaylist(String manifestText) { + // Parse using ManifestParser + List manifestAdBreaks = ManifestParser.parseHLSManifest(manifestText); + + if (manifestAdBreaks.size() > 0) { + Log.d(TAG, "Parsed " + manifestAdBreaks.size() + " ad break(s) from manifest"); + + // Merge with existing schedule (from tracking API) + adSchedule = ManifestParser.mergeAdSchedules(adSchedule, manifestAdBreaks); + } else { + Log.d(TAG, "No ad breaks found in manifest"); + } + } + + /** + * Fetch tracking metadata from MediaTailor Tracking API + */ + private void fetchTrackingMetadata() { + if (trackingUrl == null) { + Log.d(TAG, "No tracking URL available"); + return; + } + + new Thread(() -> { + try { + Log.d(TAG, "Fetching tracking metadata from: " + trackingUrl); + + URL url = new URL(trackingUrl); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(TRACKING_TIMEOUT_MS); + connection.setReadTimeout(TRACKING_TIMEOUT_MS); + + int responseCode = connection.getResponseCode(); + + if (responseCode == HttpURLConnection.HTTP_OK) { + BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream())); + StringBuilder response = new StringBuilder(); + String line; + + while ((line = reader.readLine()) != null) { + response.append(line); + } + reader.close(); + + // Parse tracking data + processTrackingMetadata(response.toString()); + } else { + Log.w(TAG, "Tracking API returned code: " + responseCode); + } + + connection.disconnect(); + + } catch (Exception e) { + Log.e(TAG, "Error fetching tracking metadata", e); + } + }).start(); + } + + /** + * Process tracking metadata from API + */ + private void processTrackingMetadata(String jsonData) { + try { + JSONObject data = new JSONObject(jsonData); + + if (data.has("avails")) { + JSONArray avails = data.getJSONArray("avails"); + Log.d(TAG, "Received " + avails.length() + " avail(s) from tracking API"); + + for (int i = 0; i < avails.length(); i++) { + JSONObject avail = avails.getJSONObject(i); + + // Parse avail data + double startTime = avail.optDouble("startTimeInSeconds", 0); + double duration = avail.optDouble("durationInSeconds", 0); + String availId = avail.optString("availId", ""); + + Log.d(TAG, String.format("Parsing avail %d: startTime=%.2fs, duration=%.2fs", + i, startTime, duration)); + + // Create ad break + AdBreak adBreak = new AdBreak(); + adBreak.id = availId; + adBreak.startTime = startTime; + adBreak.duration = duration; + adBreak.endTime = startTime + duration; + adBreak.source = "tracking-api"; + + // Parse ads within avail + if (avail.has("ads")) { + JSONArray ads = avail.getJSONArray("ads"); + + for (int j = 0; j < ads.length(); j++) { + JSONObject ad = ads.getJSONObject(j); + + double adStartTime = ad.optDouble("startTimeInSeconds", 0); // Absolute time from stream start + double adDuration = ad.optDouble("durationInSeconds", 0); + + Log.d(TAG, String.format(" Parsing ad %d: startTime=%.2fs, duration=%.2fs, title=%s", + j, adStartTime, adDuration, ad.optString("adTitle", ""))); + + AdPod pod = new AdPod(); + pod.title = ad.optString("adTitle", ""); + pod.creativeId = ad.optString("creativeId", ""); + pod.startTime = adStartTime; // Use absolute time directly (not offset!) + pod.duration = adDuration; + pod.endTime = pod.startTime + pod.duration; + + adBreak.pods.add(pod); + } + } + + // Add to schedule + adSchedule.add(adBreak); + } + + // Sort by start time (using Collections.sort for API 16+ compatibility) + java.util.Collections.sort(adSchedule, new java.util.Comparator() { + @Override + public int compare(AdBreak a, AdBreak b) { + return Double.compare(a.startTime, b.startTime); + } + }); + + Log.d(TAG, "Ad schedule updated: " + adSchedule.size() + " ad break(s)"); + + // Log detailed schedule for debugging + for (int i = 0; i < adSchedule.size(); i++) { + AdBreak ab = adSchedule.get(i); + Log.d(TAG, String.format(" Break %d: %.2fs-%.2fs (%.2fs) - %d pods", + i, ab.startTime, ab.endTime, ab.duration, ab.pods.size())); + for (int j = 0; j < ab.pods.size(); j++) { + AdPod pod = ab.pods.get(j); + Log.d(TAG, String.format(" Pod %d: '%s' %.2fs-%.2fs (%.2fs)", + j, pod.title, pod.startTime, pod.endTime, pod.duration)); + } + } + } + + } catch (Exception e) { + Log.e(TAG, "Error processing tracking metadata", e); + } + } + + /** + * Find active ad break for current time + */ + private AdBreak findActiveAdBreak(double currentTime) { + for (AdBreak adBreak : adSchedule) { + if (currentTime >= adBreak.startTime && currentTime < adBreak.endTime) { + return adBreak; + } + } + return null; + } + + /** + * Enter ad break + */ + private void enterAdBreak(AdBreak adBreak) { + currentAdBreak = adBreak; + getState().isAd = true; + + // Determine ad position + int breakIndex = adSchedule.indexOf(adBreak); + String adPosition = determineAdPosition(breakIndex, adSchedule.size()); + currentAdBreak.adPosition = adPosition; + + Log.d(TAG, "Entering ad break: " + adBreak.id + " at position " + adPosition); + + // Send AD_BREAK_START + sendAdBreakStart(); + + adBreak.hasFiredStart = true; + } + + /** + * Exit ad break + */ + private void exitAdBreak() { + if (currentAdPod != null) { + sendEnd(); + currentAdPod = null; + } + + Log.d(TAG, "Exiting ad break: " + currentAdBreak.id); + + // Send AD_BREAK_END + sendAdBreakEnd(); + currentAdBreak.hasFiredEnd = true; + currentAdBreak = null; + + getState().isAd = false; + } + + /** + * Check for pod transitions within ad break and track quartiles + */ + private void checkPodTransition(AdBreak adBreak, double currentTime) { + // Debug logging + if (Math.floor(currentTime * 4) % 4 == 0) { // Log every 250ms aligned + Log.d(TAG, String.format("checkPodTransition: time=%.2fs, break=%s (%.2fs-%.2fs), %d pods, currentPod=%s", + currentTime, adBreak.id, adBreak.startTime, adBreak.endTime, + adBreak.pods.size(), currentAdPod != null ? currentAdPod.title : "null")); + } + + if (adBreak.pods.isEmpty()) { + // No pods - treat entire break as single ad + if (!adBreak.hasFiredAdStart) { + Log.d(TAG, "→ AD_START (no pods)"); + sendRequest(); + sendStart(); + adBreak.hasFiredAdStart = true; + } + + // Track quartiles for entire break + double adProgress = currentTime - adBreak.startTime; + trackQuartiles(adBreak, adProgress); + return; + } + + // Find active pod + AdPod activePod = null; + for (AdPod pod : adBreak.pods) { + if (currentTime >= pod.startTime && currentTime < pod.endTime) { + activePod = pod; + break; + } + } + + if (activePod != null && currentAdPod != activePod) { + // Transition to new pod + if (currentAdPod != null) { + Log.d(TAG, "→ AD_END (pod transition)"); + sendEnd(); + } + + currentAdPod = activePod; + Log.d(TAG, "→ AD_START (new pod): " + activePod.title); + + sendRequest(); + sendStart(); + activePod.hasFiredStart = true; + } + + // Track quartiles for active pod + if (currentAdPod != null) { + double podProgress = currentTime - currentAdPod.startTime; + trackQuartiles(currentAdPod, podProgress); + } + } + + /** + * Track quartile events for active ad/pod (like videoJS) + */ + private void trackQuartiles(Object adObject, double progress) { + double duration; + boolean[] fired = new boolean[3]; + + if (adObject instanceof AdPod) { + AdPod pod = (AdPod) adObject; + duration = pod.duration; + fired[0] = pod.hasFiredQ1; + fired[1] = pod.hasFiredQ2; + fired[2] = pod.hasFiredQ3; + } else { + AdBreak adBreak = (AdBreak) adObject; + duration = adBreak.duration; + fired[0] = adBreak.hasFiredQ1; + fired[1] = adBreak.hasFiredQ2; + fired[2] = adBreak.hasFiredQ3; + } + + if (duration <= 0) return; + + // Q1 - 25% + if (!fired[0] && progress >= duration * 0.25) { + Log.d(TAG, "→ AD_QUARTILE 25%"); + sendAdQuartile(); + if (adObject instanceof AdPod) { + ((AdPod) adObject).hasFiredQ1 = true; + } else { + ((AdBreak) adObject).hasFiredQ1 = true; + } + } + + // Q2 - 50% + if (!fired[1] && progress >= duration * 0.50) { + Log.d(TAG, "→ AD_QUARTILE 50%"); + sendAdQuartile(); + if (adObject instanceof AdPod) { + ((AdPod) adObject).hasFiredQ2 = true; + } else { + ((AdBreak) adObject).hasFiredQ2 = true; + } + } + + // Q3 - 75% + if (!fired[2] && progress >= duration * 0.75) { + Log.d(TAG, "→ AD_QUARTILE 75%"); + sendAdQuartile(); + if (adObject instanceof AdPod) { + ((AdPod) adObject).hasFiredQ3 = true; + } else { + ((AdBreak) adObject).hasFiredQ3 = true; + } + } + } + + /** + * Determine ad position (pre-roll, mid-roll, post-roll) + */ + private String determineAdPosition(int index, int total) { + if (index == 0) { + return "pre-roll"; + } else if (index == total - 1 && streamType.equals(STREAM_TYPE_VOD)) { + return "post-roll"; + } else { + return "mid-roll"; + } + } + + // Tracker metadata overrides + + @Override + public String getTrackerName() { + return "aws-media-tailor"; + } + + @Override + public String getTrackerVersion() { + return "1.0.0"; + } + + @Override + public String getPlayerName() { + return "ExoPlayer"; + } + + @Override + public String getPlayerVersion() { + return androidx.media3.common.MediaLibraryInfo.VERSION; + } + + @Override + public String getTitle() { + if (currentAdPod != null && currentAdPod.title != null) { + return currentAdPod.title; + } + if (currentAdBreak != null && currentAdBreak.id != null) { + return currentAdBreak.id; + } + return null; + } + + @Override + public String getSrc() { + return mediaTailorEndpoint; + } + + @Override + public Long getDuration() { + if (currentAdPod != null) { + return (long) (currentAdPod.duration * 1000); + } + if (currentAdBreak != null) { + return (long) (currentAdBreak.duration * 1000); + } + return null; + } + + @Override + public Map getAttributes(String action, Map attributes) { + Map attr = super.getAttributes(action, attributes); + + // Add MediaTailor-specific attributes + if (sessionId != null) { + attr.put("sessionId", sessionId); + } + + if (currentAdBreak != null && currentAdBreak.adPosition != null) { + attr.put("adPosition", currentAdBreak.adPosition); + } + + attr.put("adIntegration", "AWS MediaTailor"); + attr.put("streamType", streamType); + + return attr; + } + + /** + * Cleanup + */ + public void dispose() { + Log.d(TAG, "Disposing MediaTailorAdsTracker"); + stopLivePolling(); + unregisterListeners(); + } + + // Inner classes for ad scheduling + + /** + * Represents an ad break (avail) + */ + public static class AdBreak { + public String id; + public double startTime; + public double duration; + public double endTime; + public String source; + public String adPosition; + public boolean hasFiredStart = false; + public boolean hasFiredEnd = false; + public boolean hasFiredAdStart = false; + public boolean hasFiredQ1 = false; + public boolean hasFiredQ2 = false; + public boolean hasFiredQ3 = false; + public List pods = new ArrayList<>(); + } + + /** + * Represents an individual ad within a break (pod) + */ + public static class AdPod { + public String title; + public String creativeId; + public double startTime; + public double duration; + public double endTime; + public boolean hasFiredStart = false; + public boolean hasFiredQ1 = false; + public boolean hasFiredQ2 = false; + public boolean hasFiredQ3 = false; + } +} diff --git a/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/ManifestParser.java b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/ManifestParser.java new file mode 100644 index 00000000..9ba31b43 --- /dev/null +++ b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/ManifestParser.java @@ -0,0 +1,339 @@ +package com.newrelic.videoagent.mediatailor.utils; + +import android.util.Log; + +import com.newrelic.videoagent.mediatailor.tracker.NRTrackerMediaTailor.AdBreak; +import com.newrelic.videoagent.mediatailor.tracker.NRTrackerMediaTailor.AdPod; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; + +/** + * Manifest Parser Utilities for AWS MediaTailor + * Parses HLS and DASH manifests to detect SCTE-35 ad markers + * Based on VideoJS mt.js implementation + */ +public class ManifestParser { + + private static final String TAG = "MTManifestParser"; + + /** + * Parse HLS manifest text for SCTE-35 markers (CUE-OUT/CUE-IN) + * Based on videoJS implementation in mt.js:205-331 + * + * @param manifestText The HLS manifest content (.m3u8) + * @return List of detected ad breaks with pod information + */ + public static List parseHLSManifest(String manifestText) { + List adBreaks = new ArrayList<>(); + + if (manifestText == null || manifestText.isEmpty()) { + Log.w(TAG, "Empty manifest text provided"); + return adBreaks; + } + + String[] lines = manifestText.split("\n"); + + double currentTime = 0; + AdBreak currentAdBreak = null; + List adPods = new ArrayList<>(); + Double currentPodStartTime = null; + String lastMapUrl = null; + boolean isInAdBreak = false; + + for (String line : lines) { + line = line.trim(); + + // Detect CUE-OUT (ad break start) + if (line.startsWith("#EXT-X-CUE-OUT")) { + Matcher matcher = MediaTailorConstants.REGEX_CUE_OUT.matcher(line); + Double duration = null; + + if (matcher.find()) { + duration = Double.parseDouble(matcher.group(1)); + } + + isInAdBreak = true; + adPods = new ArrayList<>(); + currentPodStartTime = currentTime; + lastMapUrl = null; + + currentAdBreak = new AdBreak(); + currentAdBreak.id = "avail-manifest-" + currentTime; + currentAdBreak.startTime = currentTime; + currentAdBreak.duration = duration != null ? duration : 0; + currentAdBreak.endTime = 0; // Will be set when CUE-IN is found + currentAdBreak.source = MediaTailorConstants.AD_SOURCE_MANIFEST_CUE; + + Log.d(TAG, String.format("Found CUE-OUT at %.2fs, duration=%.2fs", + currentTime, currentAdBreak.duration)); + } + + // Detect CUE-IN (ad break end) + else if (line.startsWith("#EXT-X-CUE-IN")) { + if (currentAdBreak != null) { + // Close final pod + if (currentPodStartTime != null) { + double podDuration = currentTime - currentPodStartTime; + AdPod pod = new AdPod(); + pod.startTime = currentPodStartTime; + pod.duration = podDuration; + pod.endTime = currentTime; + adPods.add(pod); + + Log.d(TAG, String.format(" Final pod %.2fs-%.2fs (%.2fs)", + pod.startTime, pod.endTime, pod.duration)); + } + + // Calculate actual duration + double actualDuration = currentTime - currentAdBreak.startTime; + + // Filter false positives (< MIN_AD_DURATION) + if (actualDuration >= MediaTailorConstants.MIN_AD_DURATION) { + currentAdBreak.duration = actualDuration; + currentAdBreak.endTime = currentTime; + currentAdBreak.pods = adPods; + adBreaks.add(currentAdBreak); + + Log.d(TAG, String.format("CUE-IN at %.2fs, total duration=%.2fs, %d pod(s)", + currentTime, actualDuration, adPods.size())); + } else { + Log.d(TAG, String.format("Ignoring short ad break (%.2fs)", actualDuration)); + } + + // Reset state + currentAdBreak = null; + isInAdBreak = false; + currentPodStartTime = null; + lastMapUrl = null; + adPods = new ArrayList<>(); + } + } + + // Detect MAP URL changes (pod boundaries) + else if (isInAdBreak && line.startsWith("#EXT-X-MAP")) { + Matcher matcher = MediaTailorConstants.REGEX_MAP.matcher(line); + if (matcher.find()) { + String mapUrl = matcher.group(1); + + if (mapUrl != null && !mapUrl.equals(lastMapUrl)) { + // New MAP = new pod boundary + if (currentPodStartTime != null && lastMapUrl != null) { + double podDuration = currentTime - currentPodStartTime; + AdPod pod = new AdPod(); + pod.startTime = currentPodStartTime; + pod.duration = podDuration; + pod.endTime = currentTime; + adPods.add(pod); + + Log.d(TAG, String.format(" Pod boundary (MAP change) %.2fs-%.2fs (%.2fs)", + pod.startTime, pod.endTime, pod.duration)); + } + currentPodStartTime = currentTime; + lastMapUrl = mapUrl; + } + } + } + + // Track time via EXTINF (segment duration) + else if (line.startsWith("#EXTINF:")) { + Matcher matcher = MediaTailorConstants.REGEX_EXTINF.matcher(line); + if (matcher.find()) { + double segmentDuration = Double.parseDouble(matcher.group(1)); + currentTime += segmentDuration; + } + } + } + + // Handle unclosed ad break (shouldn't happen with proper manifests) + if (currentAdBreak != null && currentAdBreak.duration > 0) { + currentAdBreak.endTime = currentAdBreak.startTime + currentAdBreak.duration; + currentAdBreak.pods = adPods; + adBreaks.add(currentAdBreak); + Log.d(TAG, "Added unclosed ad break"); + } + + return adBreaks; + } + + /** + * Extract target duration from HLS manifest + * Used to determine optimal polling interval for LIVE streams + * + * @param manifestText The HLS manifest content + * @return Target duration in seconds, or 0 if not found + */ + public static int extractTargetDuration(String manifestText) { + if (manifestText == null || manifestText.isEmpty()) { + return 0; + } + + Matcher matcher = MediaTailorConstants.REGEX_TARGET_DURATION.matcher(manifestText); + if (matcher.find()) { + int targetDuration = Integer.parseInt(matcher.group(1)); + Log.d(TAG, "Found target duration: " + targetDuration + "s"); + return targetDuration; + } + + return 0; + } + + /** + * Extract first variant stream URL from master playlist + * Master playlists don't contain SCTE-35 markers - need to fetch variant playlist + * + * @param masterPlaylistText The master playlist content + * @param baseUrl The base URL for resolving relative paths + * @return First variant stream URL, or null if not found + */ + public static String extractFirstVariantUrl(String masterPlaylistText, String baseUrl) { + if (masterPlaylistText == null || masterPlaylistText.isEmpty()) { + return null; + } + + String[] lines = masterPlaylistText.split("\n"); + String lastStreamInfo = null; + + for (String line : lines) { + line = line.trim(); + + // Look for #EXT-X-STREAM-INF (variant stream definition) + if (line.startsWith("#EXT-X-STREAM-INF")) { + lastStreamInfo = line; + } + // The next non-comment line after STREAM-INF is the variant URL + else if (lastStreamInfo != null && !line.startsWith("#") && !line.isEmpty()) { + String variantUrl = line; + + // If relative URL, make it absolute + if (!variantUrl.startsWith("http")) { + // Extract base URL (remove master.m3u8 part) + int lastSlash = baseUrl.lastIndexOf('/'); + if (lastSlash > 0) { + String base = baseUrl.substring(0, lastSlash + 1); + variantUrl = base + variantUrl; + } + } + + Log.d(TAG, "Found variant stream URL: " + variantUrl); + return variantUrl; + } + } + + Log.w(TAG, "No variant stream found in master playlist"); + return null; + } + + /** + * Check if manifest is a master playlist (contains #EXT-X-STREAM-INF) + * + * @param manifestText The manifest content + * @return true if master playlist, false if media playlist + */ + public static boolean isMasterPlaylist(String manifestText) { + return manifestText != null && manifestText.contains("#EXT-X-STREAM-INF"); + } + + /** + * Parse DASH manifest for SCTE-35 EventStream markers + * TODO: Implement DASH manifest parsing + * Based on videoJS mt.js:573-685 + * + * @param xmlText The DASH manifest content (.mpd) + * @return List of detected ad breaks + */ + public static List parseDASHManifest(String xmlText) { + List adBreaks = new ArrayList<>(); + + // TODO: Implement DASH parsing + // 1. Parse XML using Android XML parser + // 2. Find elements with SCTE-35 schemeIdUri + // 3. Extract elements with presentationTime, duration, id + // 4. Convert timescale to seconds + // 5. Create AdBreak objects from events + + Log.w(TAG, "DASH manifest parsing not yet implemented"); + return adBreaks; + } + + /** + * Merge manifest-detected ads with existing schedule + * Deduplicates and enriches ads from both sources + * + * @param adSchedule Existing ad schedule (may contain tracking API data) + * @param manifestAdBreaks Ad breaks detected from manifest parsing + * @return Merged and deduplicated ad schedule + */ + public static List mergeAdSchedules(List adSchedule, List manifestAdBreaks) { + if (manifestAdBreaks == null || manifestAdBreaks.isEmpty()) { + return adSchedule; + } + + // If no existing schedule, use manifest ads directly + if (adSchedule.isEmpty()) { + Log.d(TAG, "Using manifest ads as primary schedule"); + return new ArrayList<>(manifestAdBreaks); + } + + List mergedSchedule = new ArrayList<>(adSchedule); + + // Merge: Check for overlaps with tracking API data + for (AdBreak manifestBreak : manifestAdBreaks) { + boolean found = false; + + // Check if this break already exists (from tracking API) + for (AdBreak existingBreak : mergedSchedule) { + // Consider it the same break if start times are within AD_TIMING_TOLERANCE + if (Math.abs(existingBreak.startTime - manifestBreak.startTime) + < MediaTailorConstants.AD_TIMING_TOLERANCE) { + found = true; + + // Enrich tracking API break with manifest data + if (existingBreak.pods.isEmpty() && !manifestBreak.pods.isEmpty()) { + existingBreak.pods = manifestBreak.pods; + Log.d(TAG, String.format("Enriched break at %.2fs with %d manifest pod(s)", + existingBreak.startTime, manifestBreak.pods.size())); + } + existingBreak.source = MediaTailorConstants.AD_SOURCE_MANIFEST_AND_TRACKING; + break; + } + } + + // If not found, add manifest break to schedule + if (!found) { + mergedSchedule.add(manifestBreak); + Log.d(TAG, String.format("Added new manifest-only break at %.2fs", manifestBreak.startTime)); + } + } + + // Sort by start time (using Collections.sort for API 16+ compatibility) + java.util.Collections.sort(mergedSchedule, new java.util.Comparator() { + @Override + public int compare(AdBreak a, AdBreak b) { + return Double.compare(a.startTime, b.startTime); + } + }); + + Log.d(TAG, "Merged ad schedule now has " + mergedSchedule.size() + " break(s)"); + return mergedSchedule; + } + + /** + * Calculate ad position (pre/mid/post) for VOD streams + * + * @param adStartTime Ad break start time in seconds + * @param contentDuration Total content duration in seconds + * @param tolerance Time tolerance for pre/post detection + * @return Ad position: "pre", "mid", or "post" + */ + public static String calculateAdPosition(double adStartTime, double contentDuration, double tolerance) { + if (adStartTime <= tolerance) { + return MediaTailorConstants.AD_POSITION_PRE; + } else if (adStartTime >= contentDuration - tolerance) { + return MediaTailorConstants.AD_POSITION_POST; + } else { + return MediaTailorConstants.AD_POSITION_MID; + } + } +} diff --git a/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/MediaTailorConstants.java b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/MediaTailorConstants.java new file mode 100644 index 00000000..ed21c4ca --- /dev/null +++ b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/MediaTailorConstants.java @@ -0,0 +1,72 @@ +package com.newrelic.videoagent.mediatailor.utils; + +import java.util.regex.Pattern; + +/** + * MediaTailor Constants + * Contains all regex patterns, config defaults, and constant values for AWS MediaTailor ad tracking + * Based on VideoJS mt-constants.js + */ +public class MediaTailorConstants { + + // HLS Manifest Regex Patterns + public static final Pattern REGEX_CUE_OUT = Pattern.compile("#EXT-X-CUE-OUT:DURATION=([\\d.]+)"); + public static final Pattern REGEX_CUE_IN = Pattern.compile("#EXT-X-CUE-IN"); + public static final Pattern REGEX_DISCONTINUITY = Pattern.compile("#EXT-X-DISCONTINUITY"); + public static final Pattern REGEX_MAP = Pattern.compile("#EXT-X-MAP:URI=\"([^\"]+)\""); + public static final Pattern REGEX_EXTINF = Pattern.compile("#EXTINF:([\\d.]+)"); + public static final Pattern REGEX_TARGET_DURATION = Pattern.compile("#EXT-X-TARGETDURATION:(\\d+)"); + + // MediaTailor URL Patterns + public static final String MT_SEGMENT_PATTERN = "segments.mediatailor"; + public static final String MT_URL_PATTERN = ".mediatailor."; + + // Default Configuration + public static final boolean DEFAULT_ENABLE_MANIFEST_PARSING = true; + public static final boolean DEFAULT_ENABLE_TRACKING_API = true; + public static final long DEFAULT_LIVE_MANIFEST_POLL_INTERVAL_MS = 5000; // 5s + public static final long DEFAULT_LIVE_TRACKING_POLL_INTERVAL_MS = 10000; // 10s + public static final int DEFAULT_TRACKING_API_TIMEOUT_MS = 5000; // 5s + public static final long DEFAULT_TIME_UPDATE_INTERVAL_MS = 250; // 250ms + + // Timing Thresholds + public static final double MIN_AD_DURATION = 0.5; // Minimum ad duration in seconds (filter false positives) + public static final double AD_TIMING_TOLERANCE = 0.5; // Tolerance for matching ad times in seconds + public static final long POST_AD_PAUSE_THRESHOLD_MS = 500; // Ignore pause events within 500ms after ad break + + // Stream Types + public static final String STREAM_TYPE_VOD = "vod"; + public static final String STREAM_TYPE_LIVE = "live"; + + // Manifest Types + public static final String MANIFEST_TYPE_HLS = "hls"; + public static final String MANIFEST_TYPE_DASH = "dash"; + + // Ad Position Types (for VOD only) + public static final String AD_POSITION_PRE = "pre"; + public static final String AD_POSITION_MID = "mid"; + public static final String AD_POSITION_POST = "post"; + + // Ad Detection Sources + public static final String AD_SOURCE_MANIFEST_CUE = "manifest-cue"; + public static final String AD_SOURCE_TRACKING_API = "tracking-api"; + public static final String AD_SOURCE_MANIFEST_AND_TRACKING = "manifest+tracking"; + public static final String AD_SOURCE_VHS_DISCONTINUITY = "vhs-discontinuity"; + public static final String AD_SOURCE_DASH_EMSG = "dash-emsg"; + public static final String AD_SOURCE_DASH_EVENT_STREAM = "dash-event-stream"; + + // Quartile Percentages + public static final double QUARTILE_Q1 = 0.25; // 25% + public static final double QUARTILE_Q2 = 0.50; // 50% + public static final double QUARTILE_Q3 = 0.75; // 75% + + // Ad Partner + public static final String AD_PARTNER = "aws-mediatailor"; + + // Heartbeat Interval + public static final long AD_HEARTBEAT_INTERVAL_MS = 30000; // 30 seconds + + private MediaTailorConstants() { + // Prevent instantiation + } +} diff --git a/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/NetworkUtils.java b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/NetworkUtils.java new file mode 100644 index 00000000..95a431d2 --- /dev/null +++ b/NRMediaTailorTracker/src/main/java/com/newrelic/videoagent/mediatailor/utils/NetworkUtils.java @@ -0,0 +1,185 @@ +package com.newrelic.videoagent.mediatailor.utils; + +import android.util.Log; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * Network Utilities for MediaTailor + * Handles HTTP requests for manifest and tracking API fetching + */ +public class NetworkUtils { + + private static final String TAG = "MTNetworkUtils"; + + /** + * Callback interface for async fetch operations + */ + public interface FetchCallback { + void onSuccess(String response); + void onError(Exception error); + } + + /** + * Fetch text content from a URL (synchronous) + * + * @param urlString The URL to fetch + * @param timeoutMs Connection and read timeout in milliseconds + * @return Response text, or null on error + */ + public static String fetchText(String urlString, int timeoutMs) { + try { + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(timeoutMs); + connection.setReadTimeout(timeoutMs); + + int responseCode = connection.getResponseCode(); + + if (responseCode == HttpURLConnection.HTTP_OK) { + BufferedReader reader = new BufferedReader( + new InputStreamReader(connection.getInputStream()) + ); + StringBuilder response = new StringBuilder(); + String line; + + while ((line = reader.readLine()) != null) { + response.append(line).append("\n"); + } + reader.close(); + connection.disconnect(); + + return response.toString(); + } else { + Log.w(TAG, "HTTP request failed with code: " + responseCode + " for URL: " + urlString); + connection.disconnect(); + return null; + } + + } catch (Exception e) { + Log.e(TAG, "Error fetching URL: " + urlString, e); + return null; + } + } + + /** + * Fetch text content from a URL (asynchronous) + * + * @param urlString The URL to fetch + * @param timeoutMs Connection and read timeout in milliseconds + * @param callback Callback to receive result + */ + public static void fetchTextAsync(final String urlString, final int timeoutMs, final FetchCallback callback) { + new Thread(new Runnable() { + @Override + public void run() { + try { + String response = fetchText(urlString, timeoutMs); + if (response != null) { + callback.onSuccess(response); + } else { + callback.onError(new Exception("HTTP request failed")); + } + } catch (Exception e) { + callback.onError(e); + } + } + }).start(); + } + + /** + * Extract session ID from MediaTailor URL + * Pattern: ?aws.sessionId=SESSION_ID or /v1/session/SESSION_ID/ + * + * @param url The MediaTailor URL + * @return Session ID, or null if not found + */ + public static String extractSessionId(String url) { + if (url == null || url.isEmpty()) { + return null; + } + + // Try query parameter pattern: ?aws.sessionId=... + if (url.contains("aws.sessionId=")) { + int start = url.indexOf("aws.sessionId=") + "aws.sessionId=".length(); + int end = url.indexOf("&", start); + if (end == -1) { + end = url.length(); + } + return url.substring(start, end); + } + + // Try path pattern: /v1/session/SESSION_ID/ + if (url.contains("/v1/session/")) { + int start = url.indexOf("/v1/session/") + "/v1/session/".length(); + int end = url.indexOf("/", start); + if (end == -1) { + end = url.length(); + } + return url.substring(start, end); + } + + return null; + } + + /** + * Build tracking API URL from MediaTailor session + * Format: https://{domain}/v1/tracking/{sessionId} + * + * @param manifestUrl The MediaTailor manifest URL + * @param sessionId The session ID + * @return Tracking API URL, or null if cannot construct + */ + public static String buildTrackingUrl(String manifestUrl, String sessionId) { + if (manifestUrl == null || sessionId == null) { + return null; + } + + try { + URL url = new URL(manifestUrl); + String protocol = url.getProtocol(); + String host = url.getHost(); + + return protocol + "://" + host + "/v1/tracking/" + sessionId; + } catch (Exception e) { + Log.e(TAG, "Error building tracking URL", e); + return null; + } + } + + /** + * Detect manifest type from URL + * + * @param url The manifest URL + * @return "hls" for .m3u8, "dash" for .mpd, or null for unknown + */ + public static String detectManifestType(String url) { + if (url == null || url.isEmpty()) { + return null; + } + + url = url.toLowerCase(); + + if (url.contains(".m3u8")) { + return MediaTailorConstants.MANIFEST_TYPE_HLS; + } else if (url.contains(".mpd")) { + return MediaTailorConstants.MANIFEST_TYPE_DASH; + } + + return null; + } + + /** + * Check if URL is a MediaTailor URL + * + * @param url The URL to check + * @return true if URL contains ".mediatailor." + */ + public static boolean isMediaTailorUrl(String url) { + return url != null && url.contains(MediaTailorConstants.MT_URL_PATTERN); + } +} diff --git a/NewRelicVideoCore/src/main/java/com/newrelic/videoagent/core/NRVideo.java b/NewRelicVideoCore/src/main/java/com/newrelic/videoagent/core/NRVideo.java index fbc71466..c8e38194 100644 --- a/NewRelicVideoCore/src/main/java/com/newrelic/videoagent/core/NRVideo.java +++ b/NewRelicVideoCore/src/main/java/com/newrelic/videoagent/core/NRVideo.java @@ -48,17 +48,33 @@ public static Integer addPlayer(NRVideoPlayerConfiguration config) { throw new IllegalStateException("NRVideo is not initialized. Call NRVideo.newBuilder(context).withConfiguration(config).build() first."); } - // Create content tracker with ExoPlayer instance + // Detect MediaTailor stream + boolean isMediaTailor = isMediaTailorStream(config.getPlayer()); + + // Create content tracker (always ExoPlayer) NRTracker contentTracker = createContentTracker(); NRTracker adsTracker = null; - if (config.isAdEnabled()) { - adsTracker = createAdTracker(); - NRLog.d("add tracker is added"); + + // Create appropriate ads tracker based on stream type + if (isMediaTailor) { + // MediaTailor tracker for SSAI ad detection + adsTracker = createMediaTailorTracker(); + NRLog.d("MediaTailor tracker added"); + } else if (config.isAdEnabled()) { + // IMA tracker for client-side ads + NRLog.d("IMA ads tracker added"); } // Now start the tracker system Integer trackerId = NewRelicVideoAgent.getInstance().start(contentTracker, adsTracker); ((NRVideoTracker) contentTracker).setPlayer(config.getPlayer()); + + // MediaTailor tracker needs player reference for event listening and ad detection + // IMA tracker doesn't need it as it uses its own AdsManager + if (isMediaTailor && adsTracker != null) { + ((NRVideoTracker) adsTracker).setPlayer(config.getPlayer()); + } + NRLog.i("NRVideo initialization completed successfully with tracker ID: " + trackerId + " and player name:" + config.getPlayerName()); if (config.getCustomAttributes() != null && !config.getCustomAttributes().isEmpty()) { for (Map.Entry entry : config.getCustomAttributes().entrySet()) { @@ -267,6 +283,34 @@ private static NRTracker createAdTracker() { } } + private static NRTracker createMediaTailorTracker() { + try { + // Create MediaTailor tracker + Class mtTrackerClass = Class.forName("com.newrelic.videoagent.mediatailor.tracker.NRTrackerMediaTailor"); + return (NRTracker) mtTrackerClass.newInstance(); + } catch (Exception e) { + NRLog.w("Failed to create MediaTailor tracker, falling back to ExoPlayer tracker: " + e.getMessage()); + // Fallback to ExoPlayer tracker + return createContentTracker(); + } + } + + private static boolean isMediaTailorStream(Object player) { + try { + // Use reflection to call NRTrackerMediaTailor.isUsing() + Class mtTrackerClass = Class.forName("com.newrelic.videoagent.mediatailor.tracker.NRTrackerMediaTailor"); + java.lang.reflect.Method isUsingMethod = mtTrackerClass.getMethod("isUsing", androidx.media3.exoplayer.ExoPlayer.class); + Boolean result = (Boolean) isUsingMethod.invoke(null, player); + NRLog.d("MediaTailor detection result: " + result); + return result; + } catch (Exception e) { + // If MediaTailor tracker not available or detection fails, assume not MediaTailor + NRLog.w("MediaTailor detection failed: " + e.getMessage()); + e.printStackTrace(); + return false; + } + } + /** * Sets the user ID. * diff --git a/app/build.gradle b/app/build.gradle index 4da34dc9..d85b4771 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -56,6 +56,7 @@ dependencies { implementation project(path: ':NewRelicVideoCore') implementation project(path: ':NRExoPlayerTracker') implementation project(path: ':NRIMATracker') + implementation project(path: ':NRMediaTailorTracker') testImplementation 'junit:junit:4.13.2' // Updated AndroidX Test dependencies that are compatible with Android 12+ @@ -68,6 +69,7 @@ dependencies { implementation 'androidx.media3:media3-ui:1.1.0' implementation 'androidx.media3:media3-exoplayer-ima:1.1.0' implementation 'androidx.media3:media3-exoplayer-dash:1.1.0' + implementation 'androidx.media3:media3-exoplayer-hls:1.1.0' implementation 'com.android.support:multidex:1.0.3' } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ae9a0c9a..0abd31fc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,6 +13,11 @@ android:label="@string/title_activity_video_player_ads" android:theme="@style/Theme.NRVideoProject.NoActionBar" android:exported="false"> + diff --git a/app/src/main/java/com/newrelic/nrvideoproject/MainActivity.java b/app/src/main/java/com/newrelic/nrvideoproject/MainActivity.java index 6d448c0a..28ce2808 100644 --- a/app/src/main/java/com/newrelic/nrvideoproject/MainActivity.java +++ b/app/src/main/java/com/newrelic/nrvideoproject/MainActivity.java @@ -34,6 +34,17 @@ protected void onCreate(Bundle savedInstanceState) { findViewById(R.id.video1).setOnClickListener(this); findViewById(R.id.video2).setOnClickListener(this); findViewById(R.id.video3).setOnClickListener(this); + + // MediaTailor button - launches MediaTailor player with auto-start + findViewById(R.id.mediatailor_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + Intent intent = new Intent(MainActivity.this, VideoPlayerMediaTailor.class); + intent.putExtra("autoplay", true); + startActivity(intent); + } + }); + findViewById(R.id.video4).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { diff --git a/app/src/main/java/com/newrelic/nrvideoproject/VideoPlayerMediaTailor.java b/app/src/main/java/com/newrelic/nrvideoproject/VideoPlayerMediaTailor.java new file mode 100644 index 00000000..2306e71f --- /dev/null +++ b/app/src/main/java/com/newrelic/nrvideoproject/VideoPlayerMediaTailor.java @@ -0,0 +1,287 @@ +package com.newrelic.nrvideoproject; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Player; +import androidx.media3.datasource.DataSource; +import androidx.media3.datasource.DefaultDataSource; +import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; +import androidx.media3.ui.PlayerView; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.TextView; +import com.newrelic.videoagent.core.NRVideo; +import com.newrelic.videoagent.core.NRVideoPlayerConfiguration; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class VideoPlayerMediaTailor extends AppCompatActivity { + + private ExoPlayer player; + private Integer trackerId; + + private RadioGroup initModeGroup; + private RadioButton radioAuto; + private RadioButton radioManual; + private RadioButton radioAll; + private Button loadStreamButton; + private TextView statusText; + + private ExecutorService executorService; + private Handler mainHandler; + + // MediaTailor configuration + // TODO: Replace with your actual AWS MediaTailor credentials + private static final String CLOUDFRONT_ID = "YOUR_CLOUDFRONT_ID_HERE"; + private static final String REGION = "YOUR_AWS_REGION_HERE"; // e.g., us-east-1, ap-southeast-2 + private static final String ACCOUNT_ID = "YOUR_ACCOUNT_ID_HERE"; + private static final String PLAYBACK_CONFIG = "YOUR_PLAYBACK_CONFIG_HERE"; + private static final String BASE_URL = "https://" + CLOUDFRONT_ID + ".mediatailor." + REGION + ".amazonaws.com"; + + private enum InitMode { + AUTO, MANUAL, ALL + } + + private InitMode currentMode = InitMode.AUTO; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_video_player_mediatailor); + + executorService = Executors.newSingleThreadExecutor(); + mainHandler = new Handler(Looper.getMainLooper()); + + // Check if launched from MainActivity with autoplay + boolean autoplay = getIntent().getBooleanExtra("autoplay", false); + + String video = getIntent().getStringExtra("video"); + Log.v("VideoPlayerMediaTailor", "Play video with AWS MediaTailor: " + video); + + // Initialize UI components + initModeGroup = findViewById(R.id.init_mode_group); + radioAuto = findViewById(R.id.radio_auto); + radioManual = findViewById(R.id.radio_manual); + radioAll = findViewById(R.id.radio_all); + loadStreamButton = findViewById(R.id.load_stream_button); + statusText = findViewById(R.id.status_text); + + // If launched with autoplay flag, hide controls and auto-start + if (autoplay) { + findViewById(R.id.controls_container).setVisibility(View.GONE); + updateStatus("Initializing MediaTailor session..."); + initializeSessionAndPlay(); + } else { + // Setup radio button listeners for manual mode selection + setupModeListeners(); + + // Auto mode is default - load immediately + if (currentMode == InitMode.AUTO) { + updateStatus("Auto mode: Loading stream..."); + initializeSessionAndPlay(); + } + } + } + + private void setupModeListeners() { + initModeGroup.setOnCheckedChangeListener((group, checkedId) -> { + if (checkedId == R.id.radio_auto) { + currentMode = InitMode.AUTO; + updateStatus("Auto mode: Stream will load automatically"); + loadStreamButton.setVisibility(View.GONE); + initializeSessionAndPlay(); + } else if (checkedId == R.id.radio_manual) { + currentMode = InitMode.MANUAL; + updateStatus("Manual mode: Click button to load stream"); + loadStreamButton.setVisibility(View.VISIBLE); + if (player != null) { + player.stop(); + } + } else if (checkedId == R.id.radio_all) { + currentMode = InitMode.ALL; + updateStatus("All mode: Supports both auto and manual initialization. Button available for manual control."); + loadStreamButton.setVisibility(View.VISIBLE); + initializeSessionAndPlay(); + } + }); + + loadStreamButton.setOnClickListener(v -> { + updateStatus("Loading stream..."); + initializeSessionAndPlay(); + }); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (trackerId != null) { + NRVideo.releaseTracker(trackerId); + } + if (player != null) { + player.stop(); + player.release(); + } + if (executorService != null) { + executorService.shutdown(); + } + } + + private void updateStatus(String message) { + updateStatus(message, false); + } + + private void updateStatus(String message, boolean isError) { + mainHandler.post(() -> { + statusText.setText(message); + if (isError) { + statusText.setBackgroundColor(Color.parseColor("#F8D7DA")); + statusText.setTextColor(Color.parseColor("#721C24")); + } else { + statusText.setBackgroundColor(Color.parseColor("#F8F9FA")); + statusText.setTextColor(Color.parseColor("#495057")); + } + }); + Log.d("VideoPlayerMediaTailor", message); + } + + /** + * Initialize MediaTailor session and play video with sessionId + * This follows the pattern from the videojs sample + */ + private void initializeSessionAndPlay() { + executorService.execute(() -> { + try { + updateStatus("Initializing session..."); + + // Call MediaTailor session initialization endpoint (using HLS since origin is HLS) + String sessionEndpoint = BASE_URL + "/v1/session/" + ACCOUNT_ID + "/" + PLAYBACK_CONFIG + "/hls"; + Log.d("VideoPlayerMediaTailor", "Session endpoint: " + sessionEndpoint); + + URL url = new URL(sessionEndpoint); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + + // Send empty JSON body + OutputStream os = connection.getOutputStream(); + os.write("{}".getBytes()); + os.flush(); + os.close(); + + int responseCode = connection.getResponseCode(); + Log.d("VideoPlayerMediaTailor", "Session init response code: " + responseCode); + + if (responseCode == HttpURLConnection.HTTP_OK) { + BufferedReader in = new BufferedReader(new InputStreamReader(connection.getInputStream())); + StringBuilder response = new StringBuilder(); + String line; + while ((line = in.readLine()) != null) { + response.append(line); + } + in.close(); + + JSONObject jsonResponse = new JSONObject(response.toString()); + String manifestUrl = jsonResponse.getString("manifestUrl"); + Log.d("VideoPlayerMediaTailor", "Manifest URL: " + manifestUrl); + + // Extract sessionId from manifestUrl + Pattern pattern = Pattern.compile("aws\\.sessionId=([^&]+)"); + Matcher matcher = pattern.matcher(manifestUrl); + + if (matcher.find()) { + String sessionId = matcher.group(1); + Log.d("VideoPlayerMediaTailor", "Extracted sessionId: " + sessionId); + + // Construct HLS URL with sessionId (following videoJS pattern) + // Use master.m3u8 path and append sessionId as query parameter + String hlsUrl = BASE_URL + "/v1/master/" + ACCOUNT_ID + "/" + PLAYBACK_CONFIG + "/master.m3u8?aws.sessionId=" + sessionId; + Log.d("VideoPlayerMediaTailor", "HLS URL with sessionId: " + hlsUrl); + + updateStatus("Session initialized! Loading stream..."); + + // Play video on main thread + mainHandler.post(() -> playVideo(hlsUrl, sessionId)); + } else { + throw new Exception("Failed to extract sessionId from manifest URL"); + } + } else { + throw new Exception("Session init failed with response code: " + responseCode); + } + + connection.disconnect(); + + } catch (Exception e) { + Log.e("VideoPlayerMediaTailor", "Session initialization error", e); + updateStatus("Session init error: " + e.getMessage(), true); + } + }); + } + + /** + * Play video with the given URL and sessionId + */ + private void playVideo(String videoUrl, String sessionId) { + updateStatus("Initializing player..."); + + // Clean up existing player if any + if (player != null) { + if (trackerId != null) { + NRVideo.releaseTracker(trackerId); + trackerId = null; + } + player.stop(); + player.release(); + player = null; + } + + // Create a DataSource.Factory for network access + DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(this); + + // Create a MediaSource factory with DASH and HLS support + DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(this); + + // Build ExoPlayer with media source factory + player = new ExoPlayer.Builder(this) + .setMediaSourceFactory(mediaSourceFactory) + .build(); + + // IMPORTANT: Set media item BEFORE NRVideo.addPlayer() so MediaTailor detection can work + // The detection checks player.getCurrentMediaItem().uri for ".mediatailor." pattern + player.setMediaItem(MediaItem.fromUri(videoUrl)); + + NRVideoPlayerConfiguration playerConfiguration = new NRVideoPlayerConfiguration("mediatailor-player", player, false, null); + trackerId = NRVideo.addPlayer(playerConfiguration); + + Log.d("VideoPlayerMediaTailor", "Player configured with dual trackers (ExoPlayer + MediaTailor) and sessionId: " + sessionId); + + PlayerView playerView = findViewById(R.id.player); + playerView.setPlayer(player); + + updateStatus("Loading HLS stream..."); + + // MediaTailor provides an HLS manifest with ads stitched in + player.setPlayWhenReady(true); // Autoplay + player.prepare(); + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 65a5f605..54e16aa4 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -6,6 +6,19 @@ android:layout_height="match_parent" tools:context=".MainActivity"> +