Merge pull request #2806 from blundell/update-min-sdk-to-api-21

Update minimum SDK to API 26 (Android 8.0 Oreo)
This commit is contained in:
py - Pierre Yves Ricau
2026-03-05 05:13:05 -06:00
committed by GitHub
39 changed files with 69 additions and 606 deletions

View File

@@ -36,21 +36,10 @@ jobs:
target: [ google_apis ]
channel: [ stable ]
api-level:
- 21
- 23
- 26
- 31
- 33
- 34
include:
- arch: x86
api-level: 16
target: google_apis
channel: stable
- arch: x86
api-level: 19
target: google_apis
channel: stable
steps:
- name: Enable KVM group perms
run: |

View File

@@ -23,7 +23,7 @@ androidXJunit = "1.1.5"
workManager = "2.7.0"
detekt = "1.23.8"
hilt = "2.53"
androidMinSdk = "14"
androidMinSdk = "26"
androidCompileSdk = "35"
[libraries]
@@ -60,7 +60,7 @@ androidX-test-monitor = { module = "androidx.test:monitor", version = "1.6.1" }
androidX-test-rules = { module = "androidx.test:rules", version.ref = "androidXTest" }
# Exposed transitively, avoid increasing
androidX-test-runner = { module = "androidx.test:runner", version = "1.5.2" }
androidX-test-orchestrator = { module = "androidx.test:orchestrator", version = "1.4.2" } # 1.5.0+ requires API 21+, keeping at 1.4.2 for API 16 compatibility
androidX-test-orchestrator = { module = "androidx.test:orchestrator", version = "1.5.0" }
androidX-test-espresso = { module = "androidx.test.espresso:espresso-core", version = "3.5.1" }
androidX-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidXJunit" }
androidX-test-junitKtx = { module = "androidx.test.ext:junit-ktx", version.ref = "androidXJunit" }

View File

@@ -7,8 +7,6 @@ import android.content.Intent
import android.content.pm.ShortcutInfo.Builder
import android.content.pm.ShortcutManager
import android.graphics.drawable.Icon
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import com.squareup.leakcanary.core.R
import shark.SharkLog
@@ -18,9 +16,6 @@ internal object LeakCanaryAndroidInternalUtils {
@Suppress("ReturnCount")
fun addLeakActivityDynamicShortcut(application: Application) {
if (VERSION.SDK_INT < VERSION_CODES.N_MR1) {
return
}
if (!application.resources.getBoolean(R.bool.leak_canary_add_dynamic_shortcut)) {
return
}
@@ -124,6 +119,6 @@ internal object LeakCanaryAndroidInternalUtils {
}
fun isInstantApp(application: Application): Boolean {
return VERSION.SDK_INT >= VERSION_CODES.O && application.packageManager.isInstantApp
return application.packageManager.isInstantApp
}
}

View File

@@ -4,7 +4,6 @@ import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import com.squareup.leakcanary.core.R
import leakcanary.EventListener.Event
import leakcanary.EventListener.Event.DumpingHeap
@@ -67,11 +66,7 @@ object NotificationEventListener : EventListener {
} else {
appContext.getString(R.string.leak_canary_analysis_failed)
}
val flags = if (Build.VERSION.SDK_INT >= 23) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
val pendingIntent = PendingIntent.getActivity(appContext, 1, event.showIntent, flags)
showHeapAnalysisResultNotification(contentTitle,pendingIntent)
}

View File

