Repository: square/leakcanary
File: shark/shark-android/src/test/java/shark/AndroidObjectInspectorsTest.kt (Kotlin)

Code before:
        composeViewClass!!.instances.filter { instance ->
          val filter = AndroidObjectInspectors.VIEW.leakingObjectFilter!!
          filter.invoke(instance)
          true
        }.map { it.objectId }.toSet()
      },
      referenceMatchers = AndroidReferenceMatchers.appDefaults,
      objectInspectors = AndroidObjectInspectors.appDefaults
    )
    println(analysis)
    analysis as HeapAnalysisSuccess
    val leakTrace = analysis.applicationLeaks.single().leakTraces.single()

    // Verify the AndroidComposeView is the leaking object
    assertThat(leakTrace.leakingObject.className).isEqualTo("androidx.compose.ui.platform.AndroidComposeView")
    assertThat(leakTrace.leakingObject.leakingStatus).isEqualTo(LEAKING)

    // The key test: our fix should handle the new state field without crashing
    // Before the fix, analyzing a heap dump with CompositionImpl having "state" field (no "disposed" field)
    // would throw NullPointerException in AndroidObjectInspectors$COMPOSITION_IMPL$inspect at line 792
    // After the fix, the analysis completes successfully and handles both field structures gracefully:
    // 1. Old Compose: disposed boolean field (backward compatibility)
    // 2. New Compose: state int field with values RUNNING=0, DISPOSED=3, etc.
    // 3. Edge case: neither field exists (graceful fallback)

    // The fact that we got here without a crash proves the fix is working
  }
}


Review comment (suggestion):
😆 Claude really struggled to make this test. 

`"compose_new_state_field.hprof"` was programmatically created using a Kotlin script. But it took a lot of iterations and I don't think its perfect or fully covering the new `state` field.

Code after:
    assertThat(unreachableObject.leakingStatus).isEqualTo(LEAKING)
    assertThat(unreachableObject.leakingStatusReason)
      .contains("Composition disposed")
  }

  @Test fun `COMPOSITION_IMPL with new state field DISPOSED should be leaking`() {
    val analysis = analyzeCompositionImpl(mapOf("state" to IntHolder(3))) // DISPOSED = 3
    val unreachableObject = analysis.unreachableObjects.single()

    assertThat(unreachableObject.leakingStatus).isEqualTo(LEAKING)
    assertThat(unreachableObject.leakingStatusReason)
      .contains("Composition disposed")
  }

  @Test fun `COMPOSITION_IMPL with new state field RUNNING should not be leaking`() {
    val analysis = analyzeCompositionImpl(mapOf("state" to IntHolder(0))) // RUNNING = 0
    val unreachableObject = analysis.unreachableObjects.single()

    // Note: Status is still LEAKING because this is a watchedInstance (tracked by ObjectWatcher)
    // but the inspector provides the correct reason explaining why it's not actually leaking
    assertThat(unreachableObject.leakingStatus).isEqualTo(LEAKING)
    assertThat(unreachableObject.leakingStatusReason)
      .contains("Composition running")
  }

  private fun analyzeCompositionImpl(fields: Map<String, ValueHolder>): HeapAnalysisSuccess {
    val heapDump = dump {
      "androidx.compose.runtime.CompositionImpl" watchedInstance {
        fields.forEach { (name, value) ->
          field[name] = value
        }
      }
    }

    val heapAnalyzer = HeapAnalyzer(OnAnalysisProgressListener.NO_OP)

    return heapDump.openHeapGraph().use { graph: HeapGraph ->
      heapAnalyzer.analyze(
        heapDumpFile = File("/no/file"),
        graph = graph,
        leakingObjectFinder = FilteringLeakingObjectFinder(
          ObjectInspectors.jdkLeakingObjectFilters
        ),
        referenceMatchers = JdkReferenceMatchers.defaults,
        computeRetainedHeapSize = false,
        objectInspectors = listOf(ObjectInspectors.KEYED_WEAK_REFERENCE, AndroidObjectInspectors.COMPOSITION_IMPL),
        metadataExtractor = MetadataExtractor.NO_OP
      )
    } as HeapAnalysisSuccess
  }
}