tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 2de38e53416efa3b168a8dad0d83f55b777dbebc
parent bd8f46ca365978f6483a9f98ecebf525cc36d2e2
Author: Segun Famisa <sfamisa@mozilla.com>
Date:   Fri,  5 Dec 2025 15:36:14 +0000

Bug 1968045 - Output memory leak traces to a file r=android-reviewers,tthibaud

This patch introduces a custom reporter for LeakCanary that writes memory leak traces to a file when a leak is detected during Android instrumentation tests.
The new `FenixDetectLeaksAssert` class and `MemoryLeaksFileOutputReporter` handle writing the heap analysis to a text file in the directory specified by the `additionalTestOutputDir` directory.

Differential Revision: https://phabricator.services.mozilla.com/D269042

Diffstat:
Mmobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/perf/DetectMemoryLeaksRule.kt | 7+++++--
Amobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/perf/FenixDetectLeaksAssert.kt | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 162 insertions(+), 2 deletions(-)

diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/perf/DetectMemoryLeaksRule.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/perf/DetectMemoryLeaksRule.kt @@ -8,7 +8,6 @@ import android.util.Log import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.platform.app.InstrumentationRegistry import leakcanary.AppWatcher -import leakcanary.LeakAssertions import leakcanary.LeakCanary import leakcanary.TestDescriptionHolder import org.junit.rules.TestRule @@ -69,7 +68,11 @@ class DetectMemoryLeaksRule( referenceMatchers = AndroidReferenceMatchers.appDefaults + knownLeaks, ) base.evaluate() - LeakAssertions.assertNoLeaks(tag) + + FenixDetectLeaksAssert.assertNoLeaks( + tag = tag, + filename = "${description.className}_${description.methodName}", + ) } finally { AppWatcher.objectWatcher.clearWatchedObjects() } diff --git a/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/perf/FenixDetectLeaksAssert.kt b/mobile/android/fenix/app/src/androidTest/java/org/mozilla/fenix/helpers/perf/FenixDetectLeaksAssert.kt @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.helpers.perf + +import android.content.Context +import android.os.Build +import android.os.Environment +import androidx.test.platform.app.InstrumentationRegistry +import leakcanary.AndroidDetectLeaksAssert +import leakcanary.DetectLeaksAssert +import leakcanary.HeapAnalysisReporter +import leakcanary.LeakAssertions +import leakcanary.NoLeakAssertionFailedError +import shark.HeapAnalysis +import shark.HeapAnalysisSuccess +import java.io.File + +/** + * Fenix implementation of [DetectLeaksAssert] that wraps around the default [AndroidDetectLeaksAssert] + * implementation and provides a custom [HeapAnalysisReporter] that writes to an output file + * specified by the params. + * + * @param filename Destination filename for the leak report if a leak occurs + * @param directory Directory within the destination location to host the file if a leak occurs + */ +class FenixDetectLeaksAssert( + private val filename: String, + private val directory: String, +) : DetectLeaksAssert { + + private val delegateAssert: DetectLeaksAssert = AndroidDetectLeaksAssert( + heapAnalysisReporter = MemoryLeaksFileOutputReporter( + filename = filename, + directory = directory, + ), + ) + + override fun assertNoLeaks(tag: String) { + delegateAssert.assertNoLeaks(tag) + } + + companion object { + + /** + * Asserts that there are no leaks detected. If any leak is detected, then the test is + * failed and a leak trace is written to a file in a directory specified by the [directory] param within + * the `/sdcard/googletest/test_outputfiles/` location. + * + * This is built upon the [LeakAssertions.assertNoLeaks] function from the library. + * + * @param tag The tag used to identify the calling code + * @param filename The filename to be used for the memory leak trace in the event of + */ + fun assertNoLeaks(tag: String, filename: String, directory: String = "memory_leaks") { + DetectLeaksAssert.update( + FenixDetectLeaksAssert( + filename = filename, + directory = directory, + ), + ) + + LeakAssertions.assertNoLeaks(tag) + } + } +} + +/** + * Custom [HeapAnalysisReporter] that writes the leak trace to a file output + * specified by [filename], [directory] and then calls the default analysis reporter. + * + * The reports are written into a directory specified by [directory], within the location that is + * specified by the `additionalTestOutputDir` argument of the test runner argument. + */ +private class MemoryLeaksFileOutputReporter( + private val filename: String, + private val directory: String, + private val defaultReporter: HeapAnalysisReporter = NoLeakAssertionFailedError.throwOnApplicationLeaks(), +) : HeapAnalysisReporter { + override fun reportHeapAnalysis(heapAnalysis: HeapAnalysis) { + heapAnalysis.writeToFile(filename = filename, directory = directory) + defaultReporter.reportHeapAnalysis(heapAnalysis) + } +} + +private fun HeapAnalysis.writeToFile( + filename: String, + directory: String, +) { + if (this is HeapAnalysisSuccess && this.applicationLeaks.isNotEmpty()) { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val outputDirectory = + InstrumentationRegistry.getArguments().getString("additionalTestOutputDir") + ?.let { File(it) } + ?: getFallbackUsableDirectory(context) + + // delete any existing files in the directory + outputDirectory.listFiles()?.forEach { file -> + if (file.isFile) file.delete() + } + + val leakDirectory = File(outputDirectory, directory) + leakDirectory.mkdirs() + + val file = File(leakDirectory, "$filename.txt") + if (file.createNewFile()) { + file.writeText(toString()) + } + } +} + +/** + * Gets the fallback usable directory. In the event that the test runner does not provide an + * `additionalTestOutputDir` argument, the fallback directory is used. + * + * This is copied from how Android implements it in the macro benchmark library + * + * Source: [cs.android.com](https://cs.android.com/androidx/platform/frameworks/support/+/9bd4efdf8576ab9ce6654b0d115aadd6e1ea6ef5:benchmark/benchmark-common/src/main/java/androidx/benchmark/Outputs.kt;bpv=0) + */ +private fun getFallbackUsableDirectory(context: Context): File { + val dirUsableByAppAndShell = + when { + Build.VERSION.SDK_INT >= 29 -> { + // On Android Q+ we are using the media directory because that is + // the directory that the shell has access to. Context: b/181601156 + // Additionally, Benchmarks append user space traces to the ones produced + // by the Macro Benchmark run; and that is a lot simpler to do if we use the + // Media directory. (b/216588251) + @Suppress("DEPRECATION") + context.externalMediaDirs.firstOrNull { + Environment.getExternalStorageState(it) == Environment.MEDIA_MOUNTED + } + } + + Build.VERSION.SDK_INT <= 22 -> { + // prior to API 23, shell didn't have access to externalCacheDir + context.cacheDir + } + + else -> context.externalCacheDir + } + ?: throw IllegalStateException( + "Unable to select a directory for writing files, " + + "additionalTestOutputDir argument required to declare output dir.", + ) + + if (Build.VERSION.SDK_INT in 21..22) { + // By default, shell doesn't have access to app dirs on 21/22 so we need to modify + // this so that the shell can output here too + dirUsableByAppAndShell.setReadable(true, false) + dirUsableByAppAndShell.setWritable(true, false) + dirUsableByAppAndShell.setExecutable(true, false) + } + return dirUsableByAppAndShell +}