Mark duplicate bitmaps in Heap dump Task

- Add filter to Heap dump task in profilers to mark duplicate bitmaps in
memory

- Uses a hashmap of BitmapInfos (height, weight, buffer) to find the
duplicate bitmap instances

Bug: 424384806
Test: Added BitmapDuplicationAnalyzerTest.kt and
  BitmapDuplicationInstanceFilterTest.kt
Change-Id: I181f83a6f784cee2126d9ca755a0cc415d35880b
diff --git a/profilers-ui/src/com/android/tools/profilers/memory/CapturePanel.kt b/profilers-ui/src/com/android/tools/profilers/memory/CapturePanel.kt
index 94f12b0..6d04d97 100644
--- a/profilers-ui/src/com/android/tools/profilers/memory/CapturePanel.kt
+++ b/profilers-ui/src/com/android/tools/profilers/memory/CapturePanel.kt
@@ -53,7 +53,7 @@
                    selectionRange: Range,
                    ideComponents: IdeProfilerComponents,
                    timeline: StreamingTimeline,
-                   isFullScreenHeapDumpUi: Boolean): AspectObserver() {
+                   isFullScreenHeapDumpUi: Boolean) : AspectObserver() {
   val heapView = MemoryHeapView(selection)
   val captureView = MemoryCaptureView(selection, ideComponents) // TODO: remove after full migration. Only needed for legacy tests
   val classGrouping = MemoryClassGrouping(selection)
@@ -195,8 +195,10 @@
   private fun buildSummaryPanel() = JPanel(FlowLayout(FlowLayout.LEFT)).apply {
     fun mkLabel(desc: String, action: Runnable? = null) =
       StatLabel(0L, desc, numFont = ProfilerFonts.H2_FONT, descFont = AdtUiUtils.DEFAULT_FONT.biggerOn(1f), action = action)
+
     val totalClassLabel = mkLabel("Classes")
     val totalLeakLabel = mkLabel("Leaks", action = Runnable(::showLeaks))
+    val totalBitmapDuplicatesLabel = mkLabel("Duplicates", action = Runnable(::showBitmapDuplicates))
     val totalCountLabel = mkLabel("Count")
     val totalNativeSizeLabel = mkLabel("Native Size")
     val totalShallowSizeLabel = mkLabel("Shallow Size")
@@ -232,6 +234,15 @@
               icon = if (leakCount > 0) StudioIcons.Common.WARNING else null
             }
           }
+          when (val filter = capture.bitmapDuplicationFilter) {
+            null -> totalBitmapDuplicatesLabel.isVisible = false
+            else -> totalBitmapDuplicatesLabel.apply {
+              val duplicateCount = heap.getInstanceFilterMatchCount(filter).toLong()
+              isVisible = true
+              numValue = duplicateCount
+              icon = if (duplicateCount > 0) StudioIcons.Common.WARNING else null
+            }
+          }
         }
       }
     }
@@ -242,6 +253,7 @@
 
     add(totalClassLabel)
     add(totalLeakLabel)
+    add(totalBitmapDuplicatesLabel)
     add(FlatSeparator(6, 36))
     add(totalCountLabel)
     add(totalNativeSizeLabel)
@@ -255,4 +267,10 @@
       instanceFilterMenu.component.selectedItem = it
     }
   }
+
+  private fun showBitmapDuplicates() {
+    (selection.selectedCapture as? HeapDumpCaptureObject)?.getBitmapDuplicationFilter()?.let {
+      instanceFilterMenu.component.selectedItem = it
+    }
+  }
 }
