diff --git a/android/plugins/leakcanary2/build.gradle b/android/plugins/leakcanary2/build.gradle new file mode 100644 index 000000000..dbc0b409c --- /dev/null +++ b/android/plugins/leakcanary2/build.gradle @@ -0,0 +1,31 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'maven' + +android { + compileSdkVersion rootProject.compileSdkVersion + buildToolsVersion rootProject.buildToolsVersion + + defaultConfig { + minSdkVersion rootProject.minSdkVersion + targetSdkVersion rootProject.targetSdkVersion + } + + dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$KOTLIN_VERSION" + implementation project(':android') + implementation deps.leakcanary2 + compileOnly deps.jsr305 + } +} + +apply plugin: 'com.vanniktech.maven.publish' diff --git a/android/plugins/leakcanary2/gradle.properties b/android/plugins/leakcanary2/gradle.properties new file mode 100644 index 000000000..51ae3ccfc --- /dev/null +++ b/android/plugins/leakcanary2/gradle.properties @@ -0,0 +1,8 @@ +# This source code is licensed under the MIT license found in the LICENSE +# file in the root directory of this source tree. + +POM_NAME=Flipper LeakCanary2 Plugin +POM_DESCRIPTION=LeakCanary2 plugin for Flipper +POM_ARTIFACT_ID=flipper-leakcanary2-plugin +POM_PACKAGING=aar + diff --git a/android/plugins/leakcanary2/src/main/AndroidManifest.xml b/android/plugins/leakcanary2/src/main/AndroidManifest.xml new file mode 100644 index 000000000..8308d56d9 --- /dev/null +++ b/android/plugins/leakcanary2/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/FlipperLeakListener.kt b/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/FlipperLeakListener.kt new file mode 100644 index 000000000..2cba5cfb4 --- /dev/null +++ b/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/FlipperLeakListener.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.leakcanary2 + +import com.facebook.flipper.android.AndroidFlipperClient +import leakcanary.DefaultOnHeapAnalyzedListener +import leakcanary.OnHeapAnalyzedListener +import shark.HeapAnalysis +import shark.HeapAnalysisSuccess + +class FlipperLeakListener : OnHeapAnalyzedListener { + private val leaks: MutableList = mutableListOf() + + private val defaultListener = DefaultOnHeapAnalyzedListener.create() + + override fun onHeapAnalyzed(heapAnalysis: HeapAnalysis) { + leaks.addAll(heapAnalysis.toLeakList()) + + AndroidFlipperClient.getInstanceIfInitialized()?.let { client -> + (client.getPlugin(LeakCanary2FlipperPlugin.ID) as? LeakCanary2FlipperPlugin) + ?.reportLeaks(leaks) + } + + defaultListener.onHeapAnalyzed(heapAnalysis) + } + + private fun HeapAnalysis.toLeakList(): List { + return if (this is HeapAnalysisSuccess) { + allLeaks.mapNotNull { + if (it.leakTraces.isNotEmpty()) { + it.leakTraces[0].toLeak(it.shortDescription) + } else { + null + } + }.toList() + } else { + emptyList() + } + } +} diff --git a/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/LeakCanary2FlipperPlugin.kt b/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/LeakCanary2FlipperPlugin.kt new file mode 100644 index 000000000..9d0388139 --- /dev/null +++ b/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/LeakCanary2FlipperPlugin.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.leakcanary2 + +import com.facebook.flipper.core.FlipperConnection +import com.facebook.flipper.core.FlipperPlugin + +private const val REPORT_LEAK_EVENT = "reportLeak2" +private const val CLEAR_EVENT = "clear" + +class LeakCanary2FlipperPlugin : FlipperPlugin { + private val leaks: MutableList = mutableListOf() + private val alreadySeenLeakSignatures: MutableSet = mutableSetOf() + private var connection: FlipperConnection? = null + + override fun getId() = ID + + override fun onConnect(connection: FlipperConnection?) { + this.connection = connection + connection?.receive(CLEAR_EVENT) { _, _ -> leaks.clear() } + sendLeakList() + } + + override fun onDisconnect() { + connection = null + } + + override fun runInBackground() = false + + internal fun reportLeaks(leaks: List) { + for (leak in leaks) { + if (leak.signature !in alreadySeenLeakSignatures) { + this.leaks.add(leak) + alreadySeenLeakSignatures.add(leak.signature) + } + } + + sendLeakList() + } + + private fun sendLeakList() { + connection?.send(REPORT_LEAK_EVENT, LeakCanary2Report(leaks).toFlipperObject()) + } + + companion object { + const val ID = "LeakCanary" + } +} diff --git a/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/LeakCanary2Report.kt b/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/LeakCanary2Report.kt new file mode 100644 index 000000000..410db39f8 --- /dev/null +++ b/android/plugins/leakcanary2/src/main/java/com/facebook/flipper/plugins/leakcanary2/LeakCanary2Report.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.flipper.plugins.leakcanary2 + +import com.facebook.flipper.core.FlipperArray +import com.facebook.flipper.core.FlipperObject +import com.facebook.flipper.core.FlipperValue +import shark.LeakTrace +import shark.LeakTraceObject +import java.util.UUID + +internal data class LeakCanary2Report(val leaks: List) : FlipperValue { + override fun toFlipperObject(): FlipperObject = FlipperObject.Builder() + .put("leaks", leaks.map { it.toFlipperObject() }.toFlipperArray()) + .build() +} + +internal data class Leak( + val title: String, + val root: String, + val elements: Map, + val retainedSize: String, + val signature: String, + val details: String +) : FlipperValue { + override fun toFlipperObject(): FlipperObject { + return FlipperObject.Builder() + .put("title", title) + .put("root", root) + .put("elements", elements.toFlipperObject()) + .put("retainedSize", retainedSize) + .put("details", details) + .build() + } + + private fun Map.toFlipperObject(): FlipperObject = + mapValues { it.value.toFlipperObject() }.toFlipperObject() + + @JvmName("toFlipperObjectStringFlipperObject") + private fun Map.toFlipperObject(): FlipperObject = + asIterable() + .fold(FlipperObject.Builder()) { builder, entry -> + builder.put(entry.key, entry.value) + } + .build() +} + +internal fun LeakTrace.toLeak(title: String): Leak { + val elements = getElements() + return Leak( + title = title, + elements = elements.toMap(), + retainedSize = retainedHeapByteSize?.let { "$it bytes" } ?: "unknown size", + signature = signature, + root = elements.first().first, + details = "$this" + ) +} + +private fun LeakTrace.getElements(): List> { + val referenceElements = referencePath.map { reference -> + val id = UUID.randomUUID().toString() + id to Element(id, reference.originObject) + }.toMutableList() + + val leakId = UUID.randomUUID().toString() + referenceElements.add(leakId to Element(leakId, leakingObject)) + + return referenceElements.mapIndexed { index, pair -> + pair.first to if (index == referenceElements.lastIndex) pair.second else pair.second.copy( + children = listOf(referenceElements[index + 1].second.id) + ) + } +} + +internal data class Element( + val id: String, + val name: String, + val expanded: Boolean = true, + val children: List = emptyList(), + val attributes: List, + val decoration: String = "" +) : FlipperValue { + constructor(id: String, leakObject: LeakTraceObject) : this( + id = id, + name = "${leakObject.className} (${leakObject.typeName})", + attributes = listOf( + ElementAttribute("leaking", leakObject.leakingStatus.shortName), + ElementAttribute("retaining", leakObject.retaining) + ) + ) + + override fun toFlipperObject(): FlipperObject { + return FlipperObject.Builder() + .put("id", id) + .put("name", name) + .put("expanded", expanded) + .put("children", children.toFlipperArray()) + .put("attributes", attributes.toFlipperArray()) + .put("data", EMPTY_FLIPPER_OBJECT) + .put("decoration", decoration) + .put("extraInfo", EMPTY_FLIPPER_OBJECT) + .build() + } + + @JvmName("toFlipperArrayFlipperValue") + private fun Iterable.toFlipperArray(): FlipperArray = + map { it.toFlipperObject() }.toFlipperArray() + + @JvmName("toFlipperArrayString") + private fun Iterable.toFlipperArray(): FlipperArray = + fold(FlipperArray.Builder()) { builder, row -> builder.put(row) }.build() +} + +internal fun Iterable.toFlipperArray(): FlipperArray = + fold(FlipperArray.Builder()) { builder, row -> builder.put(row) }.build() + +private val LeakTraceObject.LeakingStatus.shortName: String + get() = when (this) { + LeakTraceObject.LeakingStatus.NOT_LEAKING -> "N" + LeakTraceObject.LeakingStatus.LEAKING -> "Y" + LeakTraceObject.LeakingStatus.UNKNOWN -> "?" + } +private val LeakTraceObject.retaining: String + get() = retainedHeapByteSize?.let { "$it bytes ($retainedObjectCount objects)" } ?: "unknown" + +private val EMPTY_FLIPPER_OBJECT = FlipperObject.Builder().build() + +data class ElementAttribute( + val name: String, + val value: String +) : FlipperValue { + override fun toFlipperObject(): FlipperObject { + return FlipperObject.Builder() + .put("name", name) + .put("value", value) + .build() + } +} diff --git a/build.gradle b/build.gradle index 322101213..96e3a9ce7 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,7 @@ buildscript { classpath 'com.android.tools.build:gradle:4.1.2' classpath 'com.vanniktech:gradle-maven-publish-plugin:0.14.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION" + classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.4.20" } } @@ -101,6 +102,7 @@ ext.deps = [ mockito : 'org.mockito:mockito-core:2.26.0', okhttp3 : 'com.squareup.okhttp3:okhttp:3.14.1', leakcanary : 'com.squareup.leakcanary:leakcanary-android:1.6.3', + leakcanary2 : 'com.squareup.leakcanary:leakcanary-android:2.6', testCore : 'androidx.test:core:1.1.0', testRules : 'androidx.test:rules:1.1.0', // Plugin dependencies diff --git a/desktop/plugins/leak_canary/index.tsx b/desktop/plugins/leak_canary/index.tsx index 5370691e7..e968a6081 100644 --- a/desktop/plugins/leak_canary/index.tsx +++ b/desktop/plugins/leak_canary/index.tsx @@ -36,6 +36,10 @@ type LeakReport = { leaks: string[]; }; +type LeakCanary2Report = { + leaks: Leak2[]; +}; + export type Fields = {[key: string]: string}; export type Leak = { title: string; @@ -45,6 +49,15 @@ export type Leak = { instanceFields: {[key: string]: Fields}; staticFields: {[key: string]: Fields}; retainedSize: string; + details?: string; +}; + +export type Leak2 = { + title: string; + root: string; + elements: {[key: string]: Element}; + retainedSize: string; + details: string; }; const Window = styled(FlexRow)({ @@ -72,23 +85,43 @@ export default class LeakCanary extends FlipperPlugin< init() { this.client.subscribe('reportLeak', (results: LeakReport) => { - // We only process new leaks instead of replacing the whole list in order - // to both avoid redundant processing and to preserve the expanded/ - // collapsed state of the tree view - const newLeaks = processLeaks(results.leaks.slice(this.state.leaksCount)); + this._addNewLeaks(processLeaks(results.leaks)); + }); - const leaks = this.state.leaks; - for (let i = 0; i < newLeaks.length; i++) { - leaks.push(newLeaks[i]); - } - - this.setState({ - leaks: leaks, - leaksCount: results.leaks.length, - }); + this.client.subscribe('reportLeak2', (results: LeakCanary2Report) => { + this._addNewLeaks(results.leaks.map(this._adaptLeak2)); }); } + _addNewLeaks = (incomingLeaks: Leak[]) => { + // We only process new leaks instead of replacing the whole list in order + // to both avoid redundant processing and to preserve the expanded/ + // collapsed state of the tree view + const newLeaks = incomingLeaks.slice(this.state.leaksCount); + const leaks = this.state.leaks; + for (let i = 0; i < newLeaks.length; i++) { + leaks.push(newLeaks[i]); + } + + this.setState({ + leaks: leaks, + leaksCount: leaks.length, + }); + }; + + _adaptLeak2 = (leak: Leak2): Leak => { + return { + title: leak.title, + root: leak.root, + elements: leak.elements, + elementsSimple: leak.elements, + staticFields: {}, + instanceFields: {}, + retainedSize: leak.retainedSize, + details: leak.details, + }; + }; + _clearLeaks = () => { this.setState({ leaks: [], @@ -154,20 +187,29 @@ export default class LeakCanary extends FlipperPlugin< return ( - - - - - - + {instanceFields && ( + + + + )} + {staticFields && ( + + + + )} + {leak.details && ( + +
{leak.details}
+
+ )}
); } @@ -184,6 +226,7 @@ export default class LeakCanary extends FlipperPlugin< const elements = showFullClassPaths ? leak.elements : leak.elementsSimple; + const selected = selectedIdx == idx ? selectedEid : null; return (