diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt
index 6daaf6a6975..1177e01643a 100644
--- a/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt
+++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt
@@ -6,6 +6,7 @@ import android.os.strictmode.DiskWriteViolation
import android.os.strictmode.IncorrectContextUseViolation
import android.os.strictmode.Violation
import androidx.annotation.RequiresApi
+import io.homeassistant.companion.android.common.data.HomeAssistantApis
import io.homeassistant.companion.android.common.util.IgnoreViolationRule
val vmPolicyIgnoredViolationRules = listOf(
@@ -28,6 +29,7 @@ val threadPolicyIgnoredViolationRules = listOf(
IgnoreMiuiTurboSchedMonitorDiskRead,
IgnoreChromiumKeyStoreDiskWrite,
IgnoreAppCompatPersistLocalesDiskReadWrite,
+ IgnoreConfigureOkHttpClientDiskRead,
)
/**
@@ -327,3 +329,21 @@ private data object IgnoreChromiumKeyStoreDiskWrite : IgnoreViolationRule {
}
}
}
+
+/**
+ * Ignores a [DiskReadViolation] raised while [HomeAssistantApis] configures its OkHttpClient:
+ * building the TLSHelper reads CA keystores from disk on some devices.
+ *
+ * Solved by https://github.com/home-assistant/android/pull/7042
+ */
+private data object IgnoreConfigureOkHttpClientDiskRead : IgnoreViolationRule {
+ @RequiresApi(Build.VERSION_CODES.P)
+ override fun shouldIgnore(violation: Violation): Boolean {
+ if (violation !is DiskReadViolation) return false
+
+ return violation.stackTrace.any {
+ it.className == HomeAssistantApis::class.java.name &&
+ it.methodName == "configureOkHttpClient"
+ }
+ }
+}
diff --git a/automotive/lint-baseline.xml b/automotive/lint-baseline.xml
index 31d9fb83f10..9760c3673cd 100644
--- a/automotive/lint-baseline.xml
+++ b/automotive/lint-baseline.xml
@@ -349,7 +349,7 @@
errorLine2=" ^">
@@ -360,7 +360,7 @@
errorLine2=" ^">
@@ -371,7 +371,7 @@
errorLine2=" ^">
@@ -778,7 +778,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -789,7 +789,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -800,7 +800,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -811,7 +811,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -822,7 +822,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -833,7 +833,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -844,7 +844,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -855,7 +855,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -866,7 +866,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -877,7 +877,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -888,7 +888,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -899,7 +899,18 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
+
+
+
+
@@ -910,7 +921,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManager.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManager.kt
new file mode 100644
index 00000000000..b0d74478e11
--- /dev/null
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManager.kt
@@ -0,0 +1,91 @@
+package io.homeassistant.companion.android.common.data
+
+import android.annotation.SuppressLint
+import android.os.Build
+import androidx.annotation.RequiresApi
+import java.net.Socket
+import java.security.cert.CertificateException
+import java.security.cert.X509Certificate
+import javax.net.ssl.SSLEngine
+import javax.net.ssl.X509ExtendedTrustManager
+
+/**
+ * An [X509ExtendedTrustManager] that trusts a server certificate if either the [primary] or the
+ * [fallback] accepts it. The [primary] is always tried first; the [fallback] is only asked when the
+ * [primary] rejects the chain with a [CertificateException].
+ *
+ * This is deliberately more permissive than the [primary] alone: a certificate the [primary] rejects
+ * becomes trusted as soon as the [fallback] accepts it. It can never be stricter than the [primary],
+ * so it only ever adds acceptances and never introduces new rejections.
+ *
+ * Client-trust checks ([checkClientTrusted]) and [getAcceptedIssuers] decide which client
+ * certificates to accept, which is unrelated to widening server-certificate trust, so they delegate
+ * to the [primary] only.
+ *
+ * Requires API 24: [X509ExtendedTrustManager] and its hostname-aware overloads don't exist below it.
+ */
+@RequiresApi(Build.VERSION_CODES.N)
+@SuppressLint("CustomX509TrustManager")
+internal class CompositeX509ExtendedTrustManager(
+ private val primary: X509ExtendedTrustManager,
+ private val fallback: X509ExtendedTrustManager,
+) : X509ExtendedTrustManager() {
+
+ /**
+ * Runs [check] against the [primary], and only if it rejects with a [CertificateException]
+ * retries against the [fallback]. If both reject, the [primary]'s exception is rethrown (its
+ * verdict wins) with the [fallback]'s attached via [Throwable.addSuppressed].
+ *
+ * Only [CertificateException] triggers the fallback; anything else propagates unchanged.
+ */
+ private inline fun checkServerOrFallback(check: (X509ExtendedTrustManager) -> Unit) {
+ try {
+ check(primary)
+ } catch (primaryError: CertificateException) {
+ try {
+ check(fallback)
+ } catch (fallbackError: CertificateException) {
+ primaryError.addSuppressed(fallbackError)
+ throw primaryError
+ }
+ }
+ }
+
+ override fun checkServerTrusted(chain: Array?, authType: String?) {
+ checkServerOrFallback { it.checkServerTrusted(chain, authType) }
+ }
+
+ override fun checkServerTrusted(chain: Array?, authType: String?, socket: Socket?) {
+ checkServerOrFallback { it.checkServerTrusted(chain, authType, socket) }
+ }
+
+ override fun checkServerTrusted(chain: Array?, authType: String?, engine: SSLEngine?) {
+ checkServerOrFallback { it.checkServerTrusted(chain, authType, engine) }
+ }
+
+ /**
+ * Client trust uses the primary only (see class doc).
+ */
+ override fun checkClientTrusted(chain: Array?, authType: String?) {
+ primary.checkClientTrusted(chain, authType)
+ }
+
+ /**
+ * Client trust uses the primary only (see class doc).
+ */
+ override fun checkClientTrusted(chain: Array?, authType: String?, socket: Socket?) {
+ primary.checkClientTrusted(chain, authType, socket)
+ }
+
+ /**
+ * Client trust uses the primary only (see class doc).
+ */
+ override fun checkClientTrusted(chain: Array?, authType: String?, engine: SSLEngine?) {
+ primary.checkClientTrusted(chain, authType, engine)
+ }
+
+ /** Returns the [primary]'s accepted issuers only; the [fallback] has no role in client authentication. */
+ override fun getAcceptedIssuers(): Array {
+ return primary.acceptedIssuers
+ }
+}
diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt
index 3bb2db67d6b..3786d6976a8 100644
--- a/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt
+++ b/common/src/main/kotlin/io/homeassistant/companion/android/common/data/TLSHelper.kt
@@ -1,9 +1,16 @@
package io.homeassistant.companion.android.common.data
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.annotation.VisibleForTesting
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
import io.homeassistant.companion.android.common.data.keychain.NamedKeyChain
import io.homeassistant.companion.android.common.data.keychain.NamedKeyStore
+import io.homeassistant.companion.android.common.util.FailFast
+import io.homeassistant.companion.android.common.util.SdkVersion
+import java.io.IOException
import java.net.Socket
+import java.security.GeneralSecurityException
import java.security.KeyStore
import java.security.Principal
import java.security.PrivateKey
@@ -12,34 +19,83 @@ import javax.inject.Inject
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
+import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager
import okhttp3.OkHttpClient
import timber.log.Timber
+/**
+ * Helper to configure an [OkHttpClient] for server-certificate validation (with a
+ * user-installed CA fallback) and the client certificate for mutual TLS (mTLS).
+ */
class TLSHelper @Inject constructor(
@NamedKeyChain private val keyChainRepository: KeyChainRepository,
@NamedKeyStore private val keyStore: KeyChainRepository,
) {
+ /**
+ * Configures [builder] to validate server certificates.
+ *
+ * It uses Android's default trust manager, which honors `network_security_config.xml` (system and
+ * user CAs) and can rebuild an incomplete chain itself (see
+ * https://github.com/home-assistant/android/issues/6810).
+ *
+ * Some ROMs (e.g. /e/OS, https://github.com/home-assistant/android/issues/5565) don't honor
+ * user-installed CAs through that default path even though their browser and WebView do. To cover
+ * them, the handshake also falls back to a trust manager holding only the user-installed CAs
+ * whenever the default rejects a certificate (see [withUserInstalledCaFallback]). This relies on
+ * the app opting into user CAs via `` in `network_security_config.xml`.
+ */
fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) {
- val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
- // Load AndroidCAStore explicitly to include user-installed CAs alongside
- // system CAs. On some Android builds, passing null may load only the
- // system store, which can bypass user-CA trust configured in
- // network_security_config.xml (#5565).
- val androidCaStore: KeyStore? = try {
- KeyStore.getInstance("AndroidCAStore").apply { load(null) }
- } catch (e: Throwable) {
- Timber.w(e, "AndroidCAStore unavailable, falling back to system trust store")
- null
+ val platformTrustManager = defaultX509TrustManager() ?: run {
+ FailFast.fail { "No default X509 trust manager available" }
+ return
}
- trustManagerFactory.init(androidCaStore)
- val trustManagers = trustManagerFactory.trustManagers
+ val handshakeTrustManager = withUserInstalledCaFallback(platformTrustManager)
val sslContext = SSLContext.getInstance("TLS")
- sslContext.init(arrayOf(getMTLSKeyManagerForOKHTTP()), trustManagers, null)
+ sslContext.init(arrayOf(getMTLSKeyManagerForOKHTTP()), arrayOf(handshakeTrustManager), null)
- builder.sslSocketFactory(sslContext.socketFactory, trustManagers[0] as X509TrustManager)
+ builder.sslSocketFactory(sslContext.socketFactory, platformTrustManager)
+ }
+
+ /**
+ * Wraps [trustManager] so user-installed CAs are honored as a fallback (see
+ * https://github.com/home-assistant/android/issues/5565).
+ *
+ * Returns [trustManager] unchanged when there is nothing to add:
+ * - before Android N, where user-installed CAs are already trusted by default;
+ * - when [trustManager] is not an [X509ExtendedTrustManager] (the composite needs the
+ * hostname-aware overloads); or
+ * - when there are no user-installed CAs.
+ */
+ private fun withUserInstalledCaFallback(trustManager: X509TrustManager): X509TrustManager {
+ if (!SdkVersion.isAtLeast(Build.VERSION_CODES.N)) return trustManager
+ val extended = trustManager as? X509ExtendedTrustManager ?: return trustManager
+ val userCaTrustManager = userInstalledCaTrustManager() ?: return trustManager
+ return CompositeX509ExtendedTrustManager(primary = extended, fallback = userCaTrustManager)
+ }
+
+ private fun defaultX509TrustManager(keyStore: KeyStore? = null): X509TrustManager? {
+ val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
+ factory.init(keyStore)
+ return factory.trustManagers.filterIsInstance().firstOrNull()
+ }
+
+ /**
+ * Builds a trust manager from the user-installed CAs (see [userInstalledCaKeyStore]), or `null`
+ * when there are none or AndroidCAStore can't be read.
+ */
+ @RequiresApi(Build.VERSION_CODES.N)
+ private fun userInstalledCaTrustManager(): X509ExtendedTrustManager? = try {
+ val androidCaStore = loadKeyStore("AndroidCAStore")
+ userInstalledCaKeyStore(androidCaStore)?.let { defaultX509TrustManager(it) as? X509ExtendedTrustManager }
+ } catch (e: GeneralSecurityException) {
+ Timber.w(e, "Could not read user-installed CAs, user-CA fallback disabled")
+ null
+ } catch (e: IOException) {
+ Timber.w(e, "Could not read user-installed CAs, user-CA fallback disabled")
+ null
}
private fun getMTLSKeyManagerForOKHTTP(): X509ExtendedKeyManager {
@@ -70,3 +126,26 @@ class TLSHelper @Inject constructor(
}
}
}
+
+/**
+ * Returns a new key store holding only the user-installed CA certificates of [caStore],
+ * or `null` if there are none.
+ */
+@VisibleForTesting
+internal fun userInstalledCaKeyStore(caStore: KeyStore): KeyStore? {
+ val userCaStore = loadKeyStore()
+
+ for (alias in caStore.aliases()) {
+ if (alias.startsWith("user:")) {
+ userCaStore.setCertificateEntry(alias, caStore.getCertificate(alias))
+ }
+ }
+ return userCaStore.takeIf { it.size() > 0 }
+}
+
+/**
+ * Returns a key store of the given [type] initialized with `load(null, null)`.
+ */
+@VisibleForTesting
+internal fun loadKeyStore(type: String = KeyStore.getDefaultType()): KeyStore =
+ KeyStore.getInstance(type).apply { load(null, null) }
diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManagerTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManagerTest.kt
new file mode 100644
index 00000000000..01f56eaab4c
--- /dev/null
+++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/CompositeX509ExtendedTrustManagerTest.kt
@@ -0,0 +1,135 @@
+package io.homeassistant.companion.android.common.data
+
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import java.net.Socket
+import java.security.cert.CertificateException
+import java.security.cert.X509Certificate
+import javax.net.ssl.SSLEngine
+import javax.net.ssl.X509ExtendedTrustManager
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.MethodSource
+
+class CompositeX509ExtendedTrustManagerTest {
+
+ private lateinit var primary: X509ExtendedTrustManager
+ private lateinit var fallback: X509ExtendedTrustManager
+ private lateinit var composite: CompositeX509ExtendedTrustManager
+
+ @BeforeEach
+ fun setUp() {
+ primary = mockk(relaxed = true)
+ fallback = mockk(relaxed = true)
+ composite = CompositeX509ExtendedTrustManager(primary = primary, fallback = fallback)
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("serverChecks")
+ fun `Given primary accepts when checking server certificate then fallback is not consulted`(check: ServerCheck) {
+ check.run(composite)
+
+ verify(exactly = 1) { check.run(primary) }
+ verify(exactly = 0) { check.run(fallback) }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("serverChecks")
+ fun `Given primary rejects and fallback accepts when checking server certificate then it is trusted`(
+ check: ServerCheck,
+ ) {
+ every { check.run(primary) } throws CertificateException("primary rejected")
+
+ // Must not throw: the fallback (relaxed mock) accepts the certificate.
+ check.run(composite)
+
+ verify(exactly = 1) { check.run(fallback) }
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("serverChecks")
+ fun `Given both reject when checking server certificate then primary error is thrown with fallback suppressed`(
+ check: ServerCheck,
+ ) {
+ val primaryError = CertificateException("primary rejected")
+ val fallbackError = CertificateException("fallback rejected")
+ every { check.run(primary) } throws primaryError
+ every { check.run(fallback) } throws fallbackError
+
+ val thrown = assertThrows(CertificateException::class.java) { check.run(composite) }
+
+ assertSame(primaryError, thrown)
+ assertTrue(thrown.suppressed.contains(fallbackError))
+ }
+
+ @Test
+ fun `Given primary throws a non-certificate error when checking server then it propagates without fallback`() {
+ every { primary.checkServerTrusted(CHAIN, AUTH_TYPE) } throws IllegalStateException("boom")
+
+ assertThrows(IllegalStateException::class.java) { composite.checkServerTrusted(CHAIN, AUTH_TYPE) }
+
+ verify(exactly = 0) { fallback.checkServerTrusted(CHAIN, AUTH_TYPE) }
+ }
+
+ @Test
+ fun `When checking client certificate then it delegates to the primary only`() {
+ composite.checkClientTrusted(CHAIN, AUTH_TYPE)
+ composite.checkClientTrusted(CHAIN, AUTH_TYPE, SOCKET)
+ composite.checkClientTrusted(CHAIN, AUTH_TYPE, ENGINE)
+
+ verify {
+ primary.checkClientTrusted(CHAIN, AUTH_TYPE)
+ primary.checkClientTrusted(CHAIN, AUTH_TYPE, SOCKET)
+ primary.checkClientTrusted(CHAIN, AUTH_TYPE, ENGINE)
+ }
+ verify(exactly = 0) {
+ fallback.checkClientTrusted(any(), any())
+ fallback.checkClientTrusted(any(), any(), any())
+ fallback.checkClientTrusted(any(), any(), any())
+ }
+ }
+
+ @Test
+ fun `When getting accepted issuers then only the primary issuers are returned`() {
+ val issuers = arrayOf(mockk())
+ every { primary.acceptedIssuers } returns issuers
+
+ assertSame(issuers, composite.acceptedIssuers)
+
+ verify(exactly = 0) { fallback.acceptedIssuers }
+ }
+
+ /**
+ * One of the three `checkServerTrusted` overloads, used to run each test against all of them.
+ * [run] calls the overload on any [X509ExtendedTrustManager]: the composite under test, or a mock
+ * inside a MockK `every`/`verify` block.
+ */
+ data class ServerCheck(private val label: String, val run: (X509ExtendedTrustManager) -> Unit) {
+ override fun toString(): String = label
+ }
+
+ companion object {
+ private const val AUTH_TYPE = "RSA"
+ private val CHAIN = arrayOf(mockk(relaxed = true))
+ private val SOCKET = mockk(relaxed = true)
+ private val ENGINE = mockk(relaxed = true)
+
+ @JvmStatic
+ fun serverChecks() = listOf(
+ ServerCheck("checkServerTrusted(chain, authType)") {
+ it.checkServerTrusted(CHAIN, AUTH_TYPE)
+ },
+ ServerCheck("checkServerTrusted(chain, authType, socket)") {
+ it.checkServerTrusted(CHAIN, AUTH_TYPE, SOCKET)
+ },
+ ServerCheck("checkServerTrusted(chain, authType, engine)") {
+ it.checkServerTrusted(CHAIN, AUTH_TYPE, ENGINE)
+ },
+ )
+ }
+}
diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt
new file mode 100644
index 00000000000..136334bacfe
--- /dev/null
+++ b/common/src/test/kotlin/io/homeassistant/companion/android/common/data/TLSHelperTest.kt
@@ -0,0 +1,98 @@
+package io.homeassistant.companion.android.common.data
+
+import java.security.KeyStore
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertNotNull
+import org.junit.jupiter.api.assertNull
+
+class TLSHelperTest {
+
+ @Test
+ fun `Given user and system CAs when filtering then only the user CA is kept`() {
+ val store = keyStoreOf(
+ "user:1a2b.0" to USER_CA,
+ "system:3c4d.0" to SYSTEM_CA,
+ )
+
+ val userCaStore = userInstalledCaKeyStore(store)
+
+ assertNotNull(userCaStore)
+ assertEquals(1, userCaStore.size())
+ assertNotNull(userCaStore.getCertificateAlias(USER_CA))
+ assertNull(userCaStore.getCertificateAlias(SYSTEM_CA))
+ }
+
+ @Test
+ fun `Given several user CAs when filtering then all of them are kept`() {
+ val store = keyStoreOf(
+ "user:1a2b.0" to USER_CA,
+ "user:5e6f.0" to USER_CA,
+ "system:3c4d.0" to SYSTEM_CA,
+ )
+
+ val userCaStore = userInstalledCaKeyStore(store)
+
+ assertNotNull(userCaStore)
+ assertEquals(2, userCaStore.size())
+ }
+
+ @Test
+ fun `Given only system CAs when filtering then it returns null`() {
+ val store = keyStoreOf("system:3c4d.0" to SYSTEM_CA)
+
+ assertNull(userInstalledCaKeyStore(store))
+ }
+
+ @Test
+ fun `Given an empty store when filtering then it returns null`() {
+ assertNull(userInstalledCaKeyStore(keyStoreOf()))
+ }
+
+ private fun keyStoreOf(vararg entries: Pair): KeyStore {
+ return loadKeyStore().apply {
+ entries.forEach { (alias, certificate) -> setCertificateEntry(alias, certificate) }
+ }
+ }
+
+ companion object {
+ private val USER_CA = parseCertificate(
+ """
+ -----BEGIN CERTIFICATE-----
+ MIIBgjCCASmgAwIBAgIUK3+GgehOO2LIwFaGYCqBPSfr9gAwCgYIKoZIzj0EAwIw
+ FzEVMBMGA1UEAwwMVGVzdCBVc2VyIENBMB4XDTI2MDYxNTA5MTYxMloXDTM2MDYx
+ MjA5MTYxMlowFzEVMBMGA1UEAwwMVGVzdCBVc2VyIENBMFkwEwYHKoZIzj0CAQYI
+ KoZIzj0DAQcDQgAEuWKQFJagdiv0NG8ROZUjviXdxMAYZVIPiBRtPKZ4xEdZ3TSy
+ 7gFe5R6ZRGD65P/554JCVv99Z5UiUdrn3A0Dt6NTMFEwHQYDVR0OBBYEFNTSQ/2z
+ oR4mJW8BfJjWudET1UR+MB8GA1UdIwQYMBaAFNTSQ/2zoR4mJW8BfJjWudET1UR+
+ MA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDRwAwRAIgWRDzptriiK+L11Em
+ KRPW95Y/AAevho3810p++8sAiK8CIBL/aktDSszIPhvngN/YObx9Rsa1X0GhbCur
+ WttX27wB
+ -----END CERTIFICATE-----
+ """.trimIndent(),
+ )
+
+ private val SYSTEM_CA = parseCertificate(
+ """
+ -----BEGIN CERTIFICATE-----
+ MIIBhjCCAS2gAwIBAgIULM9Sv5gifek7RA04cDsow+5DUC8wCgYIKoZIzj0EAwIw
+ GTEXMBUGA1UEAwwOVGVzdCBTeXN0ZW0gQ0EwHhcNMjYwNjE1MDkxNjEyWhcNMzYw
+ NjEyMDkxNjEyWjAZMRcwFQYDVQQDDA5UZXN0IFN5c3RlbSBDQTBZMBMGByqGSM49
+ AgEGCCqGSM49AwEHA0IABIoDRdoHkgePxBTI/FrxNtwmlmw2ASjf0nDsXrGzvaCe
+ Hmopa+Kk+XidaLBFM7uxs01pYfMwfsVM8EFuK4OOsxajUzBRMB0GA1UdDgQWBBR5
+ 9mR1Ojk9OqBRX1pZSuaNsSnkuTAfBgNVHSMEGDAWgBR59mR1Ojk9OqBRX1pZSuaN
+ sSnkuTAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIFWTMdMCTVkn
+ 0sVpgRzAofWK821vZgz9M2c9KOWLYrQNAiAAlkVuqpbn6Z+KXNvkMteeAyr0sTwn
+ 8x92kn3oJNr6WA==
+ -----END CERTIFICATE-----
+ """.trimIndent(),
+ )
+
+ private fun parseCertificate(pem: String): X509Certificate {
+ return CertificateFactory.getInstance("X.509")
+ .generateCertificate(pem.byteInputStream()) as X509Certificate
+ }
+ }
+}
diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt
index 5861f980a8b..362fa2466d3 100644
--- a/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt
+++ b/wear/src/main/kotlin/io/homeassistant/companion/android/HomeAssistantApplication.kt
@@ -21,6 +21,7 @@ import io.homeassistant.companion.android.common.util.SdkVersion
import io.homeassistant.companion.android.common.util.configureComposeDiagnosticStackTrace
import io.homeassistant.companion.android.complications.ComplicationReceiver
import io.homeassistant.companion.android.sensors.SensorReceiver
+import io.homeassistant.companion.android.util.threadPolicyIgnoredViolationRules
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -45,7 +46,7 @@ open class HomeAssistantApplication : Application() {
BuildConfig.DEBUG &&
!BuildConfig.NO_STRICT_MODE
) {
- HAStrictMode.enable()
+ HAStrictMode.enable(threadPolicyIgnoredViolationRules = threadPolicyIgnoredViolationRules)
}
Timber.i("Running ${BuildConfig.VERSION_NAME} on SDK $SdkVersion")
diff --git a/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt b/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt
new file mode 100644
index 00000000000..ffd687e0067
--- /dev/null
+++ b/wear/src/main/kotlin/io/homeassistant/companion/android/util/IgnoreViolationRules.kt
@@ -0,0 +1,30 @@
+package io.homeassistant.companion.android.util
+
+import android.os.Build
+import android.os.strictmode.DiskReadViolation
+import android.os.strictmode.Violation
+import androidx.annotation.RequiresApi
+import io.homeassistant.companion.android.common.data.HomeAssistantApis
+import io.homeassistant.companion.android.common.util.IgnoreViolationRule
+
+val threadPolicyIgnoredViolationRules: List = listOf(
+ IgnoreConfigureOkHttpClientDiskRead,
+)
+
+/**
+ * Ignores a [DiskReadViolation] raised while [HomeAssistantApis] configures its OkHttpClient:
+ * building the TLSHelper trust managers reads CA keystores from disk on some devices.
+ *
+ * Tracked by https://github.com/home-assistant/android/pull/7042
+ */
+private data object IgnoreConfigureOkHttpClientDiskRead : IgnoreViolationRule {
+ @RequiresApi(Build.VERSION_CODES.P)
+ override fun shouldIgnore(violation: Violation): Boolean {
+ if (violation !is DiskReadViolation) return false
+
+ return violation.stackTrace.any {
+ it.className == HomeAssistantApis::class.java.name &&
+ it.methodName == "configureOkHttpClient"
+ }
+ }
+}