Add PackageInfoCompat signature verification APIs
Create methods for retrieving the Signature[] for a package on device,
as well as verifying that a package has a set of certificates.
Checking by app UID is not supported. Callers must use package name.
This is done to ensure the system doesn't arbitrarily choose a package
to check against.
A matchExact parameter is provided to address compatibility for
un-patched devices that are affected by the certificate reference fake
ID vulnerability. For such devices, all certificates of the package
being checked must be verified.
Bug: 159831205
Test: androidx.core.content.pm.PackageInfoCompatSignaturesTest
Test: androidx.core.content.pm.PackageInfoCompatTest
Relnote: "Added PackageInfoCompat#getSignatures for retrieving the
certificate array for a package"
Relnote: "Added PackageInfoCompat#hasSignatures for verifying package
ceritificates"
Change-Id: I8e9a3ece2d45416abbcbaaa0cf2a0485180997d3
diff --git a/buildSrc/src/main/kotlin/androidx/build/dependencies/Dependencies.kt b/buildSrc/src/main/kotlin/androidx/build/dependencies/Dependencies.kt
index bc2e12d..1798719 100644
--- a/buildSrc/src/main/kotlin/androidx/build/dependencies/Dependencies.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/dependencies/Dependencies.kt
@@ -39,6 +39,7 @@
const val DAGGER = "com.google.dagger:dagger:2.29.1"
const val DAGGER_COMPILER = "com.google.dagger:dagger-compiler:2.29.1"
const val DEXMAKER_MOCKITO = "com.linkedin.dexmaker:dexmaker-mockito:2.25.0"
+const val DEXMAKER_MOCKITO_INLINE = "com.linkedin.dexmaker:dexmaker-mockito-inline:2.25.0"
const val ESPRESSO_CONTRIB = "androidx.test.espresso:espresso-contrib:3.3.0"
const val ESPRESSO_CORE = "androidx.test.espresso:espresso-core:3.3.0"
const val ESPRESSO_INTENTS = "androidx.test.espresso:espresso-intents:3.3.0"
diff --git a/core/core/api/current.txt b/core/core/api/current.txt
index 6acbdb5..83b6f85 100644
--- a/core/core/api/current.txt
+++ b/core/core/api/current.txt
@@ -1033,6 +1033,8 @@
public final class PackageInfoCompat {
method public static long getLongVersionCode(android.content.pm.PackageInfo);
+ method public static java.util.List<android.content.pm.Signature!> getSignatures(android.content.pm.PackageManager, String) throws android.content.pm.PackageManager.NameNotFoundException;
+ method public static boolean hasSignatures(android.content.pm.PackageManager, String, @Size(min=1) java.util.Map<byte[]!,java.lang.Integer!>, boolean) throws android.content.pm.PackageManager.NameNotFoundException;
}
public final class PermissionInfoCompat {
diff --git a/core/core/api/public_plus_experimental_current.txt b/core/core/api/public_plus_experimental_current.txt
index 2341858..95fea5a 100644
--- a/core/core/api/public_plus_experimental_current.txt
+++ b/core/core/api/public_plus_experimental_current.txt
@@ -1033,6 +1033,8 @@
public final class PackageInfoCompat {
method public static long getLongVersionCode(android.content.pm.PackageInfo);
+ method public static java.util.List<android.content.pm.Signature!> getSignatures(android.content.pm.PackageManager, String) throws android.content.pm.PackageManager.NameNotFoundException;
+ method public static boolean hasSignatures(android.content.pm.PackageManager, String, @Size(min=1) java.util.Map<byte[]!,java.lang.Integer!>, boolean) throws android.content.pm.PackageManager.NameNotFoundException;
}
public final class PermissionInfoCompat {
diff --git a/core/core/api/restricted_current.txt b/core/core/api/restricted_current.txt
index 46a404b..ea6ae4d 100644
--- a/core/core/api/restricted_current.txt
+++ b/core/core/api/restricted_current.txt
@@ -1139,6 +1139,8 @@
public final class PackageInfoCompat {
method public static long getLongVersionCode(android.content.pm.PackageInfo);
+ method public static java.util.List<android.content.pm.Signature!> getSignatures(android.content.pm.PackageManager, String) throws android.content.pm.PackageManager.NameNotFoundException;
+ method public static boolean hasSignatures(android.content.pm.PackageManager, String, @Size(min=1) java.util.Map<byte[]!,java.lang.Integer!>, boolean) throws android.content.pm.PackageManager.NameNotFoundException;
}
public final class PermissionInfoCompat {
diff --git a/core/core/build.gradle b/core/core/build.gradle
index 7790a23..eabe679 100644
--- a/core/core/build.gradle
+++ b/core/core/build.gradle
@@ -26,13 +26,19 @@
androidTestImplementation(TRUTH)
androidTestImplementation(ESPRESSO_CORE, libs.exclude_for_espresso)
androidTestImplementation(MOCKITO_CORE, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
+
+ // Including both dexmakers allows support for all API levels plus final mocking support on
+ // API 28+. The implementation is swapped based on the finality of the mock type. This
+ // delegation is handled manually inside androidx.core.util.mockito.CustomMockMaker.
androidTestImplementation(DEXMAKER_MOCKITO, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
+ androidTestImplementation(DEXMAKER_MOCKITO_INLINE, libs.exclude_bytebuddy) // DexMaker has it"s own MockMaker
androidTestImplementation("androidx.appcompat:appcompat:1.1.0") {
exclude group: 'androidx.core', module: 'core'
}
androidTestImplementation project(':internal-testutils-runtime'), {
exclude group: 'androidx.core', module: 'core'
}
+ androidTestImplementation project(':internal-testutils-mockito')
testImplementation(ANDROIDX_TEST_CORE)
testImplementation(ANDROIDX_TEST_RUNNER)
@@ -55,6 +61,14 @@
buildTypes.all {
consumerProguardFiles 'proguard-rules.pro'
}
+
+ packagingOptions {
+ // Drop the file from external dependencies, preferring the local file inside androidTest
+ pickFirsts = [
+ "mockito-extensions/org.mockito.plugins.MockMaker",
+ "mockito-extensions/org.mockito.plugins.StackTraceCleanerProvider"
+ ]
+ }
}
androidx {
diff --git a/core/core/src/androidTest/java/androidx/core/content/pm/PackageInfoCompatHasSignaturesTest.kt b/core/core/src/androidTest/java/androidx/core/content/pm/PackageInfoCompatHasSignaturesTest.kt
new file mode 100644
index 0000000..53f5500
--- /dev/null
+++ b/core/core/src/androidTest/java/androidx/core/content/pm/PackageInfoCompatHasSignaturesTest.kt
@@ -0,0 +1,421 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package androidx.core.content.pm
+
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.Signature
+import android.content.pm.SigningInfo
+import android.os.Build
+import androidx.core.content.pm.PackageInfoCompatHasSignaturesTest.Companion.Params.QueryType
+import androidx.core.content.pm.PackageInfoCompatHasSignaturesTest.MockCerts.MockSignatures
+import androidx.core.content.pm.PackageInfoCompatHasSignaturesTest.MockCerts.MockSigningInfo
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.testutils.mockito.mockThrowOnUnmocked
+import androidx.testutils.mockito.whenever
+import com.google.common.truth.Truth.assertThat
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import org.mockito.Mockito.any
+import org.mockito.Mockito.anyInt
+import org.mockito.Mockito.eq
+import org.mockito.internal.util.reflection.FieldSetter
+import java.security.MessageDigest
+
+/**
+ * Verifies [PackageInfoCompat.hasSignatures].
+ *
+ * Due to testability restrictions with the [SigningInfo] and [Signature] classes and
+ * infrastructure for install test packages in a device test, this test uses mocked classes to
+ * verify the correct method calls. Mocking in general is preferable to signing several test
+ * packages as this isolates the test parameters to inside the test class.
+ *
+ * As final class mocking is only available starting from [Build.VERSION_CODES.P], this test
+ * manually runs itself for both [Build.VERSION_CODES.O] and the current device SDK version
+ * by swapping [Build.VERSION.SDK_INT].
+ */
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.P)
+@LargeTest
+@RunWith(Parameterized::class)
+class PackageInfoCompatHasSignaturesTest {
+
+ companion object {
+ // Following are random public certs (effectively random strings) as this test does not
+ // validate the actual signature integrity. Only the fact that the hashes and comparisons
+ // work and return the correct values.
+
+ private const val CERT_1 = "2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494" +
+ "9422b44434341574767417749424167495548384f42374c355a53594f7852577056774e454f4c336" +
+ "5726c5077774451594a4b6f5a496876634e4151454c0a425141774454454c4d416b4741315545426" +
+ "84d4356564d774942634e4d6a41774f5449794d6a4d774d6a557a576867504d7a41794d4441784d6" +
+ "a51794d7a41790a4e544e614d413078437a414a42674e5642415954416c56544d4947664d4130474" +
+ "35371475349623344514542415155414134474e4144434269514b42675144450a6f3650386341636" +
+ "c77734a646e773457415a755a685244795031556473334d5766703738434448344548614d682f393" +
+ "54a7941316e5a776e2f644174747375640a6e464356713065592b32736d373663334d454a542b456" +
+ "86b443170792f6148324f366c3639314d2b334e7a6a616272752f4c457451364d736232494553454" +
+ "2690a7a63415350756a4a635458586b346a6d44535a4d6d6359653259466d506b633151534f31387" +
+ "875446a514944415141426f314d775554416442674e56485134450a4667515534746446716839634" +
+ "16d4d35707665674d514265476c442b4b774d77487759445652306a42426777466f4155347464467" +
+ "1683963416d4d35707665670a4d514265476c442b4b774d7744775944565230544151482f4241557" +
+ "7417745422f7a414e42676b71686b6947397730424151734641414f426751436a70535a760a4d546" +
+ "76f584c3042304b393577486b61353476685a6c2f5a4c6231427243752f686431746761736766434" +
+ "9566d4d34754335614774697a422b4a3335462f4f2b0a5344572b62585854314c634b4951795a625" +
+ "66772335537736c39584f5773322f55474a33653739555948473144656f497235367534475074312" +
+ "b5338746347500a464b36496e4e42534a56584a325231446b7a754e5843476d63766a4d7a4e426b7" +
+ "47034504d773d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a"
+
+ private const val CERT_2 = "2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494" +
+ "9422b4443434157476741774942416749554739426d31332f566c61747370564461486d46574f6c7" +
+ "65a696b45774451594a4b6f5a496876634e4151454c0a425141774454454c4d416b4741315545426" +
+ "84d4356564d774942634e4d6a41774f5449794d6a4d774d7a4130576867504d7a41794d4441784d6" +
+ "a51794d7a417a0a4d4452614d413078437a414a42674e5642415954416c56544d4947664d4130474" +
+ "35371475349623344514542415155414134474e4144434269514b42675144510a595875516f67783" +
+ "4324c77572b3568656b6f694c50507178655964494250555668743442584d6e494f7835434449665" +
+ "96d6461424650645865685546395036340a7974576a2b316963677452776e4c2f62487a525953413" +
+ "637514c39492b7a45456e2b7342777779566f51325858644c51546f49394f537a54444375744f4c4" +
+ "2430a6f65754f46727373566642676f4d6838685a4f5a31775448442f706c6b38543541384463313" +
+ "7505159774944415141426f314d775554416442674e56485134450a466751554c7a5754614673507" +
+ "23161526d304166556569704b346d6d75785977487759445652306a42426777466f41554c7a57546" +
+ "1467350723161526d3041660a556569704b346d6d7578597744775944565230544151482f4241557" +
+ "7417745422f7a414e42676b71686b6947397730424151734641414f42675141496147524d0a4d423" +
+ "74c74464957714847542f69766f56572b4f6a58664f477332554f75416455776d7a6b374b7a57727" +
+ "874744639616a355250307756637755625654444e740a464c326b4c3171574450513471613333643" +
+ "34744325555416b49474b724d514668523839756a303438514c7871386a72466f663447324572755" +
+ "85353354d79790a5669573735357038354f50704c635a753939796c2b536d7675633938685170796" +
+ "a6f564f6c773d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a"
+
+ private const val CERT_3 = "2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494" +
+ "9422b444343415747674177494241674955484f6f6d736b2b642f79336c4854434e3371675166413" +
+ "335646e51774451594a4b6f5a496876634e4151454c0a425141774454454c4d416b4741315545426" +
+ "84d4356564d774942634e4d6a41774f5449794d6a4d774d7a4578576867504d7a41794d4441784d6" +
+ "a51794d7a417a0a4d5446614d413078437a414a42674e5642415954416c56544d4947664d4130474" +
+ "35371475349623344514542415155414134474e4144434269514b42675144520a6355494b3450724" +
+ "d396930685834546168485055334c575665677630546668307273785153637042496a73306b6a6a6" +
+ "34e78342f31363948674c70476f5a334d0a63424350612f61574a4778794c7145514537774b77644" +
+ "a6148596b4b56706e55706a4d313030634b6b6b4a356565336b56414958746f2f6c436b626b554a6" +
+ "1730a47334f71307677774936656130707336684350313863693066727844766d6630536e2b54615" +
+ "2396a31774944415141426f314d775554416442674e56485134450a466751554464553443534c393" +
+ "746516d774954555332444e4472356b464c5177487759445652306a42426777466f4155446455344" +
+ "3534c393746516d774954550a5332444e4472356b464c517744775944565230544151482f4241557" +
+ "7417745422f7a414e42676b71686b6947397730424151734641414f426751437a567054470a59796" +
+ "1444a6c456279447775443457616b38306d5a4153613534646a69446e6335324d30776e614145776" +
+ "84e496978623547465a50357878337859302f494c520a6a72544a6e6744377643586c556f5256384" +
+ "379794653534169306f3977544b475554434d762b303446324a6c474a4b7665486a346d473544746" +
+ "6335331574b520a6644454a792b456376563658314b716a73466d524a4a6d7a30347464525363304" +
+ "c74622f2f673d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a"
+
+ private const val CERT_4 = "2d2d2d2d2d424547494e2043455254494649434154452d2d2d2d2d0a4d494" +
+ "9422b444343415747674177494241674955526f49427173485858413246636938324d412b73706d5" +
+ "6684d7634774451594a4b6f5a496876634e4151454c0a425141774454454c4d416b4741315545426" +
+ "84d4356564d774942634e4d6a41774f5449304d546b784d7a4d77576867504d7a41794d4441784d6" +
+ "a59784f54457a0a4d7a42614d413078437a414a42674e5642415954416c56544d4947664d4130474" +
+ "35371475349623344514542415155414134474e4144434269514b426751432b0a306549525344554" +
+ "e4972666663486f4d61697431705a6b39534769616c41694e56484b6e4950466876754233497a475" +
+ "05a4d476f6d6a3956534667766e7047360a4f7166453033734e575949503944776772485546692f6" +
+ "e356f45504f742f617643746b4b71623957737531777643746b37795163354d626276644e6b78344" +
+ "c740a3679724a7151545946424479356c49624c67454b4d744a5344584246356a38747173326e705" +
+ "145514f774944415141426f314d775554416442674e56485134450a4667515557354c6e5751344f3" +
+ "2523576515731355452564955726f744e476b77487759445652306a42426777466f415557354c6e5" +
+ "751344f32523576515731350a5452564955726f744e476b7744775944565230544151482f4241557" +
+ "7417745422f7a414e42676b71686b6947397730424151734641414f42675141685768654f0a77525" +
+ "85339365536444a705459597374754741634a77414e434d3244503938325653613136766e7769653" +
+ "842477a6a724a51794f354e4a4846637a4f566e54330a626834496a65337751787551334138566e4" +
+ "54d334230683553373030524c524337373936555a787465683874304f6c7a515031703358452b776" +
+ "571797a6c4e330a4f494f435a486f6b494b6f4957527964734e58547a55353448625850597275556" +
+ "e71574451673d3d0a2d2d2d2d2d454e442043455254494649434154452d2d2d2d2d0a"
+
+ private const val TEST_PKG_NAME = "com.example.app"
+
+ private val nullCerts: List<Certificate>? = null
+ private val emptyCerts = emptyList<Certificate>()
+ private val multiSignerCerts = listOf(CERT_1, CERT_3).map(::Certificate)
+ private val pastHistoryCerts = listOf(CERT_1, CERT_2, CERT_3).map(::Certificate)
+ private val noHistoryCerts = listOf(CERT_1).map(::Certificate)
+ private val extraCert = Certificate(CERT_4)
+
+ data class Params(
+ val sdkVersion: Int,
+ val mockCerts: MockCerts,
+ val queryType: QueryType,
+ val certType: CertType,
+ val matchExact: Boolean
+ ) {
+ enum class CertType(val flag: Int) {
+ X509(PackageManager.CERT_INPUT_RAW_X509),
+ SHA256(PackageManager.CERT_INPUT_SHA256)
+ }
+
+ enum class QueryType { NONE, EXACT_COUNT, FEWER, MORE }
+
+ val queryCerts = when (queryType) {
+ QueryType.NONE -> emptyList()
+ QueryType.EXACT_COUNT -> mockCerts.certificates.orEmpty()
+ QueryType.FEWER -> mockCerts.certificates!!.drop(1)
+ QueryType.MORE -> mockCerts.certificates.orEmpty() + extraCert
+ }
+
+ val success = when (mockCerts.certificates) {
+ // If the certs returned in the packgae are null/empty, the query can never succeed
+ nullCerts, emptyCerts -> false
+ // Otherwise success depends on what the query set is
+ else -> when (queryType) {
+ // None always fails, to ensure verify cannot accidentally succeed
+ QueryType.NONE -> false
+ // If querying the exact same certs, then always succeed
+ QueryType.EXACT_COUNT -> true
+ // Otherwise if querying fewer, only succeed if not matching exactly all
+ QueryType.FEWER -> !matchExact
+ // Otherwise matching more than available, which should always fail
+ QueryType.MORE -> false
+ }
+ }
+
+ // For naming the test method variant
+ override fun toString(): String {
+ val certsVariant = when (mockCerts.certificates) {
+ nullCerts -> "null"
+ emptyCerts -> "empty"
+ multiSignerCerts -> "multiSign"
+ pastHistoryCerts -> "pastHistory"
+ noHistoryCerts -> "noHistory"
+ else -> throw IllegalArgumentException("Invalid mockCerts $mockCerts")
+ }
+
+ @Suppress("DEPRECATION")
+ val queryFlag = when (val flag = mockCerts.flag) {
+ PackageManager.GET_SIGNATURES -> "GET_SIGNATURES"
+ PackageManager.GET_SIGNING_CERTIFICATES -> "GET_SIGNING_CERTIFICATES"
+ else -> throw IllegalArgumentException("Invalid certs type $flag")
+ }
+
+ val sdkVersionName = sdkVersion.takeUnless { it == Build.VERSION.SDK_INT }
+ ?: "current"
+
+ return "$queryFlag," +
+ "$certsVariant${certType.name}," +
+ "sdkVersion=$sdkVersionName," +
+ "query=$queryType," +
+ "matchExact=$matchExact"
+ }
+ }
+
+ @Suppress("DEPRECATION")
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun parameters(): Array<Params> {
+ return listOf(
+ listOf(
+ MockSignatures(nullCerts),
+ MockSignatures(emptyCerts),
+ MockSignatures(multiSignerCerts),
+ // Legacy GET_SIGNATURES cannot include certificate history
+ MockSignatures(noHistoryCerts)
+ ).associateWith { Build.VERSION_CODES.O_MR1 },
+ listOf(
+ MockSigningInfo(
+ multiSigners = false,
+ hasHistory = null,
+ contentsSigners = null,
+ certHistory = null
+ ),
+ MockSigningInfo(
+ multiSigners = false,
+ hasHistory = null,
+ contentsSigners = null,
+ certHistory = emptyCerts
+ ),
+ MockSigningInfo(
+ multiSigners = true,
+ hasHistory = null,
+ contentsSigners = multiSignerCerts,
+ certHistory = null
+ ),
+ MockSigningInfo(
+ multiSigners = false,
+ hasHistory = true,
+ contentsSigners = null,
+ certHistory = pastHistoryCerts
+ ),
+ MockSigningInfo(
+ multiSigners = false,
+ hasHistory = false,
+ contentsSigners = null,
+ certHistory = noHistoryCerts
+ )
+ ).associateWith { Build.VERSION.SDK_INT }
+ )
+ .flatMap {
+ // Multiply all base params by QueryType, CertType and matchExact values to
+ // get the complete set of possibilities
+ it.entries.flatMap { (mockCerts, sdkVersion) ->
+ listOfNotNull(
+ QueryType.NONE,
+ QueryType.EXACT_COUNT,
+ QueryType.MORE,
+ QueryType.FEWER.takeIf {
+ val certificates = mockCerts.certificates
+ !certificates.isNullOrEmpty() && certificates.size > 1
+ }
+ ).flatMap { queryType ->
+ listOf(
+ Params.CertType.X509,
+ Params.CertType.SHA256
+ ).flatMap { certType ->
+ listOf(true, false).map { matchExact ->
+ Params(sdkVersion, mockCerts, queryType, certType, matchExact)
+ }
+ }
+ }
+ }
+ }
+ .toTypedArray()
+ }
+
+ private val sdkIntField = Build.VERSION::class.java.getDeclaredField("SDK_INT")
+
+ private fun setDeviceSdkVersion(sdkVersion: Int) {
+ FieldSetter.setField(Build.VERSION::class.java, sdkIntField, sdkVersion)
+ assertThat(Build.VERSION.SDK_INT).isEqualTo(sdkVersion)
+ }
+ }
+
+ @Parameterized.Parameter(0)
+ lateinit var params: Params
+
+ private var savedSdkVersion: Int = Build.VERSION.SDK_INT
+
+ @Before
+ fun saveSdkVersion() {
+ savedSdkVersion = Build.VERSION.SDK_INT
+ }
+
+ @After
+ fun resetSdkVersion() {
+ if (Build.VERSION.SDK_INT != savedSdkVersion) {
+ setDeviceSdkVersion(savedSdkVersion)
+ }
+ }
+
+ @Test
+ fun verify() {
+ val mock = mockPackageManager()
+ val certs = params.queryCerts.map { it.bytes(params.certType) }
+ .associateWith { params.certType.flag }
+
+ // SDK_INT must be changed after mocks are built, since MockMaker will do an SDK check
+ if (Build.VERSION.SDK_INT != params.sdkVersion) {
+ setDeviceSdkVersion(params.sdkVersion)
+ }
+
+ assertThat(PackageInfoCompat.hasSignatures(mock, TEST_PKG_NAME, certs, params.matchExact))
+ .isEqualTo(params.success)
+
+ if (Build.VERSION.SDK_INT != savedSdkVersion) {
+ setDeviceSdkVersion(savedSdkVersion)
+ }
+ }
+
+ private fun mockPackageManager() = mockThrowOnUnmocked<PackageManager> {
+ val mockCerts = params.mockCerts
+ whenever(getPackageInfo(TEST_PKG_NAME, params.mockCerts.flag)) {
+ PackageInfo().apply {
+ when (mockCerts) {
+ is MockSignatures -> {
+ @Suppress("DEPRECATION")
+ signatures = mockCerts.certificates?.map { it.signature }?.toTypedArray()
+ }
+ is MockSigningInfo -> {
+ signingInfo = mockThrowOnUnmocked<SigningInfo> {
+ whenever(hasMultipleSigners()) { mockCerts.multiSigners }
+
+ mockCerts.hasHistory?.let {
+ // Only allow this method if params specify it. to ensure past
+ // certificates aren't considered when multi-signing is enabled
+ whenever(hasPastSigningCertificates()) { it }
+ }
+
+ mockCerts.contentsSigners
+ ?.map { it.signature }
+ ?.toTypedArray()
+ ?.let { whenever(apkContentsSigners) { it } }
+
+ // Only allow fetching history if not multi signed
+ if (!hasMultipleSigners()) {
+ whenever(signingCertificateHistory) {
+ mockCerts.certHistory
+ ?.map { it.signature }
+ ?.toTypedArray()
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (!params.matchExact && params.sdkVersion >= Build.VERSION_CODES.P) {
+ whenever(hasSigningCertificate(eq(TEST_PKG_NAME), any(), anyInt())) {
+ val certs = params.mockCerts.certificates?.asSequence() ?: return@whenever false
+ val query = getArgument(1) as ByteArray
+ val certType = when (val type = getArgument(2) as Int) {
+ PackageManager.CERT_INPUT_RAW_X509 -> Params.CertType.X509
+ PackageManager.CERT_INPUT_SHA256 -> Params.CertType.SHA256
+ else -> throw IllegalArgumentException("Invalid type $type")
+ }
+ certs.map { it.bytes(certType) }.contains(query)
+ }
+ }
+ }
+
+ sealed class MockCerts {
+ abstract val certificates: List<Certificate>?
+ abstract val flag: Int
+
+ data class MockSignatures(override val certificates: List<Certificate>?) : MockCerts() {
+ @Suppress("DEPRECATION")
+ override val flag = PackageManager.GET_SIGNATURES
+ }
+
+ data class MockSigningInfo(
+ val multiSigners: Boolean,
+ val hasHistory: Boolean?,
+ val contentsSigners: List<Certificate>?,
+ val certHistory: List<Certificate>?
+ ) : MockCerts() {
+ override val certificates = contentsSigners ?: certHistory
+ override val flag = PackageManager.GET_SIGNING_CERTIFICATES
+ }
+ }
+
+ /**
+ * [Signature] wrapper to cache arrays and digests.
+ */
+ data class Certificate(val publicCertX509: String) {
+ val signature = Signature(publicCertX509)
+ private val x509Bytes = signature.toByteArray()!!
+ private val sha256Bytes = MessageDigest.getInstance("SHA256").digest(x509Bytes)
+
+ fun bytes(certType: Params.CertType): ByteArray = when (certType) {
+ Params.CertType.X509 -> x509Bytes
+ Params.CertType.SHA256 -> sha256Bytes
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/core/src/androidTest/java/androidx/core/content/pm/PackageInfoCompatTest.java b/core/core/src/androidTest/java/androidx/core/content/pm/PackageInfoCompatTest.java
index 3ea56a4..b0f7a5b 100644
--- a/core/core/src/androidTest/java/androidx/core/content/pm/PackageInfoCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/content/pm/PackageInfoCompatTest.java
@@ -18,17 +18,34 @@
import static android.os.Build.VERSION_CODES.P;
+import static com.google.common.truth.Truth.assertThat;
+
import static org.junit.Assert.assertEquals;
+import android.content.Context;
import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.Signature;
+import androidx.collection.ArrayMap;
import androidx.test.filters.SdkSuppress;
import androidx.test.filters.SmallTest;
+import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
+import java.util.List;
+import java.util.Map;
+
@SmallTest
public final class PackageInfoCompatTest {
+
+ private static final String NON_EXISTENT_PACKAGE = "com.example.app.non_existent_package_name";
+
+ private final Context mContext =
+ InstrumentationRegistry.getInstrumentation().getTargetContext();
+ private PackageManager mPackageManager = mContext.getPackageManager();
+
@Test
public void getLongVersionCodeLowerBitsOnly() {
PackageInfo info = new PackageInfo();
@@ -45,4 +62,49 @@
assertEquals(Long.MAX_VALUE, PackageInfoCompat.getLongVersionCode(info));
}
+
+ /**
+ * Only verifies non-null return, to avoid hard coding certs. Actual equality and proper
+ * return value is verified as part of {@link PackageInfoCompatHasSignaturesTest}.
+ */
+ @Test
+ public void getSignaturesNonNull() throws PackageManager.NameNotFoundException {
+ List<Signature> signatures = PackageInfoCompat.getSignatures(mPackageManager,
+ mContext.getPackageName());
+
+ assertThat(signatures).isNotEmpty();
+ }
+
+ @Test(expected = PackageManager.NameNotFoundException.class)
+ public void getSignaturesThrowOnNotFound() throws PackageManager.NameNotFoundException {
+ PackageInfoCompat.getSignatures(mPackageManager, NON_EXISTENT_PACKAGE);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void hasSignaturesThrowOnInvalidType() throws PackageManager.NameNotFoundException {
+ Map<byte[], Integer> map = new ArrayMap<>(1);
+ map.put(new byte[100], PackageManager.CERT_INPUT_SHA256 + 1);
+ PackageInfoCompat.hasSignatures(mPackageManager, mContext.getPackageName(), map, false);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void hasSignaturesThrowOnNullBytes() throws PackageManager.NameNotFoundException {
+ Map<byte[], Integer> map = new ArrayMap<>(1);
+ map.put(null, PackageManager.CERT_INPUT_SHA256);
+ PackageInfoCompat.hasSignatures(mPackageManager, mContext.getPackageName(), map, false);
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void hasSignaturesThrowOnNullType() throws PackageManager.NameNotFoundException {
+ Map<byte[], Integer> map = new ArrayMap<>(1);
+ map.put(new byte[100], null);
+ PackageInfoCompat.hasSignatures(mPackageManager, mContext.getPackageName(), map, false);
+ }
+
+ @Test(expected = PackageManager.NameNotFoundException.class)
+ public void hasSignaturesThrowOnNotFound() throws PackageManager.NameNotFoundException {
+ Map<byte[], Integer> map = new ArrayMap<>(1);
+ map.put(new byte[100], PackageManager.CERT_INPUT_SHA256);
+ PackageInfoCompat.hasSignatures(mPackageManager, NON_EXISTENT_PACKAGE, map, false);
+ }
}
diff --git a/core/core/src/androidTest/java/androidx/core/util/mockito/CustomMockMaker.kt b/core/core/src/androidTest/java/androidx/core/util/mockito/CustomMockMaker.kt
new file mode 100644
index 0000000..bfa1be2
--- /dev/null
+++ b/core/core/src/androidTest/java/androidx/core/util/mockito/CustomMockMaker.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package androidx.core.util.mockito
+
+import android.os.Build
+import com.android.dx.mockito.DexmakerMockMaker
+import com.android.dx.mockito.inline.InlineDexmakerMockMaker
+import org.mockito.invocation.MockHandler
+import org.mockito.mock.MockCreationSettings
+import org.mockito.plugins.InlineMockMaker
+import org.mockito.plugins.MockMaker
+
+/**
+ * There is no official supported method for mixing dexmaker-mockito with dexmaker-mockito-inline,
+ * so this has to be done manually.
+ *
+ * Inside the build.gradle, dexmaker-mockito is taken first and preferred, and this custom
+ * implementation is responsible for delegating to the inline variant should the regular variant
+ * fall to instantiate a mock.
+ *
+ * This allows Mockito to mock final classes on test run on API 28+ devices, while still
+ * functioning for normal non-final mocks API <28.
+ *
+ * This class is placed in the core sources since the use case is rather unique to
+ * [androidx.core.content.pm.PackageInfoCompatHasSignaturesTest], and other testing solutions should
+ * be considered before using this in other modules.
+ */
+class CustomMockMaker : InlineMockMaker {
+
+ companion object {
+ private val MOCK_MAKERS = mutableListOf<MockMaker>(DexmakerMockMaker()).apply {
+ // Inline only works on API 28+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ this += InlineDexmakerMockMaker()
+ }
+ }
+ }
+
+ override fun <T> createMock(settings: MockCreationSettings<T>, handler: MockHandler<*>): T? {
+ var lastException: Exception? = null
+ MOCK_MAKERS
+ .filter { it.isTypeMockable(settings.typeToMock).mockable() }
+ .forEach {
+ val mock = try {
+ it.createMock(settings, handler)
+ } catch (e: Exception) {
+ lastException = e
+ null
+ }
+
+ if (mock != null) {
+ return mock
+ }
+ }
+
+ lastException?.let { throw it }
+ return null
+ }
+
+ override fun getHandler(mock: Any?): MockHandler<*>? {
+ MOCK_MAKERS.forEach {
+ val handler = it.getHandler(mock)
+ if (handler != null) {
+ return handler
+ }
+ }
+ return null
+ }
+
+ override fun resetMock(
+ mock: Any?,
+ newHandler: MockHandler<*>?,
+ settings: MockCreationSettings<*>?
+ ) {
+ MOCK_MAKERS.forEach {
+ it.resetMock(mock, newHandler, settings)
+ }
+ }
+
+ override fun isTypeMockable(type: Class<*>?): MockMaker.TypeMockability? {
+ MOCK_MAKERS.forEachIndexed { index, mockMaker ->
+ val mockability = mockMaker.isTypeMockable(type)
+ // Prefer the first mockable instance, or the last one available
+ if (mockability.mockable() || index == MOCK_MAKERS.size - 1) {
+ return mockability
+ }
+ }
+ return null
+ }
+
+ override fun clearMock(mock: Any?) {
+ MOCK_MAKERS.forEach {
+ (it as? InlineMockMaker)?.clearMock(mock)
+ }
+ }
+
+ override fun clearAllMocks() {
+ MOCK_MAKERS.forEach {
+ (it as? InlineMockMaker)?.clearAllMocks()
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/core/src/androidTest/java/androidx/core/util/mockito/CustomStackTraceCleaner.kt b/core/core/src/androidTest/java/androidx/core/util/mockito/CustomStackTraceCleaner.kt
new file mode 100644
index 0000000..d927ef5
--- /dev/null
+++ b/core/core/src/androidTest/java/androidx/core/util/mockito/CustomStackTraceCleaner.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package androidx.core.util.mockito
+
+import com.android.dx.mockito.DexmakerMockMaker
+import com.android.dx.mockito.inline.DexmakerStackTraceCleaner
+import org.mockito.exceptions.stacktrace.StackTraceCleaner
+import org.mockito.internal.exceptions.stacktrace.DefaultStackTraceCleaner
+
+/**
+ * Similar to [CustomMockMaker], delegates the stack cleaner logic to mix dexmaker-mockito and
+ * dexmaker-mockito-inline.
+ */
+class CustomStackTraceCleaner : StackTraceCleaner {
+
+ companion object {
+ private val CLEANER_WRAPPER = DefaultStackTraceCleaner()
+ .let { DexmakerMockMaker().getStackTraceCleaner(it) }
+ .let { DexmakerStackTraceCleaner().getStackTraceCleaner(it) }
+ }
+
+ override fun isIn(candidate: StackTraceElement?) = CLEANER_WRAPPER.isIn(candidate)
+}
\ No newline at end of file
diff --git a/core/core/src/androidTest/resources/mockito-extensions/org.mockito.plugins.MockMaker b/core/core/src/androidTest/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000..7f9e2f4
--- /dev/null
+++ b/core/core/src/androidTest/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+androidx.core.util.mockito.CustomMockMaker
\ No newline at end of file
diff --git a/core/core/src/androidTest/resources/mockito-extensions/org.mockito.plugins.StackTraceCleaner b/core/core/src/androidTest/resources/mockito-extensions/org.mockito.plugins.StackTraceCleaner
new file mode 100644
index 0000000..d380fb4
--- /dev/null
+++ b/core/core/src/androidTest/resources/mockito-extensions/org.mockito.plugins.StackTraceCleaner
@@ -0,0 +1 @@
+androidx.core.util.mockito.CustomStackTraceCleaner
\ No newline at end of file
diff --git a/core/core/src/main/java/androidx/core/content/pm/PackageInfoCompat.java b/core/core/src/main/java/androidx/core/content/pm/PackageInfoCompat.java
index b7e7452..20ef5e5 100644
--- a/core/core/src/main/java/androidx/core/content/pm/PackageInfoCompat.java
+++ b/core/core/src/main/java/androidx/core/content/pm/PackageInfoCompat.java
@@ -16,10 +16,25 @@
package androidx.core.content.pm;
+import android.annotation.SuppressLint;
import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.Signature;
+import android.content.pm.SigningInfo;
import android.os.Build;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.Size;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
/** Helper for accessing features in {@link PackageInfo}. */
public final class PackageInfoCompat {
@@ -38,6 +53,236 @@
return info.versionCode;
}
+ /**
+ * Retrieve the {@link Signature} array for the given package. This returns some of
+ * certificates, depending on whether the package in question is multi-signed or has signing
+ * history.
+ *
+ * <note>
+ * <p>
+ * Security/identity verification should <b>not</b> be done with this method. This is only
+ * intended to return some array of certificates that correspond to a package.
+ * </p>
+ * <p>
+ * If verification if required, either use
+ * {@link #hasSignatures(PackageManager, String, Map, boolean)} or manually verify the set of
+ * certificates using {@link PackageManager#GET_SIGNING_CERTIFICATES} or
+ * {@link PackageManager#GET_SIGNATURES}.
+ * </p>
+ * </note>
+ *
+ * @param packageManager The {@link PackageManager} instance to query against.
+ * @param packageName The package to query the {@param packageManager} for. Query by app
+ * UID is only supported by manually choosing a package name
+ * returned in {@link PackageManager#getPackagesForUid(int)}.
+ * @return an array of certificates the app is signed with
+ * @throws PackageManager.NameNotFoundException if the package cannot be found through the
+ * provided {@param packageManager}
+ */
+ @NonNull
+ public static List<Signature> getSignatures(@NonNull PackageManager packageManager,
+ @NonNull String packageName) throws PackageManager.NameNotFoundException {
+ Signature[] array;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ PackageInfo pkgInfo = packageManager.getPackageInfo(packageName,
+ PackageManager.GET_SIGNING_CERTIFICATES);
+ SigningInfo signingInfo = pkgInfo.signingInfo;
+ if (Api28Impl.hasMultipleSigners(signingInfo)) {
+ array = Api28Impl.getApkContentsSigners(signingInfo);
+ } else {
+ array = Api28Impl.getSigningCertificateHistory(signingInfo);
+ }
+ } else {
+ // Lint warning's vulnerability is explicitly not handled for this method.
+ @SuppressLint("PackageManagerGetSignatures")
+ PackageInfo pkgInfo = packageManager.getPackageInfo(packageName,
+ PackageManager.GET_SIGNATURES);
+ array = pkgInfo.signatures;
+ }
+
+ // Framework code implies nullable/empty, although it may be impossible in practice.
+ if (array == null) {
+ return Collections.emptyList();
+ } else {
+ return Arrays.asList(array);
+ }
+ }
+
+ /**
+ * Check if a package on device contains set of a certificates. Supported types are raw X509 or
+ * SHA-256 bytes.
+ *
+ * @param packageManager The {@link PackageManager} instance to query against.
+ * @param packageName The package to query the {@param packageManager} for. Query by
+ * app UID is only supported by manually choosing a package name
+ * returned in {@link PackageManager#getPackagesForUid(int)}.
+ * @param certificatesAndType The bytes of the certificate mapped to the type, either
+ * {@link PackageManager#CERT_INPUT_RAW_X509} or
+ * {@link PackageManager#CERT_INPUT_SHA256}. A single or multiple
+ * certificates may be included.
+ * @param matchExact Whether or not to check for presence of all signatures exactly.
+ * If false, then the check will succeed if the query contains a
+ * subset of the package certificates. Matching exactly is strongly
+ * recommended when running on devices below
+ * {@link Build.VERSION_CODES#LOLLIPOP} due to the fake ID
+ * vulnerability that allows a package to be modified to include
+ * an unverified signature.
+ * @return true if the package is considered signed by the given certificate set, or false
+ * otherwise
+ * @throws PackageManager.NameNotFoundException if the package cannot be found through the
+ * provided {@param packageManager}
+ */
+ public static boolean hasSignatures(@NonNull PackageManager packageManager,
+ @NonNull String packageName,
+ @Size(min = 1) @NonNull Map<byte[], Integer> certificatesAndType, boolean matchExact)
+ throws PackageManager.NameNotFoundException {
+ // If empty is passed in, return false to prevent accidentally succeeding
+ if (certificatesAndType.isEmpty()) {
+ return false;
+ }
+
+ Set<byte[]> expectedCertBytes = certificatesAndType.keySet();
+
+ // The type has to be checked before any API level branching. If a new type is ever added,
+ // this code should fail and will have to be updated manually. To do otherwise would
+ // introduce a behavioral difference between the API level that added the new type and
+ // devices on prior API levels, which may not be caught by a developer calling this
+ // method if they do not test on an old API level.
+ for (byte[] bytes : expectedCertBytes) {
+ if (bytes == null) {
+ throw new IllegalArgumentException("Cert byte array cannot be null when verifying "
+ + packageName);
+ }
+ Integer type = certificatesAndType.get(bytes);
+ if (type == null) {
+ throw new IllegalArgumentException("Type must be specified for cert when verifying "
+ + packageName);
+ }
+
+ switch (type) {
+ case PackageManager.CERT_INPUT_RAW_X509:
+ case PackageManager.CERT_INPUT_SHA256:
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported certificate type " + type
+ + " when verifying " + packageName);
+ }
+ }
+
+ // getSignatures is called first to throw NameNotFoundException if necessary
+ final List<Signature> signers = getSignatures(packageManager, packageName);
+
+ // The vulnerability requiring matchExact is not necessary on P, but the signatures
+ // must still be checked manually in order to match the behavior described by the
+ // method. Otherwise matchExact == true will allow additional certificates if run
+ // on a device >= P.
+ if (!matchExact && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ // If not matching exact, delegate to the API 28 PackageManager API for checking
+ // individual certificates. This is less performant, but goes through a formally
+ // supported API.
+ for (byte[] bytes : expectedCertBytes) {
+ Integer type = certificatesAndType.get(bytes);
+ //noinspection ConstantConditions type cannot be null
+ if (!Api28Impl.hasSigningCertificate(packageManager, packageName, bytes, type)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // Fail if the query is larger than the actual set, or the size doesn't match and it should.
+ if (signers.size() == 0
+ || certificatesAndType.size() > signers.size()
+ || (matchExact && certificatesAndType.size() != signers.size())) {
+ return false;
+ }
+
+ @SuppressLint("InlinedApi")
+ boolean hasSha256 = certificatesAndType.containsValue(PackageManager.CERT_INPUT_SHA256);
+ byte[][] sha256Digests = null;
+ if (hasSha256) {
+ // Since the search does several array contains checks, cache the SHA256 digests here.
+ sha256Digests = new byte[signers.size()][];
+ for (int index = 0; index < signers.size(); index++) {
+ sha256Digests[index] = computeSHA256Digest(signers.get(index).toByteArray());
+ }
+ }
+
+ for (byte[] bytes : expectedCertBytes) {
+ Integer type = certificatesAndType.get(bytes);
+ //noinspection ConstantConditions type cannot be null
+ switch (type) {
+ case PackageManager.CERT_INPUT_RAW_X509:
+ // RAW_X509 is the type that Signatures are and always have been stored as,
+ // so defer to the Signature equals method for the platform.
+ Signature expectedSignature = new Signature(bytes);
+ if (!signers.contains(expectedSignature)) {
+ return false;
+ }
+ break;
+ case PackageManager.CERT_INPUT_SHA256:
+ // sha256Digests cannot be null due to pre-checked containsValue for its type
+ //noinspection ConstantConditions
+ if (!byteArrayContains(sha256Digests, bytes)) {
+ return false;
+ }
+ break;
+ default:
+ // Impossible to reach this point due to check at beginning of method.
+ throw new IllegalArgumentException("Unsupported certificate type " + type);
+ }
+
+ // If this point is reached, all searches have succeeded
+ return true;
+ }
+
+ return false;
+ }
+
+ private static boolean byteArrayContains(@NonNull byte[][] array, @NonNull byte[] expected) {
+ for (byte[] item : array) {
+ if (Arrays.equals(expected, item)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static byte[] computeSHA256Digest(byte[] bytes) {
+ try {
+ return MessageDigest.getInstance("SHA256").digest(bytes);
+ } catch (NoSuchAlgorithmException e) {
+ // Can't happen, SHA256 required since API level 1
+ throw new RuntimeException("Device doesn't support SHA256 cert checking", e);
+ }
+ }
+
private PackageInfoCompat() {
}
+
+ @RequiresApi(Build.VERSION_CODES.P)
+ private static class Api28Impl {
+ private Api28Impl() {
+ }
+
+ static boolean hasSigningCertificate(@NonNull PackageManager packageManager,
+ @NonNull String packageName, @NonNull byte[] bytes, int type) {
+ return packageManager.hasSigningCertificate(packageName, bytes, type);
+ }
+
+ static boolean hasMultipleSigners(@NonNull SigningInfo signingInfo) {
+ return signingInfo.hasMultipleSigners();
+ }
+
+ @Nullable
+ static Signature[] getApkContentsSigners(@NonNull SigningInfo signingInfo) {
+ return signingInfo.getApkContentsSigners();
+ }
+
+ @Nullable
+ static Signature[] getSigningCertificateHistory(@NonNull SigningInfo signingInfo) {
+ return signingInfo.getSigningCertificateHistory();
+ }
+ }
}
diff --git a/settings.gradle b/settings.gradle
index b19943d..fe0f50a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -560,6 +560,7 @@
includeProject(":internal-testutils-navigation", "testutils/testutils-navigation", [BuildType.MAIN, BuildType.FLAN])
includeProject(":internal-testutils-paging", "testutils/testutils-paging", [BuildType.MAIN])
includeProject(":internal-testutils-gradle-plugin", "testutils/testutils-gradle-plugin", [BuildType.MAIN, BuildType.FLAN])
+includeProject(":internal-testutils-mockito", "testutils/testutils-mockito", [BuildType.MAIN])
/////////////////////////////
//
diff --git a/testutils/testutils-mockito/build.gradle b/testutils/testutils-mockito/build.gradle
new file mode 100644
index 0000000..ea8a7de
--- /dev/null
+++ b/testutils/testutils-mockito/build.gradle
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * 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.
+ */
+
+import static androidx.build.dependencies.DependenciesKt.*
+
+plugins {
+ id("AndroidXPlugin")
+ id("com.android.library")
+ id("kotlin-android")
+}
+
+dependencies {
+ api(MOCKITO_CORE, libs.exclude_bytebuddy)
+
+ implementation(KOTLIN_STDLIB)
+}
diff --git a/testutils/testutils-mockito/src/main/AndroidManifest.xml b/testutils/testutils-mockito/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..dbe1e34
--- /dev/null
+++ b/testutils/testutils-mockito/src/main/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2020 The Android Open Source Project
+
+ 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.
+ -->
+<manifest package="androidx.testutils.mockito"/>
diff --git a/testutils/testutils-mockito/src/main/java/androidx/testutils/mockito/MockitoUtils.kt b/testutils/testutils-mockito/src/main/java/androidx/testutils/mockito/MockitoUtils.kt
new file mode 100644
index 0000000..96d4b0a
--- /dev/null
+++ b/testutils/testutils-mockito/src/main/java/androidx/testutils/mockito/MockitoUtils.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2020 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package androidx.testutils.mockito
+
+import org.mockito.Answers
+import org.mockito.MockSettings
+import org.mockito.Mockito
+import org.mockito.invocation.InvocationOnMock
+import org.mockito.stubbing.Answer
+
+/**
+ * [Answer] variant intended for [MockSettings.defaultAnswer] that logs the unmocked method
+ * that was called, serializing the arguments used, to try and provide a more informative
+ * error message.
+ */
+val ANSWER_THROWS = Answer {
+ when (val name = it.method.name) {
+ // Delegate to the actual toString, since that will probably not be mocked by a test
+ "toString" -> Answers.CALLS_REAL_METHODS.answer(it)
+ else -> {
+ val arguments = it.arguments
+ ?.takeUnless { it.isEmpty() }
+ ?.mapIndexed { index, arg ->
+ try {
+ arg?.toString()
+ } catch (e: Exception) {
+ "toString[$index] threw ${e.message}"
+ }
+ }
+ ?.joinToString()
+ ?: "no arguments"
+
+ throw UnsupportedOperationException(
+ "${it.mock::class.java.simpleName}#$name with $arguments should not be called"
+ )
+ }
+ }
+}
+
+fun <Type : Any?> whenever(mock: Type, block: InvocationOnMock.() -> Type) =
+ Mockito.`when`(mock).thenAnswer { block(it) }!!
+
+/**
+ * Spy an existing object and allow mocking within [block]. Once the method returns, the spied
+ * instance is prepped to throw exceptions whenever an unmocked method is called. This can be
+ * used to enforce that only specifically mocked methods are called, avoiding unexpected
+ * results when the behavior under test adds code to call an unexpected method.
+ */
+inline fun <reified T> spyThrowOnUnmocked(value: T?, block: T.() -> Unit = {}): T {
+ val swappingAnswer = object : Answer<Any?> {
+ var delegate: Answer<*> = Answers.RETURNS_DEFAULTS
+
+ override fun answer(invocation: InvocationOnMock?): Any? {
+ return delegate.answer(invocation)
+ }
+ }
+
+ val settings = Mockito.withSettings()
+ .spiedInstance(value)
+ .defaultAnswer(swappingAnswer)
+
+ return Mockito.mock(T::class.java, settings)
+ .apply(block)
+ .also {
+ // To allow Mockito.when() usage inside block, only swap to throwing afterwards
+ swappingAnswer.delegate = ANSWER_THROWS
+ }
+}
+
+/**
+ * [Mockito.mock] equivalent of [spyThrowOnUnmocked] which doesn't spy an existing instance.
+ */
+inline fun <reified T> mockThrowOnUnmocked(block: T.() -> Unit = {}) =
+ spyThrowOnUnmocked(null, block)
\ No newline at end of file