Remove obsolete AndroidLeakFixes after minimum SDK upgrade

Removes 5 AndroidLeakFixes enum constants that are no longer needed
with minimum SDK 26+:

- MEDIA_SESSION_LEGACY_HELPER (API 21 only)
- CONNECTIVITY_MANAGER (API ≤23 only)
- ACTIVITY_MANAGER (Samsung API 22 only)
- IMM_FOCUSED_VIEW (API ≤23 only)
- SPELL_CHECKER (API 23 only)

These fixes were already no-ops with early returns after the min SDK
upgrade. Removing them completely eliminates ~200 lines of dead code
and documentation while maintaining all functional leak fixes.

The remaining 10 AndroidLeakFixes enum constants continue to provide
useful leak fixes for Android 26+.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pblundell
2026-03-04 09:49:19 +00:00
parent 6f79d2232b
commit 2cbdce272b
2 changed files with 0 additions and 307 deletions

View File

@@ -1,17 +1,12 @@
public abstract class leakcanary/AndroidLeakFixes : java/lang/Enum {
public static final field ACCESSIBILITY_NODE_INFO Lleakcanary/AndroidLeakFixes;
public static final field ACTIVITY_MANAGER Lleakcanary/AndroidLeakFixes;
public static final field BUBBLE_POPUP Lleakcanary/AndroidLeakFixes;
public static final field CONNECTIVITY_MANAGER Lleakcanary/AndroidLeakFixes;
public static final field Companion Lleakcanary/AndroidLeakFixes$Companion;
public static final field FLUSH_HANDLER_THREADS Lleakcanary/AndroidLeakFixes;
public static final field IMM_CUR_ROOT_VIEW Lleakcanary/AndroidLeakFixes;
public static final field IMM_FOCUSED_VIEW Lleakcanary/AndroidLeakFixes;
public static final field LAST_HOVERED_VIEW Lleakcanary/AndroidLeakFixes;
public static final field MEDIA_SESSION_LEGACY_HELPER Lleakcanary/AndroidLeakFixes;
public static final field PERMISSION_CONTROLLER_MANAGER Lleakcanary/AndroidLeakFixes;
public static final field SAMSUNG_CLIPBOARD_MANAGER Lleakcanary/AndroidLeakFixes;
public static final field SPELL_CHECKER Lleakcanary/AndroidLeakFixes;
public static final field TEXT_LINE_POOL Lleakcanary/AndroidLeakFixes;
public static final field USER_MANAGER Lleakcanary/AndroidLeakFixes;
public static final field VIEW_LOCATION_HOLDER Lleakcanary/AndroidLeakFixes;

View File

@@ -8,7 +8,6 @@ import android.content.Context.INPUT_METHOD_SERVICE
import android.content.ContextWrapper
import android.os.Build.MANUFACTURER
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
@@ -17,18 +16,12 @@ import android.view.View
import android.view.Window
import android.view.accessibility.AccessibilityNodeInfo
import android.view.inputmethod.InputMethodManager
import android.view.textservice.TextServicesManager
import android.widget.TextView
import curtains.Curtains
import curtains.OnRootViewRemovedListener
import java.lang.reflect.Array
import java.lang.reflect.Field
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.lang.reflect.Modifier
import java.lang.reflect.Proxy
import java.util.EnumSet
import leakcanary.internal.ReferenceCleaner
import leakcanary.internal.friendly.checkMainThread
import leakcanary.internal.friendly.noOpDelegate
import shark.SharkLog
@@ -39,29 +32,6 @@ import shark.SharkLog
@SuppressLint("NewApi")
enum class AndroidLeakFixes {
/**
* MediaSessionLegacyHelper is a static singleton and did not use the application context.
* Introduced in android-5.0.1_r1, fixed in Android 5.1.0_r1.
* https://github.com/android/platform_frameworks_base/commit/9b5257c9c99c4cb541d8e8e78fb04f008b1a9091
*
* We fix this leak by invoking MediaSessionLegacyHelper.getHelper() early in the app lifecycle.
*/
MEDIA_SESSION_LEGACY_HELPER {
override fun apply(application: Application) {
// This fix was only needed for API 21, minimum SDK is now 26+
return
backgroundHandler.post {
try {
val clazz = Class.forName("android.media.session.MediaSessionLegacyHelper")
val getHelperMethod = clazz.getDeclaredMethod("getHelper", Context::class.java)
getHelperMethod.invoke(null, application)
} catch (ignored: Exception) {
SharkLog.d(ignored) { "Could not fix the $name leak" }
}
}
}
},
/**
* This flushes the TextLine pool when an activity is destroyed, to prevent memory leaks.
*
@@ -230,31 +200,6 @@ enum class AndroidLeakFixes {
}
},
/**
* ConnectivityManager has a sInstance field that is set when the first ConnectivityManager instance is created.
* ConnectivityManager has a mContext field.
* When calling activity.getSystemService(Context.CONNECTIVITY_SERVICE) , the first ConnectivityManager instance
* is created with the activity context and stored in sInstance.
* That activity context then leaks forever.
*
* This fix makes sure the connectivity manager is created with the application context.
*
* Tracked here: https://code.google.com/p/android/issues/detail?id=198852
* Introduced here: https://github.com/android/platform_frameworks_base/commit/e0bef71662d81caaaa0d7214fb0bef5d39996a69
*/
CONNECTIVITY_MANAGER {
override fun apply(application: Application) {
// This fix was only needed for API ≤23, minimum SDK is now 26+
return
try {
application.getSystemService(Context.CONNECTIVITY_SERVICE)
} catch (ignored: Exception) {
SharkLog.d(ignored) { "Could not fix the $name leak" }
}
}
},
/**
* ClipboardUIManager is a static singleton that leaks an activity context.
* This fix makes sure the manager is called with an application context.
@@ -341,48 +286,6 @@ enum class AndroidLeakFixes {
}
},
/**
* Samsung added a static mContext field to ActivityManager, holding a reference to the activity.
*
* This fix clears the field when an activity is destroyed if it refers to this specific activity.
*
* Observed here: https://github.com/square/leakcanary/issues/177
*/
ACTIVITY_MANAGER {
override fun apply(application: Application) {
// This fix was only needed for Samsung API 22, minimum SDK is now 26+
return
backgroundHandler.post {
val contextField: Field
try {
contextField = application
.getSystemService(Context.ACTIVITY_SERVICE)
.javaClass
.getDeclaredField("mContext")
contextField.isAccessible = true
if ((contextField.modifiers or Modifier.STATIC) != contextField.modifiers) {
SharkLog.d { "Could not fix the $name leak, contextField=$contextField" }
return@post
}
} catch (ignored: Exception) {
SharkLog.d(ignored) { "Could not fix the $name leak" }
return@post
}
application.onActivityDestroyed { activity ->
try {
if (contextField.get(null) == activity) {
contextField.set(null, null)
}
} catch (ignored: Exception) {
SharkLog.d(ignored) { "Could not fix the $name leak" }
}
}
}
}
},
/**
* In Android P, ViewLocationHolder has an mRoot field that is not cleared in its clear() method.
* Introduced in https://github.com/aosp-mirror/platform_frameworks_base/commit/86b326012813f09d8f1de7d6d26c986a909d
@@ -403,64 +306,6 @@ enum class AndroidLeakFixes {
}
},
/**
* Fix for https://code.google.com/p/android/issues/detail?id=171190 .
*
* When a view that has focus gets detached, we wait for the main thread to be idle and then
* check if the InputMethodManager is leaking a view. If yes, we tell it that the decor view got
* focus, which is what happens if you press home and come back from recent apps. This replaces
* the reference to the detached view with a reference to the decor view.
*/
IMM_FOCUSED_VIEW {
@SuppressLint("PrivateApi")
override fun apply(application: Application) {
// This fix was only needed for API ≤23, minimum SDK is now 26+
return
val inputMethodManager =
application.getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
val mServedViewField: Field
val mHField: Field
val finishInputLockedMethod: Method
val focusInMethod: Method
try {
mServedViewField =
InputMethodManager::class.java.getDeclaredField("mServedView")
mServedViewField.isAccessible = true
mHField = InputMethodManager::class.java.getDeclaredField("mH")
mHField.isAccessible = true
finishInputLockedMethod =
InputMethodManager::class.java.getDeclaredMethod("finishInputLocked")
finishInputLockedMethod.isAccessible = true
focusInMethod = InputMethodManager::class.java.getDeclaredMethod(
"focusIn", View::class.java
)
focusInMethod.isAccessible = true
} catch (ignored: Exception) {
SharkLog.d(ignored) { "Could not fix the $name leak" }
return
}
application.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks
by noOpDelegate() {
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?
) {
activity.window.onDecorViewReady {
val cleaner = ReferenceCleaner(
inputMethodManager,
mHField,
mServedViewField,
finishInputLockedMethod
)
val rootView = activity.window.decorView.rootView
val viewTreeObserver = rootView.viewTreeObserver
viewTreeObserver.addOnGlobalFocusChangeListener(cleaner)
}
}
})
}
},
/**
* When an activity is destroyed, the corresponding ViewRootImpl instance is released and ready to
* be garbage collected.
@@ -551,153 +396,6 @@ enum class AndroidLeakFixes {
}
},
/**
* Every editable TextView has an Editor instance which has a SpellChecker instance. SpellChecker
* is in charge of displaying the little squiggle spans that show typos. SpellChecker starts a
* SpellCheckerSession as needed and then closes it when the TextView is detached from the window.
* A SpellCheckerSession is in charge of communicating with the spell checker service (which lives
* in another process) through TextServicesManager.
*
* The SpellChecker sends the TextView content to the spell checker service every 400ms, ie every
* time the service calls back with a result the SpellChecker schedules another check for 400ms
* later.
*
* When the TextView is detached from the window, the spell checker closes the session. In practice,
* SpellCheckerSessionListenerImpl.mHandler is set to null and when the service calls
* SpellCheckerSessionListenerImpl.onGetSuggestions or
* SpellCheckerSessionListenerImpl.onGetSentenceSuggestions back from another process, there's a
* null check for SpellCheckerSessionListenerImpl.mHandler and the callback is dropped.
*
* Unfortunately, on Android M there's a race condition in how that's done. When the service calls
* back into our app process, the IPC call is received on a binder thread. That's when the null
* check happens. If the session is not closed at this point (mHandler not null), the callback is
* then posted to the main thread. If on the main thread the session is closed after that post but
* prior to that post being handled, then the post will still be processed, after the session has
* been closed.
*
* When the post is processed, SpellCheckerSession calls back into SpellChecker which in turns
* schedules a new spell check to be ran in 400ms. The check is an anonymous inner class
* (SpellChecker$1) stored as SpellChecker.mSpellRunnable and implementing Runnable. It is scheduled
* by calling [View.postDelayed]. As we've seen, at this point the session may be closed which means
* that the view has been detached. [View.postDelayed] behaves differently when a view is detached:
* instead of posting to the single [Handler] used by the view hierarchy, it enqueues the Runnable
* into ViewRootImpl.RunQueue, a static queue that holds on to "actions" to be executed. As soon as
* a view hierarchy is attached, the ViewRootImpl.RunQueue is processed and emptied.
*
* Unfortunately, that means that as long as no view hierarchy is attached, ie as long as there
* are no activities alive, the actions stay in ViewRootImpl.RunQueue. That means SpellChecker$1
* ends up being kept in memory. It holds on to SpellChecker which in turns holds on
* to the detached TextView and corresponding destroyed activity & view hierarchy.
*
* We have a fix for this! When the spell check session is closed, we replace
* SpellCheckerSession.mSpellCheckerSessionListener (which normally is the SpellChecker) with a
* no-op implementation. So even if callbacks are enqueued to the main thread handler, these
* callbacks will call the no-op implementation and SpellChecker will not be scheduling a spell
* check.
*
* Sources to corroborate:
*
* https://android.googlesource.com/platform/frameworks/base/+/marshmallow-release/core/java/android/view/textservice/SpellCheckerSession.java
* https://android.googlesource.com/platform/frameworks/base/+/marshmallow-release/core/java/android/view/textservice/TextServicesManager.java
* https://android.googlesource.com/platform/frameworks/base/+/marshmallow-release/core/java/android/widget/SpellChecker.java
* https://android.googlesource.com/platform/frameworks/base/+/marshmallow-release/core/java/android/view/ViewRootImpl.java
*/
SPELL_CHECKER {
@SuppressLint("PrivateApi")
override fun apply(application: Application) {
// This fix was only needed for API 23, minimum SDK is now 26+
return
try {
val textServiceClass = TextServicesManager::class.java
val getInstanceMethod = textServiceClass.getDeclaredMethod("getInstance")
val sServiceField = textServiceClass.getDeclaredField("sService")
sServiceField.isAccessible = true
val serviceStubInterface =
Class.forName("com.android.internal.textservice.ITextServicesManager")
val spellCheckSessionClass = Class.forName("android.view.textservice.SpellCheckerSession")
val mSpellCheckerSessionListenerField =
spellCheckSessionClass.getDeclaredField("mSpellCheckerSessionListener")
mSpellCheckerSessionListenerField.isAccessible = true
val spellCheckerSessionListenerImplClass =
Class.forName(
"android.view.textservice.SpellCheckerSession\$SpellCheckerSessionListenerImpl"
)
val listenerImplHandlerField =
spellCheckerSessionListenerImplClass.getDeclaredField("mHandler")
listenerImplHandlerField.isAccessible = true
val spellCheckSessionHandlerClass =
Class.forName("android.view.textservice.SpellCheckerSession\$1")
val outerInstanceField = spellCheckSessionHandlerClass.getDeclaredField("this$0")
outerInstanceField.isAccessible = true
val listenerInterface =
Class.forName("android.view.textservice.SpellCheckerSession\$SpellCheckerSessionListener")
val noOpListener = Proxy.newProxyInstance(
listenerInterface.classLoader, arrayOf(listenerInterface)
) { _: Any, _: Method, _: kotlin.Array<Any>? ->
SharkLog.d { "Received call to no-op SpellCheckerSessionListener after session closed" }
}
// Ensure a TextServicesManager instance is created and TextServicesManager.sService set.
getInstanceMethod
.invoke(null)
val realService = sServiceField[null]!!
val spellCheckerListenerToSession = mutableMapOf<Any, Any>()
val proxyService = Proxy.newProxyInstance(
serviceStubInterface.classLoader, arrayOf(serviceStubInterface)
) { _: Any, method: Method, args: kotlin.Array<Any>? ->
try {
if (method.name == "getSpellCheckerService") {
// getSpellCheckerService is called when the session is opened, which allows us to
// capture the corresponding SpellCheckerSession instance via
// SpellCheckerSessionListenerImpl.mHandler.this$0
val spellCheckerSessionListener = args!![3]
val handler = listenerImplHandlerField[spellCheckerSessionListener]!!
val spellCheckerSession = outerInstanceField[handler]!!
// We add to a map of SpellCheckerSessionListenerImpl to SpellCheckerSession
spellCheckerListenerToSession[spellCheckerSessionListener] = spellCheckerSession
} else if (method.name == "finishSpellCheckerService") {
// finishSpellCheckerService is called when the session is open. After the session has been
// closed, any pending work posted to SpellCheckerSession.mHandler should be ignored. We do
// so by replacing mSpellCheckerSessionListener with a no-op implementation.
val spellCheckerSessionListener = args!![0]
val spellCheckerSession =
spellCheckerListenerToSession.remove(spellCheckerSessionListener)!!
// We use the SpellCheckerSessionListenerImpl to find the corresponding SpellCheckerSession
// At this point in time the session was just closed to
// SpellCheckerSessionListenerImpl.mHandler is null, which is why we had to capture
// the SpellCheckerSession during the getSpellCheckerService call.
mSpellCheckerSessionListenerField[spellCheckerSession] = noOpListener
}
} catch (ignored: Exception) {
SharkLog.d(ignored) { "Unable to fix SpellChecker leak" }
}
// Standard delegation
try {
return@newProxyInstance if (args != null) {
method.invoke(realService, *args)
} else {
method.invoke(realService)
}
} catch (invocationException: InvocationTargetException) {
throw invocationException.targetException
}
}
sServiceField[null] = proxyService
} catch (ignored: Exception) {
SharkLog.d(ignored) { "Unable to fix SpellChecker leak" }
}
}
},
/**
* PermissionControllerManager stores the first context it's initialized with forever.
* Sometimes it's an Activity context which then leaks after Activity is destroyed.