Update minimum SDK to API 21 (Android 5.0 Lollipop)

This commit migrates the entire LeakCanary project from minimum SDK API 14
to API 21, enabling modern Android features and simplifying the codebase.

API 21 corresponds to Android 5.0 (Lollipop), released in 2014, providing
over a decade of Android compatibility while enabling Material Design and
modern development practices.

Changes included:

## Build Configuration
- Update androidMinSdk from "14" to "21" in version catalog
- Standardize all modules to use libs.versions.androidMinSdk reference
- Update hardcoded minSdk values in sample and library modules to use TOML

## GitHub Actions / CI
- Remove API 16 and API 19 from test matrix
- Streamline CI to test only API 21+ (21, 23, 26, 31, 33, 34)
- Improve CI efficiency with fewer test combinations

## Test Infrastructure
- Upgrade androidx.test:orchestrator from 1.4.2 to 1.5.0
- Modernize test assertions to use direct assertThat(Throwable) calls
- Remove API 16 workaround comments and code

## Code Modernization
- Remove obsolete SDK version checks (9 lint issues resolved)
- Simplify file provider methods for API 21+ only
- Update notification builder usage for modern APIs
- Remove legacy view tree observer workarounds
- Modernize layout and rendering code

## Resources & UI
- Migrate drawable resources from selectors to Material ripple effects
- Update themes from Holo to Material Design (API 21+ requirement)
- Merge and remove obsolete -v21 resource folders
- Modernize button and touch feedback using ripples

## Benefits
- Access to Material Design components and APIs
- Simplified codebase with removed legacy compatibility code
- Enhanced test infrastructure with latest orchestrator
- Improved user experience with modern UI patterns
- Faster CI builds with fewer test matrix combinations

## Compatibility
All modules now consistently require Android 5.0 (API 21, 2014) or higher.
This affects minSdk but does not change the public API surface.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
pblundell
2026-02-27 00:01:04 +00:00
parent a6d2b6b935
commit c1b9efe482
26 changed files with 41 additions and 188 deletions

View File

@@ -42,15 +42,6 @@ jobs:
- 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 = "21"
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

@@ -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

@@ -22,7 +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
@@ -130,11 +129,6 @@ internal object Notifications {
builder.setGroup(type.name)
}
return if (SDK_INT < JELLY_BEAN) {
@Suppress("DEPRECATION")
builder.notification
} else {
builder.build()
}
return builder.build()
}
}

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 (21+).
*/
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

@@ -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

@@ -64,7 +64,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 +91,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

@@ -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