\ No newline at end of file
diff --git a/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassSetView.java b/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassSetView.java
index 4675d0b..11f73e4 100644
--- a/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassSetView.java
+++ b/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassSetView.java
@@ -494,11 +494,11 @@
 
   private ColoredTreeCellRenderer makeNameColumnRenderer() {
     return new ValueColumnRenderer() {
-      private boolean myIsLeaked = false;
+      private boolean myIsWarning = false;
 
       @Override
       protected void paintComponent(Graphics g) {
-        if (myIsLeaked) {
+        if (myIsWarning) {
           int width = getWidth();
           int height = getHeight();
           Icon i = mySelected && isFocused() && !NewUI.isEnabled()
@@ -522,15 +522,47 @@
                                         int row,
                                         boolean hasFocus) {
         super.customizeCellRenderer(tree, value, selected, expanded, leaf, row, hasFocus);
-        if (value instanceof MemoryObjectTreeNode &&
-            ((MemoryObjectTreeNode)value).getAdapter() instanceof InstanceObject) {
-          CaptureObjectInstanceFilter leakFilter = myCaptureObject.getActivityFragmentLeakFilter();
-          myIsLeaked = leakFilter != null &&
-                       leakFilter.getInstanceTest().invoke((InstanceObject)((MemoryObjectTreeNode)value).getAdapter());
-          String msg = "To investigate leak, select instance and see \"References\"";
-          setToolTipText(myIsLeaked ? msg : null);
+        if (value instanceof MemoryObjectTreeNode<?> node && node.getAdapter() instanceof InstanceObject instance) {
+          boolean isLeaked = isInstanceLeaked(instance);
+          boolean isDuplicateBitmap = isInstanceDuplicateBitmap(instance);
+
+          myIsWarning = isLeaked || isDuplicateBitmap;
+
+          String tooltipMessage = getProblemTooltipMessage(isLeaked, isDuplicateBitmap);
+          setToolTipText(tooltipMessage);
         }
       }
+
+      /**
+       * Checks if the given instance object is considered a leak.
+       */
+      private boolean isInstanceLeaked(@NotNull InstanceObject instance) {
+        CaptureObjectInstanceFilter leakFilter = myCaptureObject != null ? myCaptureObject.getActivityFragmentLeakFilter() : null;
+        return leakFilter != null && leakFilter.getInstanceTest().invoke(instance);
+      }
+
+      /**
+       * Checks if the given instance object is considered a duplicate bitmap.
+       */
+      private boolean isInstanceDuplicateBitmap(@NotNull InstanceObject instance) {
+        CaptureObjectInstanceFilter duplicateBitmapFilter = myCaptureObject != null ? myCaptureObject.getBitmapDuplicationFilter() : null;
+        return duplicateBitmapFilter != null && duplicateBitmapFilter.getInstanceTest().invoke(instance);
+      }
+
+      /**
+       * Returns the appropriate tooltip message based on the problem states,
+       * prioritizing duplicate bitmap message over leak message.
+       */
+      @Nullable
+      private String getProblemTooltipMessage(boolean isLeaked, boolean isDuplicateBitmap) {
+        if (isDuplicateBitmap) {
+          return "To investigate duplicate bitmap, select instance and see \"References\"";
+        }
+        else if (isLeaked) {
+          return "To investigate leak, select instance and see \"References\"";
+        }
+        return null;
+      }
     };
   }
 }
diff --git a/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassifierView.java b/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassifierView.java
index 7bdd375..d936834 100644
--- a/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassifierView.java
+++ b/profilers-ui/src/com/android/tools/profilers/memory/MemoryClassifierView.java
@@ -67,10 +67,13 @@
 import java.awt.RenderingHints;
 import java.awt.event.FocusAdapter;
 import java.awt.event.FocusEvent;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
@@ -666,8 +669,8 @@
     // When `targetSet` is empty, if `rootNode` isn't empty, many of its leaves (if any) trivially count as smallest super-set nodes.
     // Because the result isn't interesting, we arbitrarily return `rootNode` itself for this special case to save some work.
     return targetSet.isEmpty() ? rootNode
-         : rootNode.getAdapter().isSupersetOf(target) ? findSmallestSuperSetNode(rootNode, target)
-         : null;
+                               : rootNode.getAdapter().isSupersetOf(target) ? findSmallestSuperSetNode(rootNode, target)
+                                                                            : null;
   }
 
   @NotNull
@@ -754,15 +757,30 @@
   @VisibleForTesting
   ColoredTreeCellRenderer getNameColumnRenderer() {
     return new ColoredTreeCellRenderer() {
-      private long myLeakCount = 0;
+      // A helper class to define a "problem" to check for.
+      private static class ProblemDescriptor {
+        final CaptureObjectInstanceFilter filter;
+        final String singularName;
+        final String pluralName;
+
+        ProblemDescriptor(CaptureObjectInstanceFilter filter, String singularName, String pluralName) {
+          this.filter = filter;
+          this.singularName = singularName;
+          this.pluralName = pluralName;
+        }
+      }
+
+      // Use a LinkedHashMap to store counts for different problems, preserving insertion order.
+      private final Map<ProblemDescriptor, Long> myProblemCounts = new LinkedHashMap<>();
+      private long myTotalProblemCount = 0;
 
       @Override
       protected void paintComponent(Graphics g) {
-        if (myLeakCount > 0) {
+        if (myTotalProblemCount > 0) {
           int width = getWidth();
           int height = getHeight();
 
-          String text = String.valueOf(myLeakCount);
+          String text = String.valueOf(myTotalProblemCount);
           ((Graphics2D)g).setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
           int textWidth = g.getFontMetrics().stringWidth(text);
 
@@ -815,7 +833,9 @@
         }
         else if (node.getAdapter() instanceof PackageSet) {
           ClassifierSet set = (ClassifierSet)node.getAdapter();
-          setIconColorized(set.hasStackInfo() ? StudioIcons.Profiler.Overlays.PACKAGE_STACK : IconManager.getInstance().getPlatformIcon(PlatformIcons.Package));
+          setIconColorized(set.hasStackInfo()
+                           ? StudioIcons.Profiler.Overlays.PACKAGE_STACK
+                           : IconManager.getInstance().getPlatformIcon(PlatformIcons.Package));
           String name = set.getName();
           append(name, SimpleTextAttributes.REGULAR_ATTRIBUTES, name);
         }
@@ -841,7 +861,9 @@
         }
         else if (node.getAdapter() instanceof HeapSet) {
           ClassifierSet set = (ClassifierSet)node.getAdapter();
-          setIconColorized(set.hasStackInfo() ? StudioIcons.Profiler.Overlays.PACKAGE_STACK : IconManager.getInstance().getPlatformIcon(PlatformIcons.Package));
+          setIconColorized(set.hasStackInfo()
+                           ? StudioIcons.Profiler.Overlays.PACKAGE_STACK
+                           : IconManager.getInstance().getPlatformIcon(PlatformIcons.Package));
           String name = set.getName() + " heap";
           append(name, SimpleTextAttributes.REGULAR_ATTRIBUTES, name);
         }
@@ -858,17 +880,63 @@
           append(name, SimpleTextAttributes.REGULAR_ATTRIBUTES, name);
         }
 
-        if (node.getAdapter() instanceof ClassifierSet) {
-          CaptureObjectInstanceFilter leakFilter = myCaptureObject != null ? myCaptureObject.getActivityFragmentLeakFilter() : null;
-          myLeakCount = leakFilter != null ?
-                        ((ClassifierSet)node.getAdapter()).getInstanceFilterMatchCount(leakFilter) :
-                        0;
-          setToolTipText(myLeakCount > 1 ? "There are " + myLeakCount + " leaks" :
-                         myLeakCount > 0 ? "There is 1 leak" :
-                         null);
+        if (node.getAdapter() instanceof ClassifierSet classifierSet && myCaptureObject != null) {
+          myProblemCounts.clear();
+          // Build a list of problems to check for
+          List<ProblemDescriptor> problemsToCheck = new ArrayList<>();
+          CaptureObjectInstanceFilter leakFilter = myCaptureObject.getActivityFragmentLeakFilter();
+          if (leakFilter != null) {
+            problemsToCheck.add(new ProblemDescriptor(leakFilter, "leak", "leaks"));
+          }
+
+          CaptureObjectInstanceFilter bitmapFilter = myCaptureObject.getBitmapDuplicationFilter();
+          if (bitmapFilter != null) {
+            problemsToCheck.add(new ProblemDescriptor(bitmapFilter, "duplicate bitmap", "duplicate bitmaps"));
+          }
+
+          // Check for each problem and update counts
+          for (ProblemDescriptor problem : problemsToCheck) {
+            long count = classifierSet.getInstanceFilterMatchCount(problem.filter);
+            if (count > 0) {
+              myProblemCounts.put(problem, count);
+            }
+          }
+
+          myTotalProblemCount = myProblemCounts.values().stream().mapToLong(Long::longValue).sum();
+          setToolTipText(generateProblemTooltip());
         }
         setTextAlign(SwingConstants.LEFT);
       }
+
+      private String generateProblemTooltip() {
+        if (myProblemCounts.isEmpty()) {
+          return null;
+        }
+
+        List<String> problemDescriptions = myProblemCounts.entrySet().stream()
+          .map(entry -> {
+            long count = entry.getValue();
+            ProblemDescriptor problem = entry.getKey();
+            String name = (count == 1) ? problem.singularName : problem.pluralName;
+            return String.format(Locale.US, "%,d %s", count, name);
+          })
+          .collect(Collectors.toList());
+
+        String summary;
+        int size = problemDescriptions.size();
+        if (size == 1) {
+          summary = problemDescriptions.get(0);
+        }
+        else if (size == 2) {
+          summary = problemDescriptions.get(0) + " and " + problemDescriptions.get(1);
+        }
+        else {
+          String lastProblem = "and " + problemDescriptions.get(size - 1);
+          summary = String.join(", ", problemDescriptions.subList(0, size - 1)) + ", " + lastProblem;
+        }
+
+        return (myTotalProblemCount > 1 ? "There are " : "There is ") + summary;
+      }
     };
   }
 
diff --git a/profilers/src/com/android/tools/profilers/memory/BitmapDuplicationAnalyzer.kt b/profilers/src/com/android/tools/profilers/memory/BitmapDuplicationAnalyzer.kt
new file mode 100644
index 0000000..3838ba0
--- /dev/null
+++ b/profilers/src/com/android/tools/profilers/memory/BitmapDuplicationAnalyzer.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.profilers.memory
+
+import com.android.tools.profilers.memory.adapters.InstanceObject
+import com.android.tools.profilers.memory.adapters.ValueObject
+import com.intellij.openapi.diagnostic.Logger
+import java.util.Locale
+
+/**
+ * Analyzes a heap dump to find duplicate bitmap instances based on their pixel data.
+ *
+ * This analyzer works by finding the shared android.graphics.Bitmap$DumpData`. It
+ * builds a map of native pointers to pixel buffers and then hashes the dimensions
+ * and buffer content of each bitmap to identify duplicates.
+ */
+class BitmapDuplicationAnalyzer {
+
+  private val duplicateBitmapInstances = mutableSetOf<InstanceObject>()
+
+  companion object {
+    private val LOG = Logger.getInstance(BitmapDuplicationAnalyzer::class.java)
+    const val BITMAP_CLASS_NAME = "android.graphics.Bitmap"
+    const val BITMAP_DUMP_DATA_CLASS_NAME = "android.graphics.Bitmap\$DumpData"
+  }
+
+  /**
+   * A data class to hold identifying information for a bitmap to check for equality.
+   */
+  private data class BitmapInfo(
+    val height: Int,
+    val width: Int,
+    val bufferHash: Int
+  ) : Comparable<BitmapInfo> {
+    /**
+     * Orders bitmaps by their size (dimension HxW) and then buffer hash,
+     * in descending order so larger bitmaps are considered more significant.
+     */
+    override fun compareTo(other: BitmapInfo): Int {
+      val areaCompare = (other.width * other.height).compareTo(this.width * this.height)
+      if (areaCompare != 0) return areaCompare
+
+      return other.bufferHash.compareTo(this.bufferHash)
+    }
+  }
+
+  /**
+   * Analyzes a collection of instances to find duplicate Bitmaps based on content.
+   * This should be called once after all instances are loaded.
+   */
+  fun analyze(instances: Iterable<InstanceObject>) {
+    duplicateBitmapInstances.clear()
+
+    // 1. Find the shared native pointer to buffer map from the heap dump.
+    val ptrToBufferMap = buildPtrToBufferMap(instances)
+    if (ptrToBufferMap.isEmpty()) {
+      LOG.debug("Could not build pointer-to-buffer map. Skipping duplicate bitmap analysis.")
+      return
+    }
+
+    // 2. Group all bitmap instances by their BitmapInfo (dimensions + content hash).
+    val bitmapsByInfo = mutableMapOf<BitmapInfo, MutableList<InstanceObject>>()
+    for (instance in instances) {
+      if (instance.classEntry.className == BITMAP_CLASS_NAME && instance.depth != Integer.MAX_VALUE) {
+        getBitmapInfo(instance, ptrToBufferMap)?.let { info ->
+          bitmapsByInfo.computeIfAbsent(info) { mutableListOf() }.add(instance)
+        }
+      }
+    }
+
+    // 3. Any group with more than one instance contains duplicates.
+    bitmapsByInfo.values.forEach { instanceList ->
+      if (instanceList.size > 1) {
+        duplicateBitmapInstances.addAll(instanceList)
+      }
+    }
+  }
+
+  /**
+   * Returns the set of InstanceObjects identified as duplicates.
+   */
+  fun getDuplicateInstances(): Set<InstanceObject> = duplicateBitmapInstances
+
+  /**
+   * Creates a [BitmapInfo] object for a given bitmap instance if its dimensions and pixel buffer can be found.
+   */
+  private fun getBitmapInfo(instance: InstanceObject, ptrToBufferMap: Map<Long, ByteArray>): BitmapInfo? {
+    var width: Int? = null
+    var height: Int? = null
+    var nativePtr: Long? = null
+
+    for (field in instance.fields) {
+      when (field.fieldName) {
+        "mWidth" -> width = field.value as? Int
+        "mHeight" -> height = field.value as? Int
+        "mNativePtr" -> nativePtr = field.value as? Long
+      }
+    }
+
+    if (width == null || height == null || nativePtr == null) return null
+    val buffer = ptrToBufferMap[nativePtr] ?: return null
+
+    return BitmapInfo(height, width, buffer.contentHashCode())
+  }
+
+  /**
+   * Builds a map of native pointers to their corresponding byte buffers.
+   */
+  private fun buildPtrToBufferMap(instances: Iterable<InstanceObject>): Map<Long, ByteArray> {
+    val ptrToBufferMap = mutableMapOf<Long, ByteArray>()
+
+    val dumpDataInstance = instances.firstOrNull { it.classEntry.className == BITMAP_DUMP_DATA_CLASS_NAME && it.depth != Integer.MAX_VALUE }
+                           ?: return emptyMap()
+    val buffersInstance = getNestedInstanceObject(dumpDataInstance, "buffers") ?: return emptyMap()
+    val nativesInstance = getNestedInstanceObject(dumpDataInstance, "natives") ?: return emptyMap()
+
+    val buffersFields = buffersInstance.fields
+    val nativesFields = nativesInstance.fields
+    if (buffersFields.size != nativesFields.size) {
+      LOG.warn(
+        String.format(
+          Locale.US, "Mismatch in size between 'buffers' (%d) and 'natives' (%d) fields. Cannot process.",
+          buffersFields.size, nativesFields.size
+        )
+      )
+      return emptyMap()
+    }
+
+    for (i in nativesFields.indices) {
+      val nativePtr = nativesFields.getOrNull(i)?.value as? Long ?: continue
+      val bufferInstance = buffersFields.getOrNull(i)?.getAsInstance() ?: continue
+      val byteArray = getByteArrayFromInstanceObject(bufferInstance) ?: continue
+      ptrToBufferMap[nativePtr] = byteArray
+    }
+    return ptrToBufferMap
+  }
+
+  private fun getByteArrayFromInstanceObject(instance: InstanceObject): ByteArray? {
+    val arrayObject = instance.arrayObject ?: run {
+      LOG.warn("Buffer instance does not contain an ArrayObject.")
+      return null
+    }
+
+    if (arrayObject.arrayElementType != ValueObject.ValueType.BYTE) {
+      LOG.warn("ArrayObject element type is not BYTE. Found: ${arrayObject.arrayElementType}")
+      return null
+    }
+
+    return arrayObject.asByteArray
+  }
+
+  private fun getNestedInstanceObject(parentInstance: InstanceObject, fieldName: String): InstanceObject? {
+    return parentInstance.fields.firstOrNull { field ->
+      fieldName == field.fieldName && field.value is InstanceObject
+    }?.getAsInstance()
+  }
+}
\ No newline at end of file
diff --git a/profilers/src/com/android/tools/profilers/memory/adapters/CaptureObject.java b/profilers/src/com/android/tools/profilers/memory/adapters/CaptureObject.java
index 45bfb32..b5e9645 100644
--- a/profilers/src/com/android/tools/profilers/memory/adapters/CaptureObject.java
+++ b/profilers/src/com/android/tools/profilers/memory/adapters/CaptureObject.java
@@ -28,6 +28,7 @@
 import com.android.tools.profilers.memory.adapters.classifiers.ClassifierSet;
 import com.android.tools.profilers.memory.adapters.classifiers.HeapSet;
 import com.android.tools.profilers.memory.adapters.instancefilters.ActivityFragmentLeakInstanceFilter;
+import com.android.tools.profilers.memory.adapters.instancefilters.BitmapDuplicationInstanceFilter;
 import com.android.tools.profilers.memory.adapters.instancefilters.CaptureObjectInstanceFilter;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListenableFutureTask;
@@ -208,6 +209,15 @@
     return null;
   }
 
