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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import android.os.Looper;

import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.C;
import androidx.media3.common.MediaLibraryInfo;
import androidx.media3.common.MediaMetadata;
Expand Down Expand Up @@ -37,7 +39,12 @@

/**
* New Relic Video tracker for ExoPlayer.
* <p>
* @OptIn is required for Media3 APIs which are marked as @UnstableApi.
* This is by design as per Google's Media3 documentation.
* @see <a href="https://developer.android.com/media/media3/exoplayer/customization#unstable-api">Media3 Unstable API Documentation</a>
*/
@OptIn(markerClass = UnstableApi.class)
public class NRTrackerExoPlayer extends NRVideoTracker implements Player.Listener, AnalyticsListener {

protected ExoPlayer player;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public final class NRVideoConfiguration {
private final boolean memoryOptimized;
private final boolean debugLoggingEnabled;
private final boolean isTV;
private final String collectorAddress;

// Performance optimization constants
private static final int DEFAULT_HARVEST_CYCLE_SECONDS = 5 * 60; // 5 minutes
Expand Down Expand Up @@ -78,6 +79,7 @@ private NRVideoConfiguration(Builder builder) {
this.memoryOptimized = builder.memoryOptimized;
this.debugLoggingEnabled = builder.debugLoggingEnabled;
this.isTV = builder.isTV;
this.collectorAddress = builder.collectorAddress;
}

// Immutable getters
Expand All @@ -91,6 +93,7 @@ private NRVideoConfiguration(Builder builder) {
public boolean isMemoryOptimized() { return memoryOptimized; }
public boolean isDebugLoggingEnabled() { return debugLoggingEnabled; }
public boolean isTV() { return isTV; }
public String getCollectorAddress() { return collectorAddress; }

/**
* Get dead letter retry interval in milliseconds
Expand All @@ -107,41 +110,56 @@ public long getDeadLetterRetryInterval() {
}

/**
* Enterprise-grade region identification with multiple fallback strategies
* Thread-safe and optimized for performance
* Parse region code from application token prefix
* Matches NewRelic iOS Agent pattern: extracts region prefix before 'x'
* Examples: "EUxABCD..." -> "EU", "APxABCD..." -> "AP", "AA..." -> ""
*/
private static String parseRegionFromToken(String applicationToken) {
if (applicationToken == null || applicationToken.length() < 3) {
return "";
}

// Find the first 'x' in the token
int xIndex = applicationToken.indexOf('x');
if (xIndex == -1) {
return ""; // No region prefix found
}

// Extract everything before the first 'x'
String regionCode = applicationToken.substring(0, xIndex);

// Remove any trailing 'x' characters
while (regionCode.length() > 0 && regionCode.charAt(regionCode.length() - 1) == 'x') {
regionCode = regionCode.substring(0, regionCode.length() - 1);
}

return regionCode;
}

/**
* Identify region with proper token parsing and fallback logic
* Behavior similar to NewRelic iOS Agent's NRMAAgentConfiguration
*/
private static String identifyRegion(String applicationToken) {
if (applicationToken == null || applicationToken.length() < 10) {
return "US"; // Safe default
}

String cleanToken = applicationToken.trim().toLowerCase();
// First, try to parse region from token prefix (e.g., "EUx", "APx")
String regionCode = parseRegionFromToken(applicationToken);

// Strategy 1: Direct prefix matching (most reliable)
for (Map.Entry<String, String> entry : REGION_MAPPINGS.entrySet()) {
String regionKey = entry.getKey().toLowerCase();
if (cleanToken.startsWith(regionKey) || cleanToken.contains("-" + regionKey + "-")) {
return entry.getValue();
}
}
if (regionCode != null && regionCode.length() > 0) {
// Convert region code to uppercase and validate
String upperRegion = regionCode.toUpperCase();

// Strategy 2: Token structure analysis
if (cleanToken.length() >= 40) { // Standard NR token length
// EU tokens often have specific patterns
if (cleanToken.contains("eu") || cleanToken.contains("europe")) {
return "EU";
}
// AP tokens often have specific patterns
if (cleanToken.contains("ap") || cleanToken.contains("asia") || cleanToken.contains("pacific")) {
return "AP";
}
// Gov tokens have specific patterns
if (cleanToken.contains("gov") || cleanToken.contains("fed")) {
return "GOV";
// Map region codes to standard regions
String mappedRegion = REGION_MAPPINGS.get(upperRegion);
if (mappedRegion != null) {
return mappedRegion;
}
}

// Strategy 3: Default to US for production stability
// Default to US for standard tokens without region prefix
return "US";
}

Expand All @@ -158,6 +176,7 @@ public static final class Builder {
private boolean memoryOptimized = true;
private boolean debugLoggingEnabled = false;
private boolean isTV = false;
private String collectorAddress = null;

public Builder(String applicationToken) {
this.applicationToken = applicationToken;
Expand Down Expand Up @@ -235,6 +254,16 @@ public Builder enableLogging() {
return this;
}

/**
* Set custom collector domain address for /connect and /data endpoints (optional)
* Example: "staging-mobile-collector.newrelic.com" or "mobile-collector.newrelic.com"
* If not set, will be auto-detected from application token region
*/
public Builder withCollectorAddress(String collectorAddress) {
this.collectorAddress = collectorAddress;
return this;
}

private void applyTVOptimizations() {
this.harvestCycleSeconds = TV_HARVEST_CYCLE_SECONDS;
this.liveHarvestCycleSeconds = TV_LIVE_HARVEST_CYCLE_SECONDS;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,18 +154,25 @@ private boolean isTokenValid() {

/**
* Build token endpoint URL based on region
* If collectorAddress is explicitly set, use it for /connect endpoint
* Otherwise, auto-detect from region
*/
private String buildTokenEndpoint() {
String region = configuration.getRegion().toUpperCase();
// If collectorAddress is explicitly set, use it for /connect endpoint
if (configuration.getCollectorAddress() != null && !configuration.getCollectorAddress().isEmpty()) {
return "https://" + configuration.getCollectorAddress() + "/mobile/v5/connect";
}

// Otherwise, auto-detect from region
String region = configuration.getRegion();
region = (region != null) ? region.toUpperCase() : "US";
switch (region) {
case "EU":
return "https://mobile-collector.eu.newrelic.com/mobile/v5/connect";
case "AP":
return "https://mobile-collector.ap.newrelic.com/mobile/v5/connect";
case "GOV":
return "https://mobile-collector.gov.newrelic.com/mobile/v5/connect";
case "STAGING":
return "https://mobile-collector.staging.newrelic.com/mobile/v5/connect";
default:
return "https://mobile-collector.newrelic.com/mobile/v5/connect";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ public class OptimizedHttpClient implements HttpClientInterface {
REGIONAL_ENDPOINTS.put("EU", "https://mobile-collector.eu.newrelic.com/mobile/v3/data");
REGIONAL_ENDPOINTS.put("AP", "https://mobile-collector.ap.newrelic.com/mobile/v3/data");
REGIONAL_ENDPOINTS.put("GOV", "https://mobile-collector.gov.newrelic.com/mobile/v3/data");
REGIONAL_ENDPOINTS.put("STAGING", "https://mobile-collector.staging.newrelic.com/mobile/v3/data");
REGIONAL_ENDPOINTS.put("DEFAULT", REGIONAL_ENDPOINTS.get("US"));
}

Expand All @@ -59,10 +58,16 @@ public OptimizedHttpClient(NRVideoConfiguration configuration, android.content.C
this.tokenManager = new TokenManager(context, configuration);
this.deviceInfo = DeviceInformation.getInstance(context);

// Set endpoint URL based on region, defaulting to US if not found
String region = configuration.getRegion().toUpperCase();
String regionEndpoint = REGIONAL_ENDPOINTS.get(region);
this.endpointUrl = regionEndpoint != null ? regionEndpoint : REGIONAL_ENDPOINTS.get("DEFAULT");
// If collectorAddress is explicitly set, use it
if (configuration.getCollectorAddress() != null && !configuration.getCollectorAddress().isEmpty()) {
this.endpointUrl = "https://" + configuration.getCollectorAddress() + "/mobile/v3/data";
} else {
// Otherwise, auto-detect from region
String region = configuration.getRegion();
region = (region != null) ? region.toUpperCase() : "US";
String endpoint = REGIONAL_ENDPOINTS.get(region);
this.endpointUrl = (endpoint != null) ? endpoint : REGIONAL_ENDPOINTS.get("DEFAULT");
}

if (configuration.isMemoryOptimized()) {
connectionTimeoutMs = 6000;
Expand All @@ -74,7 +79,7 @@ public OptimizedHttpClient(NRVideoConfiguration configuration, android.content.C
System.setProperty("http.keepAliveDuration", "300000"); // 5 minutes
System.setProperty("http.maxConnections", "5");

NRLog.d("Initialized with region: " + region +
NRLog.d("Initialized with region: " + configuration.getRegion() +
", endpoint URL: " + endpointUrl);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,11 @@ public Map<String, Object> getAttributes(String action, Map<String, Object> attr

if (state.isAd) {
attr.put("adTitle", getTitle());
attr.put("adBitrate", getBitrate());
attr.put("adRenditionBitrate", getRenditionBitrate());
// Only add bitrate attributes after ad has started (first frame shown)
if (state.isStarted) {
attr.put("adBitrate", getBitrate());
attr.put("adRenditionBitrate", getRenditionBitrate());
}
attr.put("adRenditionWidth", getRenditionWidth());
attr.put("adRenditionHeight", getRenditionHeight());
attr.put("adDuration", getDuration());
Expand Down Expand Up @@ -199,9 +202,11 @@ public Map<String, Object> getAttributes(String action, Map<String, Object> attr
}

attr.put("contentTitle", getTitle());
// attr.put("contentBitrate", getBitrate());
attr.put("contentBitrate", getActualBitrate());
attr.put("contentRenditionBitrate", getRenditionBitrate());
// Only add bitrate attributes after content has started (first frame shown)
if (state.isStarted) {
attr.put("contentBitrate", getActualBitrate());
attr.put("contentRenditionBitrate", getRenditionBitrate());
}
attr.put("contentRenditionWidth", getRenditionWidth());
attr.put("contentRenditionHeight", getRenditionHeight());
attr.put("contentDuration", getDuration());
Expand Down Expand Up @@ -411,7 +416,7 @@ public void sendHeartbeat() {
state.accumulatedVideoWatchTime = (Math.abs(state.accumulatedVideoWatchTime - heartbeatInterval) <= 5 ? heartbeatInterval : state.accumulatedVideoWatchTime);
Map<String, Object> eventData = new HashMap<>();
eventData.put("elapsedTime", state.accumulatedVideoWatchTime);
if (state.accumulatedVideoWatchTime != null && state.accumulatedVideoWatchTime > 0L) {
if (state.accumulatedVideoWatchTime != null) {
if (state.isAd) {
sendVideoAdEvent(AD_HEARTBEAT,eventData);
} else {
Expand Down
4 changes: 0 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,6 @@ As noted in our [security policy](../../security/policy), New Relic is committed

If you believe you have found a security vulnerability in this project or any of New Relic's products or websites, we welcome and greatly appreciate you reporting it to New Relic through [HackerOne](https://hackerone.com/newrelic).

## Pricing

Important: Ingesting video telemetry data via this video agent requires a subscription to an Advanced Compute. Contact your New Relic account representative for more details on pricing and entitlement.

## License

New Relic Video Agent is licensed under the [Apache 2.0](http://apache.org/licenses/LICENSE-2.0.txt) License.
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@

<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

</manifest>
22 changes: 14 additions & 8 deletions app/src/main/java/com/newrelic/nrvideoproject/VideoPlayerAds.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,28 @@
import com.newrelic.videoagent.core.NRVideoPlayerConfiguration;
import androidx.appcompat.app.AppCompatActivity;
import androidx.media3.common.MediaItem;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DefaultDataSourceFactory;
import androidx.media3.exoplayer.SimpleExoPlayer;
import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.ima.ImaAdsLoader;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.ui.PlayerView;
import androidx.annotation.OptIn;
import androidx.media3.common.util.UnstableApi;

import android.util.Log;
import com.newrelic.videoagent.core.NewRelicVideoAgent;
import com.newrelic.videoagent.ima.tracker.NRTrackerIMA;

/**
* @OptIn is required for Media3 IMA ads integration APIs which are marked as @UnstableApi.
* This is by design as per Google's Media3 documentation.
* @see <a href="https://developer.android.com/media/media3/exoplayer/customization#unstable-api">Media3 Unstable API Documentation</a>
*/
@OptIn(markerClass = UnstableApi.class)
public class VideoPlayerAds extends AppCompatActivity implements AdErrorEvent.AdErrorListener, AdEvent.AdEventListener {

private SimpleExoPlayer player;
private ExoPlayer player;
private Integer trackerId;
private ImaAdsLoader adsLoader;
private PlayerView playerView;
Expand Down Expand Up @@ -67,13 +74,12 @@ protected void onDestroy() {

private void playVideo(String videoUrl) {
// Set up the factory for media sources, passing the ads loader and ad view providers.
DataSource.Factory dataSourceFactory =
new DefaultDataSourceFactory(this, Util.getUserAgent(this, getString(R.string.app_name)));
DataSource.Factory dataSourceFactory = new DefaultDataSource.Factory(this);
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(dataSourceFactory);
mediaSourceFactory.setAdsLoaderProvider(unusedAdTagUri -> adsLoader);
mediaSourceFactory.setAdViewProvider(playerView);

player = new SimpleExoPlayer.Builder(this).setMediaSourceFactory(mediaSourceFactory).build();
player = new ExoPlayer.Builder(this).setMediaSourceFactory(mediaSourceFactory).build();
NRVideoPlayerConfiguration playerConfiguration = new NRVideoPlayerConfiguration("test-player-something-else", player, true, null);
trackerId = NRVideo.addPlayer(playerConfiguration);
adTracker = (NRTrackerIMA) NewRelicVideoAgent.getInstance().getAdTracker(trackerId);
Expand All @@ -93,7 +99,7 @@ private void playVideo(String videoUrl) {
Uri contentUri = Uri.parse(videoUrl);
Uri adTagUri = Uri.parse(getString(R.string.ad_tag_url));
MediaItem mediaItem = new MediaItem.Builder().setUri(contentUri).setAdTagUri(adTagUri).build();
// Prepare the content and ad to be played with the SimpleExoPlayer.
// Prepare the content and ad to be played with the ExoPlayer.
player.setMediaItem(mediaItem);
// Set PlayWhenReady. If true, content and ads will autoplay.
player.setPlayWhenReady(true);
Expand Down
Loading