@@ -24,7 +24,6 @@ import android.content.pm.ProviderInfo
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.os.StrictMode
@@ -499,7 +498,7 @@ internal class LeakCanaryFileProvider : ContentProvider() {
if (externalCacheDirs.isNotEmpty()) {
target = externalCacheDirs[0]
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && TAG_EXTERNAL_MEDIA == tag) {
} else if (TAG_EXTERNAL_MEDIA == tag) {
val externalMediaDirs = context.externalMediaDirs
if (externalMediaDirs.isNotEmpty()) {
target = externalMediaDirs[0]
@@ -519,19 +518,11 @@ internal class LeakCanaryFileProvider : ContentProvider() {
context: Context,
type: String?
): Array<File> {
return if (Build.VERSION.SDK_INT >= 19) {
context.getExternalFilesDirs(type)
} else {
arrayOf(context.getExternalFilesDir(type)!!)
}
return context.getExternalFilesDirs(type)
}
private fun getExternalCacheDirs(context: Context): Array<File> {
return if (Build.VERSION.SDK_INT >= 19) {
context.externalCacheDirs
} else {
arrayOf(context.externalCacheDir!!)
}
return context.externalCacheDirs
}
/**

View File

@@ -16,11 +16,8 @@
package leakcanary.internal
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.annotation.TargetApi
import android.content.Context
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.M
import android.os.Environment
import android.os.Environment.DIRECTORY_DOWNLOADS
import com.squareup.leakcanary.core.R
@@ -79,12 +76,7 @@ internal class LeakDirectoryProvider constructor(
return File(storageDirectory, fileName)
}
@TargetApi(M) fun hasStoragePermission(): Boolean {
// Defensive check: @TargetApi doesn't prevent this method from being called on older APIs
@Suppress("ObsoleteSdkInt")
if (SDK_INT < M) {
return true
}
fun hasStoragePermission(): Boolean {
// Once true, this won't change for the life of the process so we can cache it.
if (writeExternalStorageGranted) {
return true

View File

@@ -4,7 +4,6 @@ import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import leakcanary.internal.NotificationReceiver.Action.CANCEL_NOTIFICATION
import leakcanary.internal.NotificationReceiver.Action.DUMP_HEAP
import shark.SharkLog
@@ -40,11 +39,7 @@ internal class NotificationReceiver : BroadcastReceiver() {
): PendingIntent {
val broadcastIntent = Intent(context, NotificationReceiver::class.java)
broadcastIntent.action = action.name
val flags = if (Build.VERSION.SDK_INT >= 23) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
val flags = PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getBroadcast(context, 0, broadcastIntent, flags)
}
}

View File

@@ -22,8 +22,6 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build.VERSION.SDK_INT
import android.os.Build.VERSION_CODES.JELLY_BEAN
import android.os.Build.VERSION_CODES.O
import com.squareup.leakcanary.core.R
import leakcanary.LeakCanary
import leakcanary.internal.InternalLeakCanary.FormFactor.MOBILE
@@ -90,9 +88,7 @@ internal object Notifications {
return
}
val builder = if (SDK_INT >= O) {
Notification.Builder(context, type.name)
} else Notification.Builder(context)
val builder = Notification.Builder(context, type.name)
builder
.setContentText(contentText)
@@ -115,26 +111,19 @@ internal object Notifications {
builder.setSmallIcon(R.drawable.leak_canary_leak)
.setWhen(System.currentTimeMillis())
if (SDK_INT >= O) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
var notificationChannel: NotificationChannel? =
notificationManager.getNotificationChannel(type.name)
if (notificationChannel == null) {
val channelName = context.getString(type.nameResId)
notificationChannel =
NotificationChannel(type.name, channelName, type.importance)
notificationManager.createNotificationChannel(notificationChannel)
}
builder.setChannelId(type.name)
builder.setGroup(type.name)
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
var notificationChannel: NotificationChannel? =
notificationManager.getNotificationChannel(type.name)
if (notificationChannel == null) {
val channelName = context.getString(type.nameResId)
notificationChannel =
NotificationChannel(type.name, channelName, type.importance)
notificationManager.createNotificationChannel(notificationChannel)
}
builder.setChannelId(type.name)
builder.setGroup(type.name)
return if (SDK_INT < JELLY_BEAN) {
@Suppress("DEPRECATION")
builder.notification
} else {
builder.build()
}
return builder.build()
}
}

View File

@@ -15,7 +15,6 @@
*/
package leakcanary.internal
import android.annotation.TargetApi
import android.app.Activity
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
@@ -26,12 +25,10 @@ import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.os.Bundle
import android.os.Build
import android.widget.Toast
import android.widget.Toast.LENGTH_LONG
import com.squareup.leakcanary.core.R
@TargetApi(Build.VERSION_CODES.M) //
internal class RequestPermissionActivity : Activity() {
private val targetPermission: String
@@ -84,13 +81,7 @@ internal class RequestPermissionActivity : Activity() {
fun createPendingIntent(context: Context, permission: String): PendingIntent {
val intent = createIntent(context, permission)
// Defensive check: @TargetApi on class doesn't prevent this method from being called on older APIs
@Suppress("ObsoleteSdkInt")
val flags = if (Build.VERSION.SDK_INT >= 23) {
FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
} else {
FLAG_UPDATE_CURRENT
}
val flags = FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE
return PendingIntent.getActivity(context, 1, intent, flags)
}
}

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Path
import android.os.Build
import android.text.Layout
import com.squareup.leakcanary.core.R
import kotlin.math.max
@@ -107,7 +106,7 @@ internal abstract class SquigglySpanRenderer(context: Context) {
private fun Layout.getLineBottomWithoutSpacing(line: Int): Int {
val lineBottom = getLineBottom(line)
val lastLineSpacingNotAdded = Build.VERSION.SDK_INT >= 19
val lastLineSpacingNotAdded = true
val isLastLine = line == lineCount - 1
val lineBottomWithoutSpacing: Int

View File

@@ -6,7 +6,7 @@ import kotlin.concurrent.getOrSet
/**
* Similar to the more generic use() for Closable.
* Cursor started implementing Closable in API 16.
* Cursor implements Closable on all supported API levels (26+).
*/
internal inline fun <R> Cursor.use(block: (Cursor) -> R): R {
var exception: Throwable? = null

View File

@@ -2,8 +2,6 @@ package leakcanary.internal.activity.screen
import android.content.Intent
import android.graphics.Bitmap
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.os.Environment
import android.os.Environment.DIRECTORY_DOWNLOADS
import android.view.View
@@ -60,11 +58,7 @@ internal class RenderHeapDumpScreen(
imageView.visibility = View.VISIBLE
}
}
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN) {
viewTreeObserver.removeOnGlobalLayoutListener(this)
} else {
viewTreeObserver.removeGlobalOnLayoutListener(this)
}
viewTreeObserver.removeOnGlobalLayoutListener(this)
}
})

View File

@@ -2,7 +2,6 @@ package leakcanary.internal.navigation
import android.app.Activity
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.os.Parcelable
import android.view.Menu
@@ -155,12 +154,10 @@ internal abstract class NavigatingActivity : Activity() {
private fun screenUpdated() {
invalidateOptionsMenu()
if (SDK_INT >= 18) {
actionBar?.run {
val goBack = backstack.size > 0
val indicator = if (goBack) 0 else android.R.drawable.ic_menu_close_clear_cancel
setHomeAsUpIndicator(indicator)
}
actionBar?.run {
val goBack = backstack.size > 0
val indicator = if (goBack) 0 else android.R.drawable.ic_menu_close_clear_cancel
setHomeAsUpIndicator(indicator)
}
onNewScreen(currentScreen)
}

View File

@@ -2,7 +2,6 @@ package leakcanary.internal.navigation
import android.app.Activity
import android.content.Context
import android.os.Build.VERSION
import android.os.Parcelable
import android.util.SparseArray
import android.view.LayoutInflater
@@ -41,11 +40,7 @@ internal fun View.goBack() {
}
internal fun Context.getColorCompat(id: Int): Int {
return if (VERSION.SDK_INT >= 23) {
getColor(id)
} else {
resources.getColor(id)
}
return getColor(id)
}
internal fun View.onScreenExiting(block: () -> Unit) {

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:attr/colorControlHighlight">
<item android:id="@android:id/mask">
<shape>
<solid android:color="@color/leak_canary_gray" />
<corners android:radius="20dp" />
</shape>
</item>
<item>
<shape>
<solid android:color="@color/leak_canary_gray" />
<corners android:radius="20dp" />
</shape>
</item>
</ripple>

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:state_pressed="false">
<color android:color="?android:attr/colorControlHighlight" />
</item>
<item>
<color android:color="@android:color/transparent" />
</item>
</selector>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/leak_canary_yellow_button_pressed">
<item>
<shape>
<solid android:color="@color/leak_canary_yellow_button" />
<corners android:radius="12dp" />
</shape>
</item>
<item android:id="@android:id/mask">
<shape>
<solid android:color="@color/leak_canary_yellow_button" />
<corners android:radius="12dp" />
</shape>
</item>
</ripple>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/leak_canary_gray_6f">
<item>
<shape>
<solid android:color="@color/leak_canary_gray_3f" />
<corners android:radius="12dp" />
</shape>
</item>
<item android:id="@android:id/mask">
<shape>
<solid android:color="@color/leak_canary_gray_3f" />
<corners android:radius="12dp" />
</shape>
</item>
</ripple>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/leak_canary_tab_selector_ripple" />
</selector>

View File

@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="?android:attr/colorControlHighlight">
<item android:id="@android:id/mask">
<shape>
<solid android:color="@color/leak_canary_gray_3f" />
<solid android:color="@color/leak_canary_gray" />
<corners android:radius="20dp" />
</shape>
</item>
@@ -12,4 +13,4 @@
<corners android:radius="20dp" />
</shape>
</item>
</selector>
</ripple>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:state_pressed="false">
<color android:color="@color/leak_canary_gray_3f" />
<color android:color="?android:attr/colorControlHighlight" />
</item>
<item>
<color android:color="@android:color/transparent" />

View File

@@ -1,16 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="@color/leak_canary_yellow_button_pressed" />
<corners android:radius="12dp" />
</shape>
</item>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/leak_canary_yellow_button_pressed">
<item>
<shape>
<solid android:color="@color/leak_canary_yellow_button" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>
</ripple>

View File

@@ -1,15 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape>
<solid android:color="@color/leak_canary_gray_6f" />
<corners android:radius="12dp" />
</shape>
</item>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/leak_canary_gray_6f">
<item>
<shape>
<solid android:color="@color/leak_canary_gray_3f" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>
</ripple>

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="false" android:drawable="@android:color/transparent" />
<item android:drawable="@color/leak_canary_gray_darkest_25p" />
</selector>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/leak_canary_gray_darkest_25p">
<item android:drawable="@android:color/transparent" />
</ripple>

View File

@@ -2,4 +2,4 @@
<ripple
xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/leak_canary_gray_darkest_25p"
/>
/>

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2015 Square, Inc.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<resources>
<style name="leak_canary_LeakCanary.Base" parent="android:Theme.Material">
<item name="android:windowBackground">@color/leak_canary_background_color</item>
<item name="android:actionBarStyle">@style/leak_canary_Widget.ActionBar</item>
</style>
<style name="leak_canary_Widget.ActionBar" parent="android:Widget.Material.ActionBar">
<item name="android:background">@color/leak_canary_background_color</item>
<item name="android:elevation">0dp</item>
</style>
</resources>

View File

@@ -15,14 +15,14 @@
~ limitations under the License.
-->
<resources>
<style name="leak_canary_LeakCanary.Base" parent="android:Theme.Holo">
<style name="leak_canary_LeakCanary.Base" parent="android:Theme.Material">
<item name="android:windowBackground">@color/leak_canary_background_color</item>
<item name="android:actionBarStyle">@style/leak_canary_Widget.ActionBar</item>
</style>
<style name="leak_canary_Widget.ActionBar" parent="android:Widget.Holo.ActionBar">
<style name="leak_canary_Widget.ActionBar" parent="android:Widget.Material.ActionBar">
<item name="android:background">@color/leak_canary_background_color</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:elevation">0dp</item>
</style>
<style name="leak_canary_Theme.Transparent" parent="android:Theme">

View File

@@ -64,19 +64,16 @@ class TestDescriptionHolderTest {
@Test
fun testDescriptionThrowsBeforeClass() {
// On API 16, assertThat(Throwable) causes a TypeComparators class init failure.
assertThat(beforeClassThrowable == null).isFalse()
assertThat(beforeClassThrowable).isNotNull()
}
@Test
fun testDescriptionThrowsBeforeOuterEvaluate() {
// On API 16, assertThat(Throwable) causes a TypeComparators class init failure.
assertThat(outerRule.beforeEvaluateThrowable == null).isFalse()
assertThat(outerRule.beforeEvaluateThrowable).isNotNull()
}
@Test
fun testDescriptionDoesNotThrowBeforeInnerEvaluate() {
// On API 16, assertThat(Throwable) causes a TypeComparators class init failure.
assertThat(innerRule.beforeEvaluateThrowable == null).isTrue()
assertThat(innerRule.beforeEvaluateThrowable).isNull()
}
}

View File

@@ -23,7 +23,7 @@ android {
resourcePrefix = "leak_canary_"
compileSdk = libs.versions.androidCompileSdk.get().toInt()
defaultConfig {
minSdk = 16
minSdk = libs.versions.androidMinSdk.get().toInt()
buildConfigField("String", "LIBRARY_VERSION", "\"${rootProject.property("VERSION_NAME")}\"")
buildConfigField("String", "GIT_SHA", "\"${gitSha()}\"")
consumerProguardFiles("consumer-proguard-rules.pro")

View File

@@ -5,7 +5,6 @@ import android.app.ActivityManager
import android.app.ActivityManager.MemoryInfo
import android.app.ActivityManager.RunningAppProcessInfo
import android.content.Context
import android.os.Build.VERSION.SDK_INT
import android.os.Process
import android.os.SystemClock
import android.system.Os
@@ -52,11 +51,7 @@ interface ProcessInfo {
}
override val elapsedMillisSinceStart: Long
get() = if (SDK_INT >= 24) {
SystemClock.uptimeMillis() - processStartUptimeMillis
} else {
SystemClock.elapsedRealtime() - processForkRealtimeMillis
}
get() = SystemClock.uptimeMillis() - processStartUptimeMillis
@SuppressLint("UsableSpace")
override fun availableDiskSpaceBytes(path: File) = path.usableSpace
@@ -64,7 +59,7 @@ interface ProcessInfo {
override fun availableRam(context: Context): AvailableRam {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
if (SDK_INT >= 19 && activityManager.isLowRamDevice) {
if (activityManager.isLowRamDevice) {
return LowRamDevice
} else {
activityManager.getMemoryInfo(memoryInfo)
@@ -91,17 +86,7 @@ interface ProcessInfo {
val myPid = Process.myPid()
val ticksAtProcessStart = readProcessStartTicks(myPid)
val ticksPerSecond = if (SDK_INT >= 21) {
Os.sysconf(OsConstants._SC_CLK_TCK)
} else {
val tckConstant = try {
Class.forName("android.system.OsConstants").getField("_SC_CLK_TCK").getInt(null)
} catch (e: ClassNotFoundException) {
Class.forName("libcore.io.OsConstants").getField("_SC_CLK_TCK").getInt(null)
}
val os = Class.forName("libcore.io.Libcore").getField("os").get(null)!!
os::class.java.getMethod("sysconf", Integer.TYPE).invoke(os, tckConstant) as Long
}
val ticksPerSecond = Os.sysconf(OsConstants._SC_CLK_TCK)
return ticksAtProcessStart * 1000 / ticksPerSecond
}

View File

@@ -17,7 +17,7 @@ android {
compileSdk = libs.versions.androidCompileSdk.get().toInt()
defaultConfig {
targetSdk = libs.versions.androidCompileSdk.get().toInt()
minSdk = 18
minSdk = libs.versions.androidMinSdk.get().toInt()
}
buildFeatures.buildConfig = false
namespace = "com.squareup.leakcanary.android.uiautomator"

View File

@@ -32,22 +32,13 @@ class UiAutomatorShellHeapDumper(
// Based on https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:benchmark/benchmark-common/src/main/java/androidx/benchmark/Shell.kt;l=467;drc=8f2ba6a5469f67b7e385878d704f97bde22419ce
private fun UiDevice.getPidsForProcess(processName: String): List<Int> {
if (Build.VERSION.SDK_INT >= 23) {
return pgrepLF(pattern = processName)
.mapNotNull { (pid, fullProcessName) ->
if (fullProcessNameMatchesProcess(fullProcessName, processName)) {
pid
} else {
null
}
return pgrepLF(pattern = processName)
.mapNotNull { (pid, fullProcessName) ->
if (fullProcessNameMatchesProcess(fullProcessName, processName)) {
pid
} else {
null
}
}
val processList = executeShellCommand("ps")
return processList.lines()
.filter { psLineContainsProcess(it, processName) }
.map {
val columns = SPACE_PATTERN.split(it)
columns[1].toInt()
}
}
@@ -61,23 +52,12 @@ class UiAutomatorShellHeapDumper(
}
}
private fun psLineContainsProcess(
psOutputLine: String,
processName: String
): Boolean {
return psOutputLine.endsWith(" $processName") || psOutputLine.endsWith("/$processName")
}
private fun fullProcessNameMatchesProcess(
fullProcessName: String,
processName: String
): Boolean {
return fullProcessName == processName || fullProcessName.endsWith("/$processName")
}
private companion object {
private val SPACE_PATTERN = Regex("\\s+")
}
}
fun HeapDumper.Companion.forUiAutomatorAsShell(

View File

@@ -20,8 +20,8 @@ android {
defaultConfig {
applicationId = "org.leakcanary"
// 21 required by Compose
minSdk = 21
// 26 minimum SDK for modern Android features
minSdk = 26
targetSdk = libs.versions.androidCompileSdk.get().toInt()
buildConfigField("String", "GIT_SHA", "\"${gitSha()}\"")

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

@@ -1,7 +1,6 @@
package leakcanary
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.app.Activity
import android.app.Application
import android.content.Context
@@ -9,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
@@ -18,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
@@ -40,30 +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) {
if (SDK_INT != 21) {
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.
*
@@ -232,32 +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) {
if (SDK_INT > 23) {
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.
@@ -344,49 +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) {
if (MANUFACTURER != SAMSUNG || SDK_INT != 22) {
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
@@ -407,69 +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 {
// mServedView should not be accessed on API 29+. Make this clear to Lint with the
// TargetApi annotation.
@TargetApi(23)
@SuppressLint("PrivateApi")
override fun apply(application: Application) {
// Fixed in API 24.
if (SDK_INT > 23) {
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.
@@ -560,155 +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 {
@TargetApi(23)
@SuppressLint("PrivateApi")
override fun apply(application: Application) {
if (SDK_INT != 23) {
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.

View File

@@ -105,7 +105,6 @@ class FixedWindowCallback implements Window.Callback {
return delegate.onSearchRequested();
}
@RequiresApi(23)
@Override public boolean onSearchRequested(SearchEvent searchEvent) {
return delegate.onSearchRequested(searchEvent);
}
@@ -114,7 +113,7 @@ class FixedWindowCallback implements Window.Callback {
return delegate.onWindowStartingActionMode(callback);
}
@RequiresApi(23) @Nullable @Override
@Nullable @Override
public ActionMode onWindowStartingActionMode(ActionMode.Callback callback,
int type) {
return delegate.onWindowStartingActionMode(callback, type);
@@ -128,13 +127,11 @@ class FixedWindowCallback implements Window.Callback {
delegate.onActionModeFinished(mode);
}
@RequiresApi(24)
@Override public void onProvideKeyboardShortcuts(List<KeyboardShortcutGroup> data,
@Nullable Menu menu, int deviceId) {
delegate.onProvideKeyboardShortcuts(data, menu, deviceId);
}
@RequiresApi(26)
@Override public void onPointerCaptureChanged(boolean hasCapture) {
delegate.onPointerCaptureChanged(hasCapture);
}

View File

@@ -49,7 +49,7 @@ android {
defaultConfig {
applicationId = "com.example.leakcanary"
minSdk = 16
minSdk = libs.versions.androidMinSdk.get().toInt()
targetSdk = libs.versions.androidCompileSdk.get().toInt()
versionCode = 1