Skip to content

Commit 314f365

Browse files
authored
Release v7.6.12 (#430)
1. Adding Session Replay Functionality for Jetpack Compose. 2. Fixed Session Replay Bugs 3. This is the public preview of session replay
1 parent 59c394a commit 314f365

File tree

48 files changed

+4298
-335
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+4298
-335
lines changed

agent/build.gradle

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
12
/*
23
* Copyright (c) 2022 - present. New Relic Corporation. All rights reserved.
34
* SPDX-License-Identifier: Apache-2.0
@@ -9,6 +10,7 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
910
plugins {
1011
id("com.github.johnrengelman.shadow")
1112
id("com.android.library")
13+
id("org.jetbrains.kotlin.android")
1214
}
1315

1416
apply from: "$project.rootDir/jacoco.gradle"
@@ -23,6 +25,15 @@ tasks.withType(JavaExec).configureEach {
2325
}
2426
}
2527

28+
29+
30+
31+
sourceSets {
32+
main.java.srcDirs += 'src/main/kotlin'
33+
main.resources.srcDirs += 'src/main/kotlin'
34+
35+
}
36+
2637
android {
2738
namespace "com.newrelic.agent.android"
2839
compileSdkVersion project.versions.agp.compileSdk
@@ -31,6 +42,13 @@ android {
3142
minSdkVersion project.versions.agp.minSdk
3243
targetSdkVersion project.versions.agp.targetSdk
3344
}
45+
buildFeatures { // Enables Jetpack Compose for this module
46+
compose = true
47+
}
48+
composeOptions {
49+
kotlinCompilerExtensionVersion versions.composeCompiler
50+
}
51+
3452

3553
buildTypes {
3654
release {
@@ -103,12 +121,13 @@ android {
103121
dependencies {
104122
implementation project(path: ':agent-core', configuration: 'fat')
105123
implementation( 'com.squareup.curtains:curtains:1.2.5')
124+
implementation("org.jetbrains.kotlin:kotlin-stdlib:${versions.java.kotlin}")
106125

107126
compileOnly fileTree(dir: 'libs', include: '*.jar')
108127
compileOnly newrelic.deps.ndk
109128

110129
compileOnly "androidx.navigation:navigation-compose:${project.versions.jetpack}"
111-
130+
compileOnly "androidx.compose.ui:ui:${project.versions.composeui}"
112131
testImplementation project(path: ':agent-core', configuration: 'fat')
113132
testImplementation fileTree(dir: 'libs', include: '*.jar')
114133
testImplementation newrelic.deps.ndk
@@ -128,9 +147,17 @@ def toFatJarTask(def variant) {
128147
def unshadedJarProvider = project.tasks.register("unshadedJar${variant.name.capitalize()}", Jar) {
129148
dependsOn variant.runtimeConfiguration
130149
dependsOn variant.getJavaCompileProvider()
150+
// Add dependency on Kotlin compilation if available
151+
if (project.hasProperty('kotlin')) {
152+
dependsOn "compile${variant.name.capitalize()}Kotlin"
153+
}
131154
archiveClassifier = "u"
132155
include("**/GsonInstrumentation*")
133156
from variant.getJavaCompileProvider().get().destinationDir
157+
// Include Kotlin compiled classes
158+
if (project.hasProperty('kotlin')) {
159+
from "$buildDir/tmp/kotlin-classes/${variant.name}"
160+
}
134161
from {
135162
variant.runtimeConfiguration.collect { it.isDirectory() ? it : zipTree(it) }
136163
}
@@ -139,8 +166,16 @@ def toFatJarTask(def variant) {
139166
def shadedJarProvider = project.tasks.register("shadedJar${variant.name.capitalize()}", ShadowJar) {
140167
dependsOn variant.runtimeConfiguration
141168
dependsOn variant.getJavaCompileProvider()
169+
// Add dependency on Kotlin compilation if available
170+
if (project.hasProperty('kotlin')) {
171+
dependsOn "compile${variant.name.capitalize()}Kotlin"
172+
}
142173
archiveClassifier = "s"
143174
from variant.getJavaCompileProvider().get().destinationDir
175+
// Include Kotlin compiled classes
176+
if (project.hasProperty('kotlin')) {
177+
from "$buildDir/tmp/kotlin-classes/${variant.name}"
178+
}
144179
from {
145180
variant.runtimeConfiguration.collect { it.isDirectory() ? it : zipTree(it) }
146181
}

agent/src/main/java/com/newrelic/agent/android/AndroidAgentImpl.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,12 @@ public void setLocation(String countryCode, String adminRegion) {
759759
* a Location to a country and region code.
760760
*
761761
* @param location An android.location.Location
762+
* @deprecated This method is deprecated due to reliance on Geocoder which may fail or be unavailable.
763+
* Use {@link #setLocation(String, String)} instead to directly provide country code and
764+
* administrative region. This allows for more reliable location tracking without
765+
* dependency on the Geocoder service.
762766
*/
767+
@Deprecated
763768
public void setLocation(Location location) {
764769
if (location == null) {
765770
throw new IllegalArgumentException("Location must not be null.");
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.newrelic.agent.android.sessionReplay;
2+
3+
import android.annotation.SuppressLint;
4+
import android.graphics.Bitmap;
5+
import android.os.Build;
6+
import android.util.Base64;
7+
import android.util.Log;
8+
9+
import androidx.annotation.WorkerThread;
10+
11+
import java.io.ByteArrayOutputStream;
12+
13+
/**
14+
* Utility class for image compression and conversion operations
15+
* Used by both SessionReplayImageViewThingy and ComposeImageThingy
16+
*/
17+
public class ImageCompressionUtils {
18+
private static final String LOG_TAG = "ImageCompressionUtils";
19+
private static final int DEFAULT_WEBP_QUALITY = 10;
20+
21+
/**
22+
* Converts a bitmap to a Base64 encoded string with WEBP compression
23+
* @param bitmap The bitmap to convert
24+
* @return Base64 encoded image data or null if conversion fails
25+
*/
26+
@WorkerThread
27+
public static String bitmapToBase64(Bitmap bitmap) {
28+
return bitmapToBase64(bitmap, DEFAULT_WEBP_QUALITY);
29+
}
30+
31+
/**
32+
* Converts a bitmap to a Base64 encoded string with WEBP compression
33+
* @param bitmap The bitmap to convert
34+
* @param quality The compression quality (0-100)
35+
* @return Base64 encoded image data or null if conversion fails
36+
*/
37+
@WorkerThread
38+
public static String bitmapToBase64(Bitmap bitmap, int quality) {
39+
40+
Bitmap bitmapCopy;
41+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.getConfig() == Bitmap.Config.HARDWARE) {
42+
bitmapCopy = bitmap;
43+
} else {
44+
bitmapCopy = bitmap.copy(Bitmap.Config.ARGB_8888, false);
45+
}
46+
47+
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
48+
try {
49+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
50+
bitmapCopy.compress(Bitmap.CompressFormat.WEBP_LOSSY, quality, byteArrayOutputStream);
51+
} else {
52+
@SuppressWarnings("deprecation")
53+
Bitmap.CompressFormat format = Bitmap.CompressFormat.WEBP;
54+
bitmapCopy.compress(format, quality, byteArrayOutputStream);
55+
}
56+
57+
byte[] byteArray = byteArrayOutputStream.toByteArray();
58+
return Base64.encodeToString(byteArray, Base64.NO_WRAP);
59+
} catch (Exception e) {
60+
Log.e(LOG_TAG, "Error converting bitmap to Base64", e);
61+
return null;
62+
}
63+
} catch (Exception ignored) {
64+
// Ignore cleanup errors
65+
} finally {
66+
if (bitmapCopy != null && bitmapCopy != bitmap) {
67+
bitmapCopy.recycle();
68+
}
69+
}
70+
return null;
71+
}
72+
73+
/**
74+
* Converts Base64 image data to a data URL for use in CSS or HTML
75+
* @param base64Data The Base64 encoded image data
76+
* @return A data URL string or null if input is null
77+
*/
78+
public static String toImageDataUrl(String base64Data) {
79+
if (base64Data != null) {
80+
return "data:image/webp;base64," + base64Data;
81+
}
82+
return null;
83+
}
84+
}

agent/src/main/java/com/newrelic/agent/android/sessionReplay/IncrementalDiffGenerator.java

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public class IncrementalDiffGenerator {
1818
static class Symbol {
1919
boolean inNew;
2020
Integer indexInOld;
21+
int occurrencesInOld = 0;
22+
int occurrencesInNew = 0;
2123

2224
Symbol(boolean inNew, Integer indexInOld) {
2325
this.inNew = inNew;
@@ -172,8 +174,12 @@ public static List<Operation> generateDiff(List<SessionReplayViewThingyInterface
172174

173175
// Pass One: Each element of the New array is gone through, and an entry in the table made for each
174176
for (SessionReplayViewThingyInterface item : newList) {
175-
Symbol entry = new Symbol(true);
176-
table.put(item.getViewId(), entry);
177+
Symbol entry = table.get(item.getViewId());
178+
if (entry == null) {
179+
entry = new Symbol(true);
180+
table.put(item.getViewId(), entry);
181+
}
182+
entry.occurrencesInNew++;
177183
newArrayEntries.add(Entry.symbol(entry));
178184
}
179185

@@ -186,8 +192,12 @@ public static List<Operation> generateDiff(List<SessionReplayViewThingyInterface
186192
table.put(item.getViewId(), entry);
187193
} else {
188194
entry = table.get(item.getViewId());
189-
entry.indexInOld = index;
195+
// Only set indexInOld if this is the first occurrence
196+
if (entry.occurrencesInOld == 0) {
197+
entry.indexInOld = index;
198+
}
190199
}
200+
entry.occurrencesInOld++;
191201

192202
oldArrayEntries.add(Entry.symbol(entry));
193203
}
@@ -197,7 +207,9 @@ public static List<Operation> generateDiff(List<SessionReplayViewThingyInterface
197207
// the two
198208
for (int index = 0; index < newArrayEntries.size(); index++) {
199209
Entry item = newArrayEntries.get(index);
200-
if (item.isSymbol && item.symbol.inNew && item.symbol.indexInOld != null) {
210+
// Only match if element appears exactly once in both old AND new
211+
if (item.isSymbol && item.symbol.inNew && item.symbol.indexInOld != null
212+
&& item.symbol.occurrencesInOld == 1 && item.symbol.occurrencesInNew == 1) {
201213
newArrayEntries.set(index, Entry.index(item.symbol.indexInOld));
202214
oldArrayEntries.set(item.symbol.indexInOld, Entry.index(index));
203215
}
@@ -251,7 +263,7 @@ public static List<Operation> generateDiff(List<SessionReplayViewThingyInterface
251263
deleteOffsets[index] = runningOffset;
252264
Entry entry = oldArrayEntries.get(index);
253265
if (entry.isSymbol) {
254-
changes.add(Operation.remove(new Operation.RemoveChange(old.get(index).getViewDetails().parentId, old.get(index).getViewId())));
266+
changes.add(Operation.remove(new Operation.RemoveChange(old.get(index).getParentViewId(), old.get(index).getViewId())));
255267
runningOffset++;
256268
}
257269
}
@@ -262,7 +274,7 @@ public static List<Operation> generateDiff(List<SessionReplayViewThingyInterface
262274
for (int index = 0; index < newArrayEntries.size(); index++) {
263275
Entry entry = newArrayEntries.get(index);
264276
if (entry.isSymbol) {
265-
changes.add(Operation.add(new Operation.AddChange(newList.get(index).getViewDetails().parentId, newList.get(index).getViewId(), newList.get(index))));
277+
changes.add(Operation.add(new Operation.AddChange(newList.get(index).getParentViewId(), newList.get(index).getViewId(), newList.get(index))));
266278
runningOffset++;
267279
} else {
268280
int indexInOld = entry.index;
@@ -273,10 +285,11 @@ public static List<Operation> generateDiff(List<SessionReplayViewThingyInterface
273285
if ((indexInOld - deleteOffset + runningOffset) != index) {
274286
// If this doesn't get us back to where we currently are, then
275287
// the thing was moved
276-
changes.add(Operation.remove(new Operation.RemoveChange(newElement.getViewDetails().parentId, newElement.getViewId())));
277-
changes.add(Operation.add(new Operation.AddChange(newElement.getViewDetails().parentId, newElement.getViewId(), newElement)));
288+
changes.add(Operation.remove(new Operation.RemoveChange(newElement.getParentViewId(), newElement.getViewId())));
289+
changes.add(Operation.add(new Operation.AddChange(newElement.getParentViewId(), newElement.getViewId(), newElement)));
278290
} else if (newElement.getClass() == oldElement.getClass()) {
279-
if (newElement.hashCode() != oldElement.hashCode()) {
291+
// Use the hasChanged method from Diffable interface instead of hashCode
292+
if (newElement.hasChanged(oldElement)) {
280293
changes.add(Operation.update(new Operation.UpdateChange(oldElement, newElement)));
281294
}
282295
}

agent/src/main/java/com/newrelic/agent/android/sessionReplay/NewRelicIdGenerator.java

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,36 @@
44

55
import android.util.Log;
66

7+
import java.util.concurrent.atomic.AtomicInteger;
8+
79
public class NewRelicIdGenerator {
810

911
private static final String TAG = "NewRelicIdGenerator";
1012

11-
// This is a simple counter to generate unique IDs.
12-
// In a real-world scenario, you might want to use a more robust method to ensure uniqueness,
13-
// especially in a multi-threaded environment.
14-
// For example, you could use AtomicInteger or UUIDs.
15-
static int counter = 0;
1613

17-
static int generateId() {
18-
Log.d(TAG, "generateId: " + counter);
19-
return counter++;
14+
/**
15+
* ID offset to avoid collision with Compose SemanticsNode IDs.
16+
* SemanticsNode IDs start at 0 and increment, so we offset our generated IDs
17+
* to a high value (1,000,000) to ensure no overlap between traditional View IDs
18+
* and Compose node IDs in the same screen.
19+
*/
20+
private static final int ID_OFFSET = 1_000_000;
21+
22+
/**
23+
* Counter for generating unique IDs for traditional Android Views.
24+
* Starts at ID_OFFSET to avoid collision with Compose SemanticsNode IDs (which start at 0).
25+
*
26+
* Note: This is a simple counter. In a multi-threaded environment, consider using AtomicInteger
27+
* for thread-safety, though current usage via setTag/getTag on views is typically main-thread only.
28+
*/
29+
private static final AtomicInteger counter = new AtomicInteger(ID_OFFSET);
30+
31+
/**
32+
* Generates a unique ID for traditional Android Views that won't collide with Compose SemanticsNode IDs.
33+
*
34+
* @return A unique ID starting from 1,000,000 to avoid collision with Compose IDs (0-999,999 range)
35+
*/
36+
public static int generateId() {
37+
return counter.getAndIncrement();
2038
}
2139
}

0 commit comments

Comments
 (0)