+  /**
+   * @return the filter of duplicated bitmaps if that is supported, or null otherwise
+   * An implementation of this method should only return a filter in `getSupportedInstanceFilters`
+   */
+  @Nullable
+  default BitmapDuplicationInstanceFilter getBitmapDuplicationFilter() {
+    return null;
+  }
+
   @NotNull
   default Set<CaptureObjectInstanceFilter> getSelectedInstanceFilters() {
     return Collections.EMPTY_SET;
diff --git a/profilers/src/com/android/tools/profilers/memory/adapters/HeapDumpCaptureObject.kt b/profilers/src/com/android/tools/profilers/memory/adapters/HeapDumpCaptureObject.kt
index dd2fdcc..6c4a305 100644
--- a/profilers/src/com/android/tools/profilers/memory/adapters/HeapDumpCaptureObject.kt
+++ b/profilers/src/com/android/tools/profilers/memory/adapters/HeapDumpCaptureObject.kt
@@ -39,8 +39,10 @@
 import com.android.tools.profilers.memory.adapters.CaptureObject.ClassifierAttribute.SHALLOW_SIZE
 import com.android.tools.profilers.memory.adapters.CaptureObject.InstanceAttribute
 import com.android.tools.profilers.memory.adapters.classifiers.AllHeapSet
+import com.android.tools.profilers.memory.BitmapDuplicationAnalyzer
 import com.android.tools.profilers.memory.adapters.classifiers.HeapSet
 import com.android.tools.profilers.memory.adapters.instancefilters.ActivityFragmentLeakInstanceFilter
+import com.android.tools.profilers.memory.adapters.instancefilters.BitmapDuplicationInstanceFilter
 import com.android.tools.profilers.memory.adapters.instancefilters.CaptureObjectInstanceFilter
 import com.android.tools.profilers.memory.adapters.instancefilters.ProjectClassesInstanceFilter
 import com.android.tools.proguard.ProguardMap
@@ -65,6 +67,7 @@
                                  private val featureTracker: FeatureTracker,
                                  private val ideProfilerServices: IdeProfilerServices) : CaptureObject {
   private val _heapSets: MutableMap<Int, HeapSet> = HashMap()
+
   // A load factor of 0.5 is used for performance reasons due to the interaction of two hash tables. See b/372321482 for details.
   private val instanceIndex = Long2ObjectOpenHashMap<InstanceObject>(16, Hash.FAST_LOAD_FACTOR)
 
@@ -79,8 +82,10 @@
   var hasNativeAllocations = false
     private set
   private val activityFragmentLeakFilter = ActivityFragmentLeakInstanceFilter(classDb)
-  private val supportedInstanceFilters: Set<CaptureObjectInstanceFilter> = setOf(activityFragmentLeakFilter,
-                                                                                 ProjectClassesInstanceFilter(ideProfilerServices))
+  private val bitmapDuplicationAnalyzer = BitmapDuplicationAnalyzer()
+  private lateinit var bitmapDuplicationFilter: BitmapDuplicationInstanceFilter
+  private lateinit var supportedInstanceFilters: Set<CaptureObjectInstanceFilter> // To be initialized after bitmap filter
+
   private val currentInstanceFilters = mutableSetOf<CaptureObjectInstanceFilter>()
   private val executorService = MoreExecutors.listeningDecorator(
     Executors.newSingleThreadExecutor(ThreadFactoryBuilder().setNameFormat("memory-heapdump-instancefilters").build())
@@ -100,6 +105,7 @@
   override fun getHeapSet(heapId: Int) = _heapSets.getOrDefault(heapId, null)
   override fun getInstances(): Stream<InstanceObject> =
     if (hasLoaded) heapSets.find { it is AllHeapSet }!!.instancesStream else Stream.empty()
+
   override fun getStartTimeNs() = heapDumpInfo.startTime
   override fun getEndTimeNs() = heapDumpInfo.endTime
   override fun getClassDatabase() = classDb
@@ -142,15 +148,20 @@
     heapSetMappings.forEach { (heap, heapSet) ->
       heap.classes.forEach { addInstanceToRightHeap(heapSet, it.id, createClassObjectInstance(javaLangClassObject, it)) }
       heap.forEachInstance { instance ->
-          assert(ClassDb.JAVA_LANG_CLASS != instance.classObj!!.className)
-          val classEntry = instance.classObj!!.makeEntry()
-          addInstanceToRightHeap(heapSet, instance.id, HeapDumpInstanceObject(this@HeapDumpCaptureObject, instance, classEntry, null))
-          true
+        assert(ClassDb.JAVA_LANG_CLASS != instance.classObj!!.className)
+        val classEntry = instance.classObj!!.makeEntry()
+        addInstanceToRightHeap(heapSet, instance.id, HeapDumpInstanceObject(this@HeapDumpCaptureObject, instance, classEntry, null))
+        true
       }
       if ("default" != heap.name || snapshot.heaps.size == 1 || heap.instancesCount > 0) {
         _heapSets.put(heap.id, heapSet)
       }
     }
+    // Run analysis after all instances are loaded into instanceIndex
+    bitmapDuplicationAnalyzer.analyze(allInstances)
+    bitmapDuplicationFilter = BitmapDuplicationInstanceFilter(bitmapDuplicationAnalyzer.getDuplicateInstances())
+    // Initialize supportedInstanceFilters now that bitmapDuplicationFilter is ready
+    supportedInstanceFilters = setOf(activityFragmentLeakFilter, ProjectClassesInstanceFilter(ideProfilerServices), bitmapDuplicationFilter)
   }
 
   private fun addInstance(heapSet: HeapSet, id: Long, instObj: InstanceObject) {
@@ -171,9 +182,10 @@
 
   override fun getInstanceAttributes() =
     if (hasNativeAllocations) listOf(
-        InstanceAttribute.LABEL, InstanceAttribute.DEPTH, InstanceAttribute.NATIVE_SIZE, InstanceAttribute.SHALLOW_SIZE,
-        InstanceAttribute.RETAINED_SIZE)
+      InstanceAttribute.LABEL, InstanceAttribute.DEPTH, InstanceAttribute.NATIVE_SIZE, InstanceAttribute.SHALLOW_SIZE,
+      InstanceAttribute.RETAINED_SIZE)
     else listOf(InstanceAttribute.LABEL, InstanceAttribute.DEPTH, InstanceAttribute.SHALLOW_SIZE, InstanceAttribute.RETAINED_SIZE)
+
   open fun findInstanceObject(instance: Instance) = if (hasLoaded) instanceIndex.get(instance.id) else null
 
   fun createClassObjectInstance(javaLangClass: InstanceObject?, classObj: ClassObj): InstanceObject {
@@ -184,6 +196,7 @@
   }
 
   override fun getActivityFragmentLeakFilter() = activityFragmentLeakFilter
+  override fun getBitmapDuplicationFilter() = bitmapDuplicationFilter
   override fun getSupportedInstanceFilters() = supportedInstanceFilters
   override fun getSelectedInstanceFilters() = currentInstanceFilters
 
@@ -224,7 +237,7 @@
   private fun refreshInstances(instances: Set<InstanceObject>, executor: Executor): Void? {
     executor.execute {
       _heapSets.values.forEach { it.clearClassifierSets() }
-      when (val h = _heapSets.values.find {it is AllHeapSet}) {
+      when (val h = _heapSets.values.find { it is AllHeapSet }) {
         null -> instances.forEach { _heapSets[it.heapId]!!.addDeltaInstanceObject(it) }
         else -> instances.forEach { h.addDeltaInstanceObject(it) }
       }
diff --git a/profilers/src/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilter.kt b/profilers/src/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilter.kt
new file mode 100644
index 0000000..15bb0b4
--- /dev/null
+++ b/profilers/src/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilter.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.profilers.memory.adapters.instancefilters
+
+import com.android.tools.profilers.memory.adapters.InstanceObject
+
+/**
+ * A filter that shows only Bitmap instances that have been identified as duplicates
+ * based on their pixel data content.
+ *
+ * @param duplicateBitmapSet The set of all InstanceObjects that have been pre-calculated
+ *                           to be part of a duplication group by [BitmapDuplicationAnalyzer].
+ */
+class BitmapDuplicationInstanceFilter(
+  private val duplicateBitmapSet: Set<InstanceObject>
+) : CaptureObjectInstanceFilter(
+  "duplicate bitmaps",
+  "Show duplicate Bitmap instances",
+  "Shows all Bitmap instances that have the same dimensions and pixel content as at least one other Bitmap instance.",
+  null,
+  // The core filter logic: an instance passes the filter if it's in the pre-computed set of duplicates.
+  { instanceObject -> duplicateBitmapSet.contains(instanceObject) }
+)
\ No newline at end of file
diff --git a/profilers/testSrc/com/android/tools/profilers/memory/BitmapDuplicationAnalyzerTest.kt b/profilers/testSrc/com/android/tools/profilers/memory/BitmapDuplicationAnalyzerTest.kt
new file mode 100644
index 0000000..46d0ebe
--- /dev/null
+++ b/profilers/testSrc/com/android/tools/profilers/memory/BitmapDuplicationAnalyzerTest.kt
@@ -0,0 +1,183 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.profilers.memory
+
+import com.android.tools.profilers.memory.adapters.FakeCaptureObject
+import com.android.tools.profilers.memory.adapters.FakeInstanceObject
+import com.android.tools.profilers.memory.adapters.ValueObject
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class BitmapDuplicationAnalyzerTest {
+
+  private val analyzer = BitmapDuplicationAnalyzer()
+
+  companion object {
+    private const val BITMAP_CLASS_NAME = "android.graphics.Bitmap"
+    private const val DUMP_DATA_CLASS_NAME = "android.graphics.Bitmap\$DumpData"
+    private const val BYTE_ARRAY_CLASS_NAME = "byte[]"
+    private const val BYTE_ARRAY_ARRAY_CLASS_NAME = "byte[][]"
+    private const val LONG_ARRAY_CLASS_NAME = "long[]"
+  }
+
+  @Test
+  fun testAnalyze_identifiesDuplicateBitmaps() {
+    val fakeCapture = FakeCaptureObject.Builder().build()
+    val bufferContent = byteArrayOf(1, 2, 3, 4)
+    val otherBufferContent = byteArrayOf(9, 9, 9, 9)
+
+    val nativePtrs = listOf(100L, 200L, 300L)
+    val buffers = listOf(
+      bufferContent.copyOf(),
+      otherBufferContent.copyOf(),
+      bufferContent.copyOf()
+    )
+    val dumpData = createDumpDataInstance(fakeCapture, nativePtrs, buffers)
+
+    val bmp1 = createBitmapInstance(fakeCapture, 1L, 10, 10, 100L)
+    val bmp2 = createBitmapInstance(fakeCapture, 2L, 20, 20, 200L)
+    val bmp3 = createBitmapInstance(fakeCapture, 3L, 10, 10, 300L)
+
+    // Add the top-level objects that should be on the heap. The test framework
+    // should make any referenced objects (like the internal arrays) reachable.
+    fakeCapture.addInstanceObjects(setOf(dumpData, bmp1, bmp2, bmp3))
+    analyzer.analyze(fakeCapture.instances.toList())
+
+    val duplicates = analyzer.getDuplicateInstances()
+    assertThat(duplicates).containsExactly(bmp1, bmp3)
+  }
+
+  @Test
+  fun testAnalyze_withNoDuplicates() {
+    val fakeCapture = FakeCaptureObject.Builder().build()
+    val bufferContent1 = byteArrayOf(1, 2, 3, 4)
+    val bufferContent2 = byteArrayOf(5, 6, 7, 8)
+
+    // Three bitmaps, none of which are duplicates.
+    // bmp1 and bmp3 have the same content but different dimensions.
+    // bmp1 and bmp2 have different content and different dimensions.
+    val nativePtrs = listOf(100L, 200L, 300L)
+    val buffers = listOf(
+      bufferContent1.copyOf(),
+      bufferContent2.copyOf(),
+      bufferContent1.copyOf()
+    )
+    val dumpData = createDumpDataInstance(fakeCapture, nativePtrs, buffers)
+
+    val bmp1 = createBitmapInstance(fakeCapture, 1L, 10, 10, 100L)
+    val bmp2 = createBitmapInstance(fakeCapture, 2L, 20, 20, 200L)
+    val bmp3 = createBitmapInstance(fakeCapture, 3L, 15, 15, 300L) // Same buffer as bmp1, different dimensions
+
+    fakeCapture.addInstanceObjects(setOf(dumpData, bmp1, bmp2, bmp3))
+    analyzer.analyze(fakeCapture.instances.toList())
+
+    val duplicates = analyzer.getDuplicateInstances()
+    assertThat(duplicates).isEmpty()
+  }
+
+  @Test
+  fun testAnalyze_withMissingDumpData() {
+    val fakeCapture = FakeCaptureObject.Builder().build()
+
+    val bmp1 = createBitmapInstance(fakeCapture, 1L, 10, 10, 100L)
+    val bmp2 = createBitmapInstance(fakeCapture, 2L, 10, 10, 300L)
+
+    // No DumpData instance is added to the capture.
+    fakeCapture.addInstanceObjects(setOf(bmp1, bmp2))
+    analyzer.analyze(fakeCapture.instances.toList())
+
+    val duplicates = analyzer.getDuplicateInstances()
+    assertThat(duplicates).isEmpty()
+  }
+
+  @Test
+  fun testAnalyze_withMismatchedArrays() {
+    val fakeCapture = FakeCaptureObject.Builder().build()
+    val bufferContent = byteArrayOf(1, 2, 3, 4)
+
+    // Mismatch: 2 native pointers but only 1 buffer.
+    val nativePtrs = listOf(100L, 200L)
+    val buffers = listOf(bufferContent.copyOf())
+    val dumpData = createDumpDataInstance(fakeCapture, nativePtrs, buffers)
+
+    val bmp1 = createBitmapInstance(fakeCapture, 1L, 10, 10, 100L)
+    val bmp2 = createBitmapInstance(fakeCapture, 2L, 20, 20, 200L)
+
+    fakeCapture.addInstanceObjects(setOf(dumpData, bmp1, bmp2))
+    analyzer.analyze(fakeCapture.instances.toList())
+
+    val duplicates = analyzer.getDuplicateInstances()
+    assertThat(duplicates).isEmpty()
+  }
+
+  /** Creates a fake `android.graphics.Bitmap` instance for testing. */
+  private fun createBitmapInstance(
+    capture: FakeCaptureObject,
+    id: Long,
+    width: Int,
+    height: Int,
+    nativePtr: Long
+  ): FakeInstanceObject {
+    val instance = FakeInstanceObject.Builder(capture, id, BITMAP_CLASS_NAME)
+      .setFields(listOf("mWidth", "mHeight", "mNativePtr"))
+      .setDepth(1)
+      .build()
+    instance.setFieldValue("mWidth", ValueObject.ValueType.INT, width)
+    instance.setFieldValue("mHeight", ValueObject.ValueType.INT, height)
+    instance.setFieldValue("mNativePtr", ValueObject.ValueType.LONG, nativePtr)
+    return instance
+  }
+
+  /**
+   * Creates a fake `android.graphics.Bitmap$DumpData` instance which holds the
+   * mapping between native pointers and their corresponding pixel data buffers.
+   * This structure is what the analyzer inspects to find bitmap data.
+   */
+  private fun createDumpDataInstance(
+    capture: FakeCaptureObject,
+    nativePtrs: List<Long>,
+    buffers: List<ByteArray>
+  ): FakeInstanceObject {
+    val bufferInstances = buffers.mapIndexed { i, bytes ->
+      FakeInstanceObject.Builder(capture, 1000L + i, BYTE_ARRAY_CLASS_NAME)
+        .setValueType(ValueObject.ValueType.ARRAY)
+        .setArray(ValueObject.ValueType.BYTE, bytes, bytes.size)
+        .build()
+    }
+
+    val buffersArray = FakeInstanceObject.Builder(capture, 2000L, BYTE_ARRAY_ARRAY_CLASS_NAME)
+      .setFields(buffers.indices.map { it.toString() })
+      .build()
+    bufferInstances.forEachIndexed { i, instance ->
+      buffersArray.setFieldValue(i.toString(), ValueObject.ValueType.OBJECT, instance)
+    }
+
+    val nativesArray = FakeInstanceObject.Builder(capture, 3000L, LONG_ARRAY_CLASS_NAME)
+      .setFields(nativePtrs.indices.map { it.toString() })
+      .build()
+    nativePtrs.forEachIndexed { i, ptr ->
+      nativesArray.setFieldValue(i.toString(), ValueObject.ValueType.LONG, ptr)
+    }
+
+    val dumpDataInstance = FakeInstanceObject.Builder(capture, 4000L, DUMP_DATA_CLASS_NAME)
+      .setFields(listOf("buffers", "natives"))
+      .setDepth(1)
+      .build()
+    dumpDataInstance.setFieldValue("buffers", ValueObject.ValueType.OBJECT, buffersArray)
+    dumpDataInstance.setFieldValue("natives", ValueObject.ValueType.OBJECT, nativesArray)
+    return dumpDataInstance
+  }
+}
\ No newline at end of file
diff --git a/profilers/testSrc/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilterTest.java b/profilers/testSrc/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilterTest.java
new file mode 100644
index 0000000..4ad6191
--- /dev/null
+++ b/profilers/testSrc/com/android/tools/profilers/memory/adapters/instancefilters/BitmapDuplicationInstanceFilterTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.tools.profilers.memory.adapters.instancefilters;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.android.tools.profilers.memory.adapters.FakeCaptureObject;
+import com.android.tools.profilers.memory.adapters.FakeInstanceObject;
+import com.android.tools.profilers.memory.adapters.InstanceObject;
+import com.google.common.collect.ImmutableSet;
+import java.util.Set;
+import org.junit.Test;
+
+public class BitmapDuplicationInstanceFilterTest {
+
+  @Test
+  public void filterSelectsOnlyDuplicateBitmaps() {
+    FakeCaptureObject capture = new FakeCaptureObject.Builder().build();
+
+    // Instances that are considered duplicates
+    FakeInstanceObject duplicateBitmap1 =
+      new FakeInstanceObject.Builder(capture, 1, "android.graphics.Bitmap").build();
+    FakeInstanceObject duplicateBitmap2 =
+      new FakeInstanceObject.Builder(capture, 2, "android.graphics.Bitmap").build();
+
+    // An instance that is not a duplicate
+    FakeInstanceObject uniqueBitmap =
+      new FakeInstanceObject.Builder(capture, 3, "android.graphics.Bitmap").build();
+
+    // An instance that is not a bitmap at all
+    FakeInstanceObject notABitmap =
+      new FakeInstanceObject.Builder(capture, 4, "java.lang.String").build();
+
+    // The set of all instances to be filtered
+    Set<InstanceObject> allInstances =
+      ImmutableSet.of(duplicateBitmap1, duplicateBitmap2, uniqueBitmap, notABitmap);
+
+    // The pre-computed set of duplicate bitmaps
+    Set<InstanceObject> duplicateSet = ImmutableSet.of(duplicateBitmap1, duplicateBitmap2);
+
+    // Create the filter with the set of duplicates
+    BitmapDuplicationInstanceFilter filter = new BitmapDuplicationInstanceFilter(duplicateSet);
+
+    // Apply the filter
+    Set<InstanceObject> result = filter.filter(allInstances);
+
+    // Verify that only the duplicate bitmaps are in the result
+    assertThat(result).containsExactly(duplicateBitmap1, duplicateBitmap2);
+  }
+
+  @Test
+  public void filterWithEmptyDuplicateSetReturnsEmpty() {
+    FakeCaptureObject capture = new FakeCaptureObject.Builder().build();
+
+    FakeInstanceObject bitmap1 =
+      new FakeInstanceObject.Builder(capture, 1, "android.graphics.Bitmap").build();
+    FakeInstanceObject bitmap2 =
+      new FakeInstanceObject.Builder(capture, 2, "android.graphics.Bitmap").build();
+
+    Set<InstanceObject> allInstances = ImmutableSet.of(bitmap1, bitmap2);
+    Set<InstanceObject> duplicateSet = ImmutableSet.of();
+
+    BitmapDuplicationInstanceFilter filter = new BitmapDuplicationInstanceFilter(duplicateSet);
+    Set<InstanceObject> result = filter.filter(allInstances);
+
+    assertThat(result).isEmpty();
+  }
+
+  @Test
+  public void filterWithEmptyInstancesReturnsEmpty() {
+    FakeCaptureObject capture = new FakeCaptureObject.Builder().build();
+
+    FakeInstanceObject duplicateBitmap1 =
+      new FakeInstanceObject.Builder(capture, 1, "android.graphics.Bitmap").build();
+    FakeInstanceObject duplicateBitmap2 =
+      new FakeInstanceObject.Builder(capture, 2, "android.graphics.Bitmap").build();
+
+    Set<InstanceObject> allInstances = ImmutableSet.of();
+    Set<InstanceObject> duplicateSet = ImmutableSet.of(duplicateBitmap1, duplicateBitmap2);
+
+    BitmapDuplicationInstanceFilter filter = new BitmapDuplicationInstanceFilter(duplicateSet);
+    Set<InstanceObject> result = filter.filter(allInstances);
+
+    assertThat(result).isEmpty();
+  }
+}