mirror of
https://github.com/home-assistant/android
synced 2024-10-02 22:34:46 +00:00
Add Wear OS TLS client certificate authentication (TLS CCA) support (#3924)
* Add Wear OS TLS client certificate authentication (TLS CCA) support Wear OS does not currently allow the user to install certificates to the system-wide KeyChain for TLS CCA support. This commit adds support for using certificates from the app-specific Android KeyStore with UI for setting up a certificate during the Wear OS onboarding process. The manual step in the onboarding process is required since we cannot transmit certificates of the Android KeyChain because they are not extractable. In particular, this commit adds the following changes: * KeyStoreImpl as an additional KeyChainRepository interface implementation for loading and storing keys to the application's KeyStore. TLSHelper uses KeyStoreImpl as a fallback key manager. * UI for selecting a certificate file with GET_CONTENT intent during Wear OS onboarding in OnboardingActivity if it is detected that the Home Assistant may require TLS CCA. The UI includes a password check for the PKCS12 container. * During onboarding the app sends the raw PKCS12 data to Wear OS together with the container password. The connection is assumed to be encrypted and trusted so that no additional encryption is necessary. * Move PKCS12 password check to lifecycle scope * Remove redundant try-catch when loading PKCS12 * Simplify MobileAppIntegrationView layout code
This commit is contained in:
parent
df902803ab
commit
e0731c9c79
|
@ -6,6 +6,7 @@ import android.util.Log
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fasterxml.jackson.databind.JsonMappingException
|
import com.fasterxml.jackson.databind.JsonMappingException
|
||||||
|
@ -205,12 +206,21 @@ class SettingsWearViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun readUriData(uri: String): ByteArray {
|
||||||
|
if (uri.isEmpty()) return ByteArray(0)
|
||||||
|
return getApplication<HomeAssistantApplication>().contentResolver.openInputStream(uri.toUri())!!.buffered().use {
|
||||||
|
it.readBytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun sendAuthToWear(
|
fun sendAuthToWear(
|
||||||
url: String,
|
url: String,
|
||||||
authCode: String,
|
authCode: String,
|
||||||
deviceName: String,
|
deviceName: String,
|
||||||
deviceTrackingEnabled: Boolean,
|
deviceTrackingEnabled: Boolean,
|
||||||
notificationsEnabled: Boolean
|
notificationsEnabled: Boolean,
|
||||||
|
tlsClientCertificateUri: String,
|
||||||
|
tlsClientCertificatePassword: String
|
||||||
) {
|
) {
|
||||||
_hasData.value = false // Show loading indicator
|
_hasData.value = false // Show loading indicator
|
||||||
val putDataRequest = PutDataMapRequest.create("/authenticate").run {
|
val putDataRequest = PutDataMapRequest.create("/authenticate").run {
|
||||||
|
@ -221,6 +231,8 @@ class SettingsWearViewModel @Inject constructor(
|
||||||
dataMap.putString("DeviceName", deviceName)
|
dataMap.putString("DeviceName", deviceName)
|
||||||
dataMap.putBoolean("LocationTracking", deviceTrackingEnabled)
|
dataMap.putBoolean("LocationTracking", deviceTrackingEnabled)
|
||||||
dataMap.putBoolean("Notifications", notificationsEnabled)
|
dataMap.putBoolean("Notifications", notificationsEnabled)
|
||||||
|
dataMap.putByteArray("TLSClientCertificateData", readUriData(tlsClientCertificateUri))
|
||||||
|
dataMap.putString("TLSClientCertificatePassword", tlsClientCertificatePassword)
|
||||||
setUrgent()
|
setUrgent()
|
||||||
asPutDataRequest()
|
asPutDataRequest()
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.gms.wearable.Node
|
import com.google.android.gms.wearable.Node
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import io.homeassistant.companion.android.HomeAssistantApplication
|
||||||
import io.homeassistant.companion.android.onboarding.OnboardApp
|
import io.homeassistant.companion.android.onboarding.OnboardApp
|
||||||
import io.homeassistant.companion.android.settings.wear.SettingsWearViewModel
|
import io.homeassistant.companion.android.settings.wear.SettingsWearViewModel
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
@ -71,15 +72,16 @@ class SettingsWearMainView : AppCompatActivity() {
|
||||||
locationTrackingPossible = false,
|
locationTrackingPossible = false,
|
||||||
notificationsPossible = false,
|
notificationsPossible = false,
|
||||||
isWatch = true,
|
isWatch = true,
|
||||||
discoveryOptions = OnboardApp.DiscoveryOptions.ADD_EXISTING_EXTERNAL
|
discoveryOptions = OnboardApp.DiscoveryOptions.ADD_EXISTING_EXTERNAL,
|
||||||
|
mayRequireTlsClientCertificate = (application as HomeAssistantApplication).keyChainRepository.getPrivateKey() != null
|
||||||
) // While notifications are technically possible, the app can't handle this for the Wear device
|
) // While notifications are technically possible, the app can't handle this for the Wear device
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onOnboardingComplete(result: OnboardApp.Output?) {
|
private fun onOnboardingComplete(result: OnboardApp.Output?) {
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
val (url, authCode, deviceName, deviceTrackingEnabled, _) = result
|
val (url, authCode, deviceName, deviceTrackingEnabled, _, tlsCertificateUri, tlsCertificatePassword) = result
|
||||||
settingsWearViewModel.sendAuthToWear(url, authCode, deviceName, deviceTrackingEnabled, true)
|
settingsWearViewModel.sendAuthToWear(url, authCode, deviceName, deviceTrackingEnabled, true, tlsCertificateUri, tlsCertificatePassword)
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "onOnboardingComplete: Activity result returned null intent data")
|
Log.e(TAG, "onOnboardingComplete: Activity result returned null intent data")
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,7 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
open class HomeAssistantApplication : Application() {
|
open class HomeAssistantApplication : Application() {
|
||||||
|
@ -40,6 +41,7 @@ open class HomeAssistantApplication : Application() {
|
||||||
lateinit var prefsRepository: PrefsRepository
|
lateinit var prefsRepository: PrefsRepository
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
@Named("keyChainRepository")
|
||||||
lateinit var keyChainRepository: KeyChainRepository
|
lateinit var keyChainRepository: KeyChainRepository
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
|
|
@ -15,6 +15,7 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
|
||||||
private const val EXTRA_NOTIFICATIONS_POSSIBLE = "notifications_possible"
|
private const val EXTRA_NOTIFICATIONS_POSSIBLE = "notifications_possible"
|
||||||
private const val EXTRA_IS_WATCH = "extra_is_watch"
|
private const val EXTRA_IS_WATCH = "extra_is_watch"
|
||||||
private const val EXTRA_DISCOVERY_OPTIONS = "extra_discovery_options"
|
private const val EXTRA_DISCOVERY_OPTIONS = "extra_discovery_options"
|
||||||
|
private const val EXTRA_MAY_REQUIRE_TLS_CLIENT_CERTIFICATE = "may_require_tls_client_certificate"
|
||||||
|
|
||||||
fun parseInput(intent: Intent): Input = Input(
|
fun parseInput(intent: Intent): Input = Input(
|
||||||
url = intent.getStringExtra(EXTRA_URL),
|
url = intent.getStringExtra(EXTRA_URL),
|
||||||
|
@ -22,7 +23,8 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
|
||||||
locationTrackingPossible = intent.getBooleanExtra(EXTRA_LOCATION_TRACKING_POSSIBLE, false),
|
locationTrackingPossible = intent.getBooleanExtra(EXTRA_LOCATION_TRACKING_POSSIBLE, false),
|
||||||
notificationsPossible = intent.getBooleanExtra(EXTRA_NOTIFICATIONS_POSSIBLE, true),
|
notificationsPossible = intent.getBooleanExtra(EXTRA_NOTIFICATIONS_POSSIBLE, true),
|
||||||
isWatch = intent.getBooleanExtra(EXTRA_IS_WATCH, false),
|
isWatch = intent.getBooleanExtra(EXTRA_IS_WATCH, false),
|
||||||
discoveryOptions = intent.getStringExtra(EXTRA_DISCOVERY_OPTIONS)?.let { DiscoveryOptions.valueOf(it) }
|
discoveryOptions = intent.getStringExtra(EXTRA_DISCOVERY_OPTIONS)?.let { DiscoveryOptions.valueOf(it) },
|
||||||
|
mayRequireTlsClientCertificate = intent.getBooleanExtra(EXTRA_MAY_REQUIRE_TLS_CLIENT_CERTIFICATE, false)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,7 +42,8 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
|
||||||
val locationTrackingPossible: Boolean = BuildConfig.FLAVOR == "full",
|
val locationTrackingPossible: Boolean = BuildConfig.FLAVOR == "full",
|
||||||
val notificationsPossible: Boolean = true,
|
val notificationsPossible: Boolean = true,
|
||||||
val isWatch: Boolean = false,
|
val isWatch: Boolean = false,
|
||||||
val discoveryOptions: DiscoveryOptions? = null
|
val discoveryOptions: DiscoveryOptions? = null,
|
||||||
|
val mayRequireTlsClientCertificate: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Output(
|
data class Output(
|
||||||
|
@ -48,7 +51,9 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
|
||||||
val authCode: String,
|
val authCode: String,
|
||||||
val deviceName: String,
|
val deviceName: String,
|
||||||
val deviceTrackingEnabled: Boolean,
|
val deviceTrackingEnabled: Boolean,
|
||||||
val notificationsEnabled: Boolean
|
val notificationsEnabled: Boolean,
|
||||||
|
val tlsClientCertificateUri: String,
|
||||||
|
val tlsClientCertificatePassword: String
|
||||||
) {
|
) {
|
||||||
fun toIntent(): Intent {
|
fun toIntent(): Intent {
|
||||||
return Intent().apply {
|
return Intent().apply {
|
||||||
|
@ -57,6 +62,22 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
|
||||||
putExtra("DeviceName", deviceName)
|
putExtra("DeviceName", deviceName)
|
||||||
putExtra("LocationTracking", deviceTrackingEnabled)
|
putExtra("LocationTracking", deviceTrackingEnabled)
|
||||||
putExtra("Notifications", notificationsEnabled)
|
putExtra("Notifications", notificationsEnabled)
|
||||||
|
putExtra("TLSClientCertificateUri", tlsClientCertificateUri)
|
||||||
|
putExtra("TLSClientCertificatePassword", tlsClientCertificatePassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromIntent(intent: Intent): Output {
|
||||||
|
return Output(
|
||||||
|
url = intent.getStringExtra("URL").toString(),
|
||||||
|
authCode = intent.getStringExtra("AuthCode").toString(),
|
||||||
|
deviceName = intent.getStringExtra("DeviceName").toString(),
|
||||||
|
deviceTrackingEnabled = intent.getBooleanExtra("LocationTracking", false),
|
||||||
|
notificationsEnabled = intent.getBooleanExtra("Notifications", true),
|
||||||
|
tlsClientCertificateUri = intent.getStringExtra("TLSClientCertificateUri").toString(),
|
||||||
|
tlsClientCertificatePassword = intent.getStringExtra("TLSClientCertificatePassword").toString()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -69,6 +90,7 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
|
||||||
putExtra(EXTRA_NOTIFICATIONS_POSSIBLE, input.notificationsPossible)
|
putExtra(EXTRA_NOTIFICATIONS_POSSIBLE, input.notificationsPossible)
|
||||||
putExtra(EXTRA_IS_WATCH, input.isWatch)
|
putExtra(EXTRA_IS_WATCH, input.isWatch)
|
||||||
putExtra(EXTRA_DISCOVERY_OPTIONS, input.discoveryOptions?.toString())
|
putExtra(EXTRA_DISCOVERY_OPTIONS, input.discoveryOptions?.toString())
|
||||||
|
putExtra(EXTRA_MAY_REQUIRE_TLS_CLIENT_CERTIFICATE, input.mayRequireTlsClientCertificate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,17 +99,6 @@ class OnboardApp : ActivityResultContract<OnboardApp.Input, OnboardApp.Output?>(
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val url = intent.getStringExtra("URL").toString()
|
return Output.fromIntent(intent)
|
||||||
val authCode = intent.getStringExtra("AuthCode").toString()
|
|
||||||
val deviceName = intent.getStringExtra("DeviceName").toString()
|
|
||||||
val deviceTrackingEnabled = intent.getBooleanExtra("LocationTracking", false)
|
|
||||||
val notificationsEnabled = intent.getBooleanExtra("Notifications", true)
|
|
||||||
return Output(
|
|
||||||
url = url,
|
|
||||||
authCode = authCode,
|
|
||||||
deviceName = deviceName,
|
|
||||||
deviceTrackingEnabled = deviceTrackingEnabled,
|
|
||||||
notificationsEnabled = notificationsEnabled
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,6 +44,7 @@ class OnboardingActivity : BaseActivity() {
|
||||||
}
|
}
|
||||||
viewModel.deviceIsWatch = input.isWatch
|
viewModel.deviceIsWatch = input.isWatch
|
||||||
viewModel.discoveryOptions = input.discoveryOptions
|
viewModel.discoveryOptions = input.discoveryOptions
|
||||||
|
viewModel.mayRequireTlsClientCertificate = input.mayRequireTlsClientCertificate
|
||||||
|
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
supportFragmentManager.commit {
|
supportFragmentManager.commit {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package io.homeassistant.companion.android.onboarding
|
package io.homeassistant.companion.android.onboarding
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.webkit.URLUtil
|
import android.webkit.URLUtil
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
@ -52,6 +53,11 @@ class OnboardingViewModel @Inject constructor(
|
||||||
var locationTrackingEnabled by mutableStateOf(false)
|
var locationTrackingEnabled by mutableStateOf(false)
|
||||||
val notificationsPossible = mutableStateOf(true)
|
val notificationsPossible = mutableStateOf(true)
|
||||||
var notificationsEnabled by mutableStateOf(false)
|
var notificationsEnabled by mutableStateOf(false)
|
||||||
|
var mayRequireTlsClientCertificate by mutableStateOf(false)
|
||||||
|
var tlsClientCertificateUri: Uri? by mutableStateOf(null)
|
||||||
|
var tlsClientCertificateFilename by mutableStateOf("")
|
||||||
|
var tlsClientCertificatePassword by mutableStateOf("")
|
||||||
|
var tlsClientCertificatePasswordCorrect by mutableStateOf(false)
|
||||||
|
|
||||||
private var authCode = ""
|
private var authCode = ""
|
||||||
|
|
||||||
|
@ -81,7 +87,9 @@ class OnboardingViewModel @Inject constructor(
|
||||||
authCode = authCode,
|
authCode = authCode,
|
||||||
deviceName = deviceName.value,
|
deviceName = deviceName.value,
|
||||||
deviceTrackingEnabled = locationTrackingEnabled,
|
deviceTrackingEnabled = locationTrackingEnabled,
|
||||||
notificationsEnabled = notificationsEnabled
|
notificationsEnabled = notificationsEnabled,
|
||||||
|
tlsClientCertificateUri = tlsClientCertificateUri?.toString() ?: "",
|
||||||
|
tlsClientCertificatePassword = tlsClientCertificatePassword
|
||||||
)
|
)
|
||||||
|
|
||||||
fun onDiscoveryActive() {
|
fun onDiscoveryActive() {
|
||||||
|
|
|
@ -36,6 +36,7 @@ import io.homeassistant.companion.android.util.isStarted
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
import io.homeassistant.companion.android.common.R as commonR
|
import io.homeassistant.companion.android.common.R as commonR
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -54,6 +55,7 @@ class AuthenticationFragment : Fragment() {
|
||||||
lateinit var themesManager: ThemesManager
|
lateinit var themesManager: ThemesManager
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
@Named("keyChainRepository")
|
||||||
lateinit var keyChainRepository: KeyChainRepository
|
lateinit var keyChainRepository: KeyChainRepository
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
|
|
@ -8,6 +8,7 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
|
import android.provider.OpenableColumns
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -18,6 +19,7 @@ import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.core.content.getSystemService
|
import androidx.core.content.getSystemService
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.accompanist.themeadapter.material.MdcTheme
|
import com.google.accompanist.themeadapter.material.MdcTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import io.homeassistant.companion.android.R
|
import io.homeassistant.companion.android.R
|
||||||
|
@ -25,6 +27,9 @@ import io.homeassistant.companion.android.common.util.DisabledLocationHandler
|
||||||
import io.homeassistant.companion.android.onboarding.OnboardingViewModel
|
import io.homeassistant.companion.android.onboarding.OnboardingViewModel
|
||||||
import io.homeassistant.companion.android.onboarding.notifications.NotificationPermissionFragment
|
import io.homeassistant.companion.android.onboarding.notifications.NotificationPermissionFragment
|
||||||
import io.homeassistant.companion.android.sensors.LocationSensorManager
|
import io.homeassistant.companion.android.sensors.LocationSensorManager
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.IOException
|
||||||
|
import java.security.KeyStore
|
||||||
import io.homeassistant.companion.android.common.R as commonR
|
import io.homeassistant.companion.android.common.R as commonR
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
|
@ -33,6 +38,9 @@ class MobileAppIntegrationFragment : Fragment() {
|
||||||
private val requestLocationPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
private val requestLocationPermissions = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||||
onLocationPermissionResult(it)
|
onLocationPermissionResult(it)
|
||||||
}
|
}
|
||||||
|
private val getContent = registerForActivityResult(ActivityResultContracts.GetContent()) {
|
||||||
|
onGetContentResult(it)
|
||||||
|
}
|
||||||
|
|
||||||
private var dialog: AlertDialog? = null
|
private var dialog: AlertDialog? = null
|
||||||
private val viewModel by activityViewModels<OnboardingViewModel>()
|
private val viewModel by activityViewModels<OnboardingViewModel>()
|
||||||
|
@ -49,6 +57,8 @@ class MobileAppIntegrationFragment : Fragment() {
|
||||||
onboardingViewModel = viewModel,
|
onboardingViewModel = viewModel,
|
||||||
openPrivacyPolicy = this@MobileAppIntegrationFragment::openPrivacyPolicy,
|
openPrivacyPolicy = this@MobileAppIntegrationFragment::openPrivacyPolicy,
|
||||||
onLocationTrackingChanged = this@MobileAppIntegrationFragment::onLocationTrackingChanged,
|
onLocationTrackingChanged = this@MobileAppIntegrationFragment::onLocationTrackingChanged,
|
||||||
|
onSelectTLSCertificateClicked = this@MobileAppIntegrationFragment::onSelectTLSCertificateClicked,
|
||||||
|
onCheckPassword = this@MobileAppIntegrationFragment::onCheckTLSCertificatePassword,
|
||||||
onFinishClicked = this@MobileAppIntegrationFragment::onComplete
|
onFinishClicked = this@MobileAppIntegrationFragment::onComplete
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -88,6 +98,41 @@ class MobileAppIntegrationFragment : Fragment() {
|
||||||
viewModel.setLocationTracking(checked)
|
viewModel.setLocationTracking(checked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun onSelectTLSCertificateClicked() {
|
||||||
|
getContent.launch("*/*")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCheckTLSCertificatePassword(password: String) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
var ok: Boolean
|
||||||
|
context?.contentResolver?.openInputStream(viewModel.tlsClientCertificateUri!!)!!.buffered().use {
|
||||||
|
val keystore = KeyStore.getInstance("PKCS12")
|
||||||
|
ok = try {
|
||||||
|
keystore.load(it, password.toCharArray())
|
||||||
|
true
|
||||||
|
} catch (e: IOException) {
|
||||||
|
// we cannot determine if it failed due to wrong password or other reasons, since e.cause is not set to UnrecoverableKeyException
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewModel.tlsClientCertificatePasswordCorrect = ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("Range")
|
||||||
|
private fun onGetContentResult(uri: Uri?) {
|
||||||
|
if (uri != null) {
|
||||||
|
context?.contentResolver?.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
cursor.moveToFirst()
|
||||||
|
|
||||||
|
viewModel.tlsClientCertificateUri = uri
|
||||||
|
viewModel.tlsClientCertificateFilename = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
||||||
|
}
|
||||||
|
// check with empty password
|
||||||
|
onCheckTLSCertificatePassword("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun requestPermissions(sensorId: String) {
|
private fun requestPermissions(sensorId: String) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
requestLocationPermissions.launch(
|
requestLocationPermissions.launch(
|
||||||
|
|
|
@ -3,19 +3,26 @@ package io.homeassistant.companion.android.onboarding.integration
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.Button
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.Icon
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Switch
|
import androidx.compose.material.Switch
|
||||||
import androidx.compose.material.SwitchDefaults
|
import androidx.compose.material.SwitchDefaults
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.TextButton
|
import androidx.compose.material.TextButton
|
||||||
import androidx.compose.material.TextField
|
import androidx.compose.material.TextField
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Error
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
@ -26,6 +33,8 @@ import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
|
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
|
||||||
import io.homeassistant.companion.android.onboarding.OnboardingHeaderView
|
import io.homeassistant.companion.android.onboarding.OnboardingHeaderView
|
||||||
|
@ -38,6 +47,8 @@ fun MobileAppIntegrationView(
|
||||||
onboardingViewModel: OnboardingViewModel,
|
onboardingViewModel: OnboardingViewModel,
|
||||||
openPrivacyPolicy: () -> Unit,
|
openPrivacyPolicy: () -> Unit,
|
||||||
onLocationTrackingChanged: (Boolean) -> Unit,
|
onLocationTrackingChanged: (Boolean) -> Unit,
|
||||||
|
onSelectTLSCertificateClicked: () -> Unit,
|
||||||
|
onCheckPassword: (String) -> Unit,
|
||||||
onFinishClicked: () -> Unit
|
onFinishClicked: () -> Unit
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
@ -97,6 +108,69 @@ fun MobileAppIntegrationView(
|
||||||
fontWeight = FontWeight.Light
|
fontWeight = FontWeight.Light
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (onboardingViewModel.deviceIsWatch && onboardingViewModel.mayRequireTlsClientCertificate) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = commonR.string.tls_cert_onboarding_title),
|
||||||
|
style = MaterialTheme.typography.h6
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = commonR.string.tls_cert_onboarding_description),
|
||||||
|
fontWeight = FontWeight.Light
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Button(onClick = onSelectTLSCertificateClicked) {
|
||||||
|
Text(text = stringResource(id = commonR.string.select_file))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = onboardingViewModel.tlsClientCertificateFilename,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (onboardingViewModel.tlsClientCertificateUri != null) {
|
||||||
|
TextField(
|
||||||
|
value = onboardingViewModel.tlsClientCertificatePassword,
|
||||||
|
onValueChange = {
|
||||||
|
onboardingViewModel.tlsClientCertificatePassword = it
|
||||||
|
onCheckPassword(onboardingViewModel.tlsClientCertificatePassword)
|
||||||
|
},
|
||||||
|
label = { Text(text = stringResource(id = commonR.string.password)) },
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
keyboardController?.hide()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
if (onboardingViewModel.tlsClientCertificatePasswordCorrect) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
tint = colorResource(commonR.color.colorOnBackground),
|
||||||
|
contentDescription = stringResource(id = commonR.string.password_correct)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Error,
|
||||||
|
tint = colorResource(commonR.color.colorWarning),
|
||||||
|
contentDescription = stringResource(id = commonR.string.password_incorrect)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isError = !onboardingViewModel.tlsClientCertificatePasswordCorrect,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
TextButton(onClick = openPrivacyPolicy) {
|
TextButton(onClick = openPrivacyPolicy) {
|
||||||
Text(stringResource(id = commonR.string.privacy_url))
|
Text(stringResource(id = commonR.string.privacy_url))
|
||||||
}
|
}
|
||||||
|
@ -108,7 +182,10 @@ fun MobileAppIntegrationView(
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
Button(onClick = onFinishClicked) {
|
Button(
|
||||||
|
onClick = onFinishClicked,
|
||||||
|
enabled = !onboardingViewModel.deviceIsWatch || onboardingViewModel.tlsClientCertificateUri == null || onboardingViewModel.tlsClientCertificatePasswordCorrect
|
||||||
|
) {
|
||||||
Text(stringResource(id = commonR.string.continue_connect))
|
Text(stringResource(id = commonR.string.continue_connect))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,8 +19,9 @@ import java.security.PrivateKey
|
||||||
import java.security.cert.CertificateException
|
import java.security.cert.CertificateException
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
|
|
||||||
open class TLSWebViewClient @Inject constructor(private var keyChainRepository: KeyChainRepository) : WebViewClient() {
|
open class TLSWebViewClient @Inject constructor(@Named("keyChainRepository") private var keyChainRepository: KeyChainRepository) : WebViewClient() {
|
||||||
|
|
||||||
var isTLSClientAuthNeeded = false
|
var isTLSClientAuthNeeded = false
|
||||||
private set
|
private set
|
||||||
|
|
|
@ -116,6 +116,7 @@ import org.chromium.net.CronetEngine
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
import io.homeassistant.companion.android.common.R as commonR
|
import io.homeassistant.companion.android.common.R as commonR
|
||||||
|
|
||||||
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
@OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||||
|
@ -189,6 +190,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi
|
||||||
lateinit var authenticationDao: AuthenticationDao
|
lateinit var authenticationDao: AuthenticationDao
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
|
@Named("keyChainRepository")
|
||||||
lateinit var keyChainRepository: KeyChainRepository
|
lateinit var keyChainRepository: KeyChainRepository
|
||||||
|
|
||||||
private lateinit var binding: ActivityWebviewBinding
|
private lateinit var binding: ActivityWebviewBinding
|
||||||
|
|
|
@ -18,6 +18,7 @@ import io.homeassistant.companion.android.common.data.authentication.impl.Authen
|
||||||
import io.homeassistant.companion.android.common.data.integration.impl.IntegrationService
|
import io.homeassistant.companion.android.common.data.integration.impl.IntegrationService
|
||||||
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
|
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
|
||||||
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepositoryImpl
|
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepositoryImpl
|
||||||
|
import io.homeassistant.companion.android.common.data.keychain.KeyStoreRepositoryImpl
|
||||||
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
|
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
|
||||||
import io.homeassistant.companion.android.common.data.prefs.PrefsRepositoryImpl
|
import io.homeassistant.companion.android.common.data.prefs.PrefsRepositoryImpl
|
||||||
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
|
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
|
||||||
|
@ -159,8 +160,14 @@ abstract class DataModule {
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
|
@Named("keyChainRepository")
|
||||||
abstract fun bindKeyChainRepository(keyChainRepository: KeyChainRepositoryImpl): KeyChainRepository
|
abstract fun bindKeyChainRepository(keyChainRepository: KeyChainRepositoryImpl): KeyChainRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
@Named("keyStore")
|
||||||
|
abstract fun bindKeyStore(keyStore: KeyStoreRepositoryImpl): KeyChainRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindServerManager(serverManager: ServerManagerImpl): ServerManager
|
abstract fun bindServerManager(serverManager: ServerManagerImpl): ServerManager
|
||||||
|
|
|
@ -9,12 +9,16 @@ import java.security.Principal
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.TrustManagerFactory
|
import javax.net.ssl.TrustManagerFactory
|
||||||
import javax.net.ssl.X509ExtendedKeyManager
|
import javax.net.ssl.X509ExtendedKeyManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
class TLSHelper @Inject constructor(private val keyChainRepository: KeyChainRepository) {
|
class TLSHelper @Inject constructor(
|
||||||
|
@Named("keyChainRepository") private val keyChainRepository: KeyChainRepository,
|
||||||
|
@Named("keyStore") private val keyStore: KeyChainRepository
|
||||||
|
) {
|
||||||
|
|
||||||
fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) {
|
fun setupOkHttpClientSSLSocketFactory(builder: OkHttpClient.Builder) {
|
||||||
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
@ -64,7 +68,7 @@ class TLSHelper @Inject constructor(private val keyChainRepository: KeyChainRepo
|
||||||
|
|
||||||
// block until a chain is provided via the TLSWebView
|
// block until a chain is provided via the TLSWebView
|
||||||
runBlocking {
|
runBlocking {
|
||||||
chain = keyChainRepository.getCertificateChain()
|
chain = keyChainRepository.getCertificateChain() ?: keyStore.getCertificateChain()
|
||||||
}
|
}
|
||||||
|
|
||||||
return chain
|
return chain
|
||||||
|
@ -75,7 +79,7 @@ class TLSHelper @Inject constructor(private val keyChainRepository: KeyChainRepo
|
||||||
|
|
||||||
// block until a key is provided via the TLSWebView
|
// block until a key is provided via the TLSWebView
|
||||||
runBlocking {
|
runBlocking {
|
||||||
key = keyChainRepository.getPrivateKey()
|
key = keyChainRepository.getPrivateKey() ?: keyStore.getPrivateKey()
|
||||||
}
|
}
|
||||||
|
|
||||||
return key
|
return key
|
||||||
|
|
|
@ -12,6 +12,8 @@ interface KeyChainRepository {
|
||||||
|
|
||||||
suspend fun load(context: Context)
|
suspend fun load(context: Context)
|
||||||
|
|
||||||
|
suspend fun setData(alias: String, privateKey: PrivateKey, certificateChain: Array<X509Certificate>)
|
||||||
|
|
||||||
fun getAlias(): String?
|
fun getAlias(): String?
|
||||||
|
|
||||||
fun getPrivateKey(): PrivateKey?
|
fun getPrivateKey(): PrivateKey?
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.util.Log
|
||||||
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
|
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.lang.UnsupportedOperationException
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
@ -40,6 +41,10 @@ class KeyChainRepositoryImpl @Inject constructor(
|
||||||
doLoad(context)
|
doLoad(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setData(alias: String, privateKey: PrivateKey, certificateChain: Array<X509Certificate>) {
|
||||||
|
throw UnsupportedOperationException("setData not supported for KeyChainRepositoryImpl")
|
||||||
|
}
|
||||||
|
|
||||||
override fun getAlias(): String? {
|
override fun getAlias(): String? {
|
||||||
return alias
|
return alias
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
package io.homeassistant.companion.android.common.data.keychain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.KeyStore.PrivateKeyEntry
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class KeyStoreRepositoryImpl @Inject constructor() : KeyChainRepository {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "KeyStoreRepository"
|
||||||
|
const val ALIAS = "TLSClientCertificate"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var alias: String? = null
|
||||||
|
private var key: PrivateKey? = null
|
||||||
|
private var chain: Array<X509Certificate>? = null
|
||||||
|
|
||||||
|
override suspend fun clear() {
|
||||||
|
// intentionally left empty
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(context: Context, alias: String) = withContext(Dispatchers.IO) {
|
||||||
|
this@KeyStoreRepositoryImpl.alias = alias
|
||||||
|
doLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(context: Context) {
|
||||||
|
throw IllegalArgumentException("Key alias cannot be null.")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setData(alias: String, privateKey: PrivateKey, certificateChain: Array<X509Certificate>) = withContext(Dispatchers.IO) {
|
||||||
|
// clear state
|
||||||
|
this@KeyStoreRepositoryImpl.alias = null
|
||||||
|
this@KeyStoreRepositoryImpl.key = null
|
||||||
|
this@KeyStoreRepositoryImpl.chain = null
|
||||||
|
|
||||||
|
// store and load certificate to/from KeyStore
|
||||||
|
doStore(alias, privateKey, certificateChain)
|
||||||
|
this@KeyStoreRepositoryImpl.alias = alias
|
||||||
|
doLoad()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAlias(): String? {
|
||||||
|
return alias
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPrivateKey(): PrivateKey? {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCertificateChain(): Array<X509Certificate>? {
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun doLoad() {
|
||||||
|
if (alias != null && alias?.isNotEmpty() == true) {
|
||||||
|
val aks = KeyStore.getInstance("AndroidKeyStore").apply {
|
||||||
|
load(null)
|
||||||
|
if (!containsAlias(alias)) return
|
||||||
|
}
|
||||||
|
val entry = try {
|
||||||
|
aks.getEntry(alias, null) as PrivateKeyEntry
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception getting KeyStore.Entry", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (entry != null) {
|
||||||
|
if (chain == null) {
|
||||||
|
chain = try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
entry.certificateChain as Array<X509Certificate>
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception getting certificate chain", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key == null) {
|
||||||
|
key = try {
|
||||||
|
entry.privateKey
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception getting private key", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun doStore(alias: String, key: PrivateKey, chain: Array<X509Certificate>) {
|
||||||
|
try {
|
||||||
|
KeyStore.getInstance("AndroidKeyStore").apply {
|
||||||
|
load(null)
|
||||||
|
setEntry(alias, PrivateKeyEntry(key, chain), null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception storing KeyStore.Entry", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -490,6 +490,8 @@
|
||||||
<string name="other_settings">Other settings</string>
|
<string name="other_settings">Other settings</string>
|
||||||
<string name="other">Other</string>
|
<string name="other">Other</string>
|
||||||
<string name="password">Password</string>
|
<string name="password">Password</string>
|
||||||
|
<string name="password_correct">Correct password</string>
|
||||||
|
<string name="password_incorrect">Incorrect password</string>
|
||||||
<string name="permission_explanation_calls">In order to track incoming and outgoing call\'s occurrence we need access to your phone state. No phone numbers or other call details will be stored.</string>
|
<string name="permission_explanation_calls">In order to track incoming and outgoing call\'s occurrence we need access to your phone state. No phone numbers or other call details will be stored.</string>
|
||||||
<string name="permission_explanation">In order to use location tracking features or different connection urls based on WiFi SSID we need access to your location. If you want consistent background updates you will also need to allow background processing</string>
|
<string name="permission_explanation">In order to use location tracking features or different connection urls based on WiFi SSID we need access to your location. If you want consistent background updates you will also need to allow background processing</string>
|
||||||
<string name="persistent_notification">Persistent notification</string>
|
<string name="persistent_notification">Persistent notification</string>
|
||||||
|
@ -551,6 +553,7 @@
|
||||||
<string name="security">Security</string>
|
<string name="security">Security</string>
|
||||||
<string name="select">Select</string>
|
<string name="select">Select</string>
|
||||||
<string name="select_entity_to_display">Select entity to display</string>
|
<string name="select_entity_to_display">Select entity to display</string>
|
||||||
|
<string name="select_file">Select file</string>
|
||||||
<string name="select_instance">Select your Home Assistant server</string>
|
<string name="select_instance">Select your Home Assistant server</string>
|
||||||
<string name="sensor_description_active_notification_count">Total count of active notifications that are visible to the user including silent, persistent and the Sensor Worker notifications.</string>
|
<string name="sensor_description_active_notification_count">Total count of active notifications that are visible to the user including silent, persistent and the Sensor Worker notifications.</string>
|
||||||
<string name="sensor_description_app_importance">If the app is in the foreground, background or any other state it can be</string>
|
<string name="sensor_description_app_importance">If the app is in the foreground, background or any other state it can be</string>
|
||||||
|
@ -1093,6 +1096,8 @@
|
||||||
<string name="tls_cert_title">TLS client certificate error</string>
|
<string name="tls_cert_title">TLS client certificate error</string>
|
||||||
<string name="tls_cert_not_found_message">The remote site requires a client certificate.\nPlease install the required credential on your phone and try again.</string>
|
<string name="tls_cert_not_found_message">The remote site requires a client certificate.\nPlease install the required credential on your phone and try again.</string>
|
||||||
<string name="tls_cert_expired_message">The certificate is not yet or no longer valid.\nPlease install a new credential on your phone and try again.</string>
|
<string name="tls_cert_expired_message">The certificate is not yet or no longer valid.\nPlease install a new credential on your phone and try again.</string>
|
||||||
|
<string name="tls_cert_onboarding_description">If your Home Assistant instance requires TLS client certificate authentication, select a TLS client certificate file to install it on your Wear OS device.</string>
|
||||||
|
<string name="tls_cert_onboarding_title">TLS client certificate</string>
|
||||||
<string name="basic_sensor_name_battery_power">Battery power</string>
|
<string name="basic_sensor_name_battery_power">Battery power</string>
|
||||||
<string name="widget_checkbox_require_authentication">Require authentication</string>
|
<string name="widget_checkbox_require_authentication">Require authentication</string>
|
||||||
<string name="widget_error_authenticating">Error authenticating - is an authentication method configured?</string>
|
<string name="widget_error_authenticating">Error authenticating - is an authentication method configured?</string>
|
||||||
|
|
|
@ -11,14 +11,32 @@ import android.nfc.NfcAdapter
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
|
||||||
|
import io.homeassistant.companion.android.common.data.keychain.KeyStoreRepositoryImpl
|
||||||
import io.homeassistant.companion.android.complications.ComplicationReceiver
|
import io.homeassistant.companion.android.complications.ComplicationReceiver
|
||||||
import io.homeassistant.companion.android.sensors.SensorReceiver
|
import io.homeassistant.companion.android.sensors.SensorReceiver
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
open class HomeAssistantApplication : Application() {
|
open class HomeAssistantApplication : Application() {
|
||||||
|
private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO + Job())
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@Named("keyStore")
|
||||||
|
lateinit var keyStore: KeyChainRepository
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
ioScope.launch {
|
||||||
|
keyStore.load(applicationContext, KeyStoreRepositoryImpl.ALIAS)
|
||||||
|
}
|
||||||
|
|
||||||
val sensorReceiver = SensorReceiver()
|
val sensorReceiver = SensorReceiver()
|
||||||
// This will cause the sensor to be updated every time the OS broadcasts that a cable was plugged/unplugged.
|
// This will cause the sensor to be updated every time the OS broadcasts that a cable was plugged/unplugged.
|
||||||
// This should be nearly instantaneous allowing automations to fire immediately when a phone is plugged
|
// This should be nearly instantaneous allowing automations to fire immediately when a phone is plugged
|
||||||
|
|
|
@ -18,6 +18,8 @@ import com.google.android.gms.wearable.WearableListenerService
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import io.homeassistant.companion.android.BuildConfig
|
import io.homeassistant.companion.android.BuildConfig
|
||||||
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
|
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
|
||||||
|
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
|
||||||
|
import io.homeassistant.companion.android.common.data.keychain.KeyStoreRepositoryImpl
|
||||||
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
|
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
|
||||||
import io.homeassistant.companion.android.common.data.servers.ServerManager
|
import io.homeassistant.companion.android.common.data.servers.ServerManager
|
||||||
import io.homeassistant.companion.android.common.util.WearDataMessages
|
import io.homeassistant.companion.android.common.util.WearDataMessages
|
||||||
|
@ -42,7 +44,11 @@ import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.tasks.await
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111
|
@SuppressLint("VisibleForTests") // https://issuetracker.google.com/issues/239451111
|
||||||
|
@ -57,6 +63,14 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var favoritesDao: FavoritesDao
|
lateinit var favoritesDao: FavoritesDao
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@Named("keyChainRepository")
|
||||||
|
lateinit var keyChainRepository: KeyChainRepository
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@Named("keyStore")
|
||||||
|
lateinit var keyStore: KeyChainRepository
|
||||||
|
|
||||||
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
|
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
|
||||||
|
|
||||||
private val objectMapper = jacksonObjectMapper()
|
private val objectMapper = jacksonObjectMapper()
|
||||||
|
@ -135,6 +149,24 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
|
||||||
val deviceName = dataMap.getString("DeviceName")
|
val deviceName = dataMap.getString("DeviceName")
|
||||||
val deviceTrackingEnabled = dataMap.getBoolean("LocationTracking")
|
val deviceTrackingEnabled = dataMap.getBoolean("LocationTracking")
|
||||||
val notificationsEnabled = dataMap.getBoolean("Notifications")
|
val notificationsEnabled = dataMap.getBoolean("Notifications")
|
||||||
|
val tlsClientCertificateData = dataMap.getByteArray("TLSClientCertificateData")
|
||||||
|
val tlsClientCertificatePassword = dataMap.getString("TLSClientCertificatePassword").orEmpty().toCharArray()
|
||||||
|
|
||||||
|
// load TLS key
|
||||||
|
if (tlsClientCertificateData != null && tlsClientCertificateData.isNotEmpty()) {
|
||||||
|
KeyStore.getInstance("PKCS12").apply {
|
||||||
|
load(tlsClientCertificateData.inputStream(), tlsClientCertificatePassword)
|
||||||
|
|
||||||
|
val alias = aliases().nextElement()
|
||||||
|
val certificateChain = getCertificateChain(alias).filterIsInstance<X509Certificate>().toTypedArray()
|
||||||
|
val privateKey = getKey(alias, tlsClientCertificatePassword) as PrivateKey
|
||||||
|
|
||||||
|
// we store the TLS Client key under a static alias because there is currently
|
||||||
|
// no way to ask the user for the correct alias
|
||||||
|
keyStore.setData(KeyStoreRepositoryImpl.ALIAS, privateKey, certificateChain)
|
||||||
|
keyChainRepository.load(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val formattedUrl = UrlUtil.formattedUrlString(url)
|
val formattedUrl = UrlUtil.formattedUrlString(url)
|
||||||
val server = Server(
|
val server = Server(
|
||||||
|
|
Loading…
Reference in a new issue