Skip to content

Commit cde9a09

Browse files
authored
Merge pull request #2918 from square/py/heap-dumps-in-no-backup-dir
Store heap dumps in the app's no backup directory
2 parents 927de67 + a85adfd commit cde9a09

17 files changed

Lines changed: 66 additions & 636 deletions

File tree

docs/changelog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ Releases before 2.8.1 predate these markers.
1919

2020
## Unreleased
2121

22+
* 🔀 [#2790](https://github.com/square/leakcanary/issues/2790) [#2719](https://github.com/square/leakcanary/issues/2719) [#2174](https://github.com/square/leakcanary/issues/2174) Heap dumps are now always stored in a `leakcanary` directory inside the app's [no backup directory](https://developer.android.com/reference/android/content/Context#getNoBackupFilesDir()), i.e. `/data/data/com.example/no_backup/leakcanary/`. They used to go to a `leakcanary-com.example` directory in the public `Download` folder, falling back to the app's cache directory when that wasn't writable, which in practice meant three different behaviors: on API 26 to 28 the destination depended on whether the app happened to hold `WRITE_EXTERNAL_STORAGE`, which LeakCanary declared but never requested; on API 29 scoped storage made the public directory unwritable and everything fell back to the cache directory; and from API 30 on, writing there needs no permission, so every heap dump went to `Download` and the fallback became unreachable. Storing heap dumps in public storage is what caused the failures above: a heap dump in `Download` belongs to the app in `MediaStore`, and clearing the app's data or reinstalling it clears that ownership while leaving the file on disk, after which the app can't read the file (the analysis fails with *"Hprof file missing"*), can't list it (so the `maxStoredHeapDumps` cleanup stops seeing it — one reporter accumulated 400+ heap dumps, about 40 GB) and can't delete it. None of that applies to app storage, which behaves the same way on every Android version.
23+
* ⚠️ LeakCanary no longer declares the `READ_EXTERNAL_STORAGE` and `WRITE_EXTERNAL_STORAGE` permissions, so they disappear from the merged manifest of debug builds.
24+
* ⚠️ `LeakCanary.Config.requestWriteExternalStoragePermission` and `LeakCanary.Config.Builder.requestWriteExternalStoragePermission()` are deprecated and do nothing. There's no external storage permission to request anymore.
25+
* 🔀 Heap dumps taken by `leakcanary-android-test` and `leakcanary-android-instrumentation` move from the files directory of the app under test to its no backup directory, so they aren't backed up either. Heap dumps taken by `leakcanary-android-uiautomator` stay in `/data/local/tmp`: they're written by `am dumpheap` running as the shell user, which can't write to an app's private directory.
26+
* 🔀 `adb pull /sdcard/Download/leakcanary-com.example/…` doesn't work anymore. Share the heap dump from the LeakCanary UI, or run `adb exec-out run-as com.example cat no_backup/leakcanary/<name>.hprof > dump.hprof`.
27+
* 🔀 Heap dumps written by an earlier version of LeakCanary are left where they are. LeakCanary only cleans up the directory it writes to, and on API 30 and above it couldn't delete the ones it no longer owns anyway. Delete the `Download/leakcanary-com.example` directory by hand to reclaim that space.
28+
* ⚠️ The *"Render Heap Dump"* screen in the LeakCanary UI is gone, along with its *"Generate HQ Bitmap"* action. The action rendered the heap dump to a PNG in the public `Download` folder, gated on the `WRITE_EXTERNAL_STORAGE` permission — which apps targeting Android 11 and above can't be granted, so since 2020 tapping it only ever showed a toast asking for a permission that would have bought nothing.
2229
* 💥 [#2789](https://github.com/square/leakcanary/issues/2789) [#2773](https://github.com/square/leakcanary/issues/2773) The heap analysis of a large heap failed with an `OutOfMemoryError` while growing the set of objects the path finding traversal has already visited. That set was keyed by object id, so it held every reachable object in an 8 byte per slot hash table at a 0.75 load factor, sized from a guess (`instanceCount / 2`) that is always too small: on the Android heap dumps in our test resources the traversal ends up visiting 0.65x to 1.08x of `instanceCount`. Growing doubles the table and rehashes into it while the old one is still referenced, so the moment of growth needs 1.5x the new table — on a heap of 4 million objects, a 33.6 MB table and a 67.1 MB one live at the same time, in an app capped at a 512 MB growth limit. The visited set is now one bit per object in the heap dump, keyed by the object's index rather than by its id, allocated once at a size that's known upfront, so it can't grow and can't rehash: 509 KB rather than a 100.7 MB peak on that 4 million object heap. On a 4.4 million object Android heap dump, the smallest heap the analysis completes in goes from 513 MB to 385 MB.
2330
* 🔀 Mapping an object id to its index is a binary search where a hash lookup used to do, and the traversal does that once per reference it reads, so the analysis is about 2% slower end to end (4% to 8% of the path finding step) on the Android heap dumps in our test resources.
2431
* 💥 [#2773](https://github.com/square/leakcanary/issues/2773) The path finding traversal also kept a set of the object ids waiting in its queue, keyed by object id and therefore growing to the size of the traversal frontier — on a heap dump whose objects are reachable through a wide array, most of the heap. It turned out to be write-only: an object is added to exactly one of the two queues and removed from it when polled, so an object waiting in the low priority queue is never in the high priority queue as well, which is all the one read of that set was checking. Removing it takes the smallest heap the analysis of a 4.4 million object Android heap dump completes in from 385 MB to 321 MB, and makes path finding 2.6% to 4.3% faster, since maintaining the set cost a hash insert per reference enqueued and a hash removal per object visited.

docs/faq.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,22 @@ D/LeakCanary: Updated LeakCanary.config: Config(dumpHeap=false)
3737

3838
## Where does LeakCanary store heap dumps?
3939

40-
The default behavior is to store heap dumps in a `leakcanary` folder under the app directory. If the app has been granted the `android.permission.WRITE_EXTERNAL_STORAGE` permission, then heap dumps will be stored
41-
in a `leakcanary-com.example` folder (where `com.example` is your app package name) under the `Download` folder of the external storage. If the app has not been granted the `android.permission.WRITE_EXTERNAL_STORAGE` permission but that permission is listed in `AndroidManifest.xml` then LeakCanary will show a notification that can be tapped to grant permission.
40+
In a `leakcanary` folder inside the app's [no backup directory](https://developer.android.com/reference/android/content/Context#getNoBackupFilesDir()),
41+
i.e. `/data/data/com.example/no_backup/leakcanary/` where `com.example` is your app package name.
42+
That directory needs no permission on any Android version, and heap dumps stored there are excluded
43+
from Android Auto Backup, so they never count against the user's backup quota.
44+
45+
LeakCanary keeps the [LeakCanary.Config.maxStoredHeapDumps](/leakcanary/api/leakcanary-android-core/leakcanary-android-core/leakcanary/-leak-canary/-config/max-stored-heap-dumps/)
46+
most recent heap dumps, 7 by default, and deletes the older ones.
47+
48+
To pull a heap dump off the device, either share it from the LeakCanary UI (go to a heap analysis
49+
screen, click the overflow menu and select *Share Heap Dump*), or read it through the app's own
50+
user with `run-as`, which works because LeakCanary is a `debugImplementation` dependency and the app
51+
is therefore debuggable:
52+
53+
```
54+
adb exec-out run-as com.example cat no_backup/leakcanary/2026-07-31_22-30-41_484.hprof > dump.hprof
55+
```
4256

4357
## How can I dig beyond the leak trace?
4458

leakcanary/leakcanary-android-core/detekt-baseline.xml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<SmellBaseline>
33
<ManuallySuppressedIssues/>
44
<CurrentIssues>
5-
<ID>ChainWrapping:HeapDumpRenderer.kt$HeapDumpRenderer$&amp;&amp;</ID>
65
<ID>ChainWrapping:LeakCanaryTextView.kt$LeakCanaryTextView$+</ID>
76
<ID>ChainWrapping:SquigglySpanRenderer.kt$SquigglySpanRenderer.Companion$||</ID>
87
<ID>FinalNewline:HeapAnalysisTable.kt$leakcanary.internal.activity.db.HeapAnalysisTable.kt</ID>
@@ -43,7 +42,6 @@
4342
<ID>NoConsecutiveBlankLines:InternalLeakCanary.kt$InternalLeakCanary$ </ID>
4443
<ID>NoConsecutiveBlankLines:LeakCanary.kt$LeakCanary.Config.Builder$ </ID>
4544
<ID>NoConsecutiveBlankLines:LeakCanaryAndroidInternalUtils.kt$ </ID>
46-
<ID>NoConsecutiveBlankLines:RenderHeapDumpScreen.kt$ </ID>
4745
<ID>NoMultipleSpaces:LogcatEventListener.kt$LogcatEventListener$ </ID>
4846
<ID>NoMultipleSpaces:NotificationEventListener.kt$NotificationEventListener$ </ID>
4947
<ID>NoSemicolons:NotificationType.kt$NotificationType.LEAKCANARY_MAX$;</ID>

leakcanary/leakcanary-android-core/src/main/AndroidManifest.xml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,6 @@
1717
<manifest
1818
xmlns:android="http://schemas.android.com/apk/res/android">
1919

20-
<!-- To store the heap dumps and leak analysis results. -->
21-
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
22-
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
23-
2420
<!-- To allow posting notifications on Android 13 -->
2521
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
2622

@@ -94,7 +90,7 @@
9490
android:name="leakcanary.internal.RequestPermissionActivity"
9591
android:excludeFromRecents="true"
9692
android:icon="@mipmap/leak_canary_icon"
97-
android:label="@string/leak_canary_storage_permission_activity_label"
93+
android:label="@string/leak_canary_permission_activity_label"
9894
android:taskAffinity="com.squareup.leakcanary.${applicationId}"
9995
android:theme="@style/leak_canary_Theme.Transparent"
10096
/>

leakcanary/leakcanary-android-core/src/main/java/leakcanary/LeakCanary.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,12 @@ object LeakCanary {
123123
val maxStoredHeapDumps: Int = 7,
124124

125125
/**
126-
* LeakCanary always attempts to store heap dumps on the external storage if the
127-
* WRITE_EXTERNAL_STORAGE is already granted, and otherwise uses the app storage.
128-
* If the WRITE_EXTERNAL_STORAGE permission is not granted and
129-
* [requestWriteExternalStoragePermission] is true, then LeakCanary will display a notification
130-
* to ask for that permission.
131-
*
132-
* Defaults to false because that permission notification can be annoying.
126+
* No longer does anything. LeakCanary used to store heap dumps in the public `Download`
127+
* directory when the app held the `WRITE_EXTERNAL_STORAGE` permission, and would show a
128+
* notification asking for that permission when this was true. Heap dumps are now always stored
129+
* in the app's own storage, which needs no permission on any Android version.
133130
*/
131+
@Deprecated("LeakCanary no longer stores heap dumps on the external storage, so this does nothing.")
134132
val requestWriteExternalStoragePermission: Boolean = false,
135133

136134
/**
@@ -241,6 +239,8 @@ object LeakCanary {
241239
private var metadataExtractor = config.metadataExtractor
242240
private var computeRetainedHeapSize = config.computeRetainedHeapSize
243241
private var maxStoredHeapDumps = config.maxStoredHeapDumps
242+
243+
@Suppress("DEPRECATION")
244244
private var requestWriteExternalStoragePermission =
245245
config.requestWriteExternalStoragePermission
246246
private var leakingObjectFinder = config.leakingObjectFinder
@@ -281,6 +281,7 @@ object LeakCanary {
281281
apply { this.maxStoredHeapDumps = maxStoredHeapDumps }
282282

283283
/** @see [LeakCanary.Config.requestWriteExternalStoragePermission] */
284+
@Deprecated("LeakCanary no longer stores heap dumps on the external storage, so this does nothing.")
284285
fun requestWriteExternalStoragePermission(requestWriteExternalStoragePermission: Boolean) =
285286
apply { this.requestWriteExternalStoragePermission = requestWriteExternalStoragePermission }
286287

@@ -301,6 +302,7 @@ object LeakCanary {
301302
apply { this.showNotifications = showNotifications }
302303

303304

305+
@Suppress("DEPRECATION")
304306
fun build() = config.copy(
305307
dumpHeap = dumpHeap,
306308
dumpHeapWhenDebugging = dumpHeapWhenDebugging,

leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/InternalLeakCanary.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,9 @@ internal object InternalLeakCanary : (Application) -> Unit, OnObjectRetainedList
6666

6767
fun createLeakDirectoryProvider(context: Context): LeakDirectoryProvider {
6868
val appContext = context.applicationContext
69-
return LeakDirectoryProvider(appContext, {
69+
return LeakDirectoryProvider(appContext) {
7070
LeakCanary.config.maxStoredHeapDumps
71-
}, {
72-
LeakCanary.config.requestWriteExternalStoragePermission
73-
})
71+
}
7472
}
7573

7674
internal enum class FormFactor {

leakcanary/leakcanary-android-core/src/main/java/leakcanary/internal/LeakCanaryFileProvider.kt

Lines changed: 5 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import android.content.pm.ProviderInfo
2424
import android.database.Cursor
2525
import android.database.MatrixCursor
2626
import android.net.Uri
27-
import android.os.Environment
2827
import android.os.ParcelFileDescriptor
2928
import android.os.StrictMode
3029
import android.provider.OpenableColumns
@@ -364,19 +363,11 @@ internal class LeakCanaryFileProvider : ContentProvider() {
364363

365364
private const val META_DATA_FILE_PROVIDER_PATHS = "android.support.FILE_PROVIDER_PATHS"
366365

367-
private const val TAG_ROOT_PATH = "root-path"
368-
private const val TAG_FILES_PATH = "files-path"
369-
private const val TAG_CACHE_PATH = "cache-path"
370-
private const val TAG_EXTERNAL = "external-path"
371-
private const val TAG_EXTERNAL_FILES = "external-files-path"
372-
private const val TAG_EXTERNAL_CACHE = "external-cache-path"
373-
private const val TAG_EXTERNAL_MEDIA = "external-media-path"
366+
private const val TAG_NO_BACKUP_FILES_PATH = "no-backup-files-path"
374367

375368
private const val ATTR_NAME = "name"
376369
private const val ATTR_PATH = "path"
377370

378-
private val DEVICE_ROOT = File("/")
379-
380371
private val sCache = HashMap<String, PathStrategy>()
381372

382373
/**
@@ -479,52 +470,17 @@ internal class LeakCanaryFileProvider : ContentProvider() {
479470
val name = resourceParser.getAttributeValue(null, ATTR_NAME)
480471
val path = resourceParser.getAttributeValue(null, ATTR_PATH)
481472

482-
var target: File? = null
483-
if (TAG_ROOT_PATH == tag) {
484-
target = DEVICE_ROOT
485-
} else if (TAG_FILES_PATH == tag) {
486-
target = context.filesDir
487-
} else if (TAG_CACHE_PATH == tag) {
488-
target = context.cacheDir
489-
} else if (TAG_EXTERNAL == tag) {
490-
target = Environment.getExternalStorageDirectory()
491-
} else if (TAG_EXTERNAL_FILES == tag) {
492-
val externalFilesDirs = getExternalFilesDirs(context, null)
493-
if (externalFilesDirs.isNotEmpty()) {
494-
target = externalFilesDirs[0]
495-
}
496-
} else if (TAG_EXTERNAL_CACHE == tag) {
497-
val externalCacheDirs = getExternalCacheDirs(context)
498-
if (externalCacheDirs.isNotEmpty()) {
499-
target = externalCacheDirs[0]
500-
}
501-
} else if (TAG_EXTERNAL_MEDIA == tag) {
502-
val externalMediaDirs = context.externalMediaDirs
503-
if (externalMediaDirs.isNotEmpty()) {
504-
target = externalMediaDirs[0]
505-
}
506-
}
507-
508-
if (target != null) {
509-
strat.addRoot(name, buildPath(target, path))
473+
// LeakCanary only ever shares files it wrote to its own no backup directory, so that's
474+
// the only root this provider knows how to resolve.
475+
if (TAG_NO_BACKUP_FILES_PATH == tag) {
476+
strat.addRoot(name, buildPath(context.noBackupFilesDir, path))
510477
}
511478
}
512479
}
513480

514481
return strat
515482
}
516483

517-
private fun getExternalFilesDirs(
518-
context: Context,
519-
type: String?
520-
): Array<File> {
521-
return context.getExternalFilesDirs(type)
522-
}
523-
524-
private fun getExternalCacheDirs(context: Context): Array<File> {
525-
return context.externalCacheDirs
526-
}
527-
528484
/**
529485
* Copied from ContentResolver.java
530486
*/

0 commit comments

Comments
 (0)