Allow users to trust self-signed certificates (#160)
* Implement CERT-Store * Trust user-added certificates * Improve code readability * Implement saving self-signed certs to storage * Create interface for TrustedCertificatesStore * Remove unused code * Make datasource implementation internal * Bump app version to 29 (0.4.0) --------- Co-authored-by: fz72 <fz72@gmx.de>
This commit is contained in:
@@ -16,8 +16,8 @@ plugins {
|
||||
android {
|
||||
defaultConfig {
|
||||
applicationId = "gq.kirmanak.mealient"
|
||||
versionCode = 28
|
||||
versionName = "0.3.13"
|
||||
versionCode = 29
|
||||
versionName = "0.4.0"
|
||||
testInstrumentationRunner = "gq.kirmanak.mealient.MealientTestRunner"
|
||||
testInstrumentationRunnerArguments += mapOf("clearPackageData" to "true")
|
||||
resourceConfigurations += listOf("en", "es", "ru", "fr", "nl", "pt", "de")
|
||||
|
||||
@@ -2,6 +2,7 @@ package gq.kirmanak.mealient.ui.baseurl
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
@@ -11,13 +12,16 @@ import by.kirich1409.viewbindingdelegate.viewBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import gq.kirmanak.mealient.R
|
||||
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
|
||||
import gq.kirmanak.mealient.datasource.CertificateCombinedException
|
||||
import gq.kirmanak.mealient.datasource.NetworkError
|
||||
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
||||
import gq.kirmanak.mealient.extensions.collectWhenResumed
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import gq.kirmanak.mealient.ui.CheckableMenuItem
|
||||
import gq.kirmanak.mealient.ui.OperationUiState
|
||||
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
|
||||
import gq.kirmanak.mealient.ui.baseurl.BaseURLFragmentDirections.Companion.actionBaseURLFragmentToRecipesListFragment
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -36,6 +40,7 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
|
||||
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
|
||||
binding.button.setOnClickListener(::onProceedClick)
|
||||
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
|
||||
collectWhenResumed(viewModel.invalidCertificatesFlow, ::onInvalidCertificate)
|
||||
activityViewModel.updateUiState {
|
||||
it.copy(
|
||||
navigationVisible = !args.isOnboarding,
|
||||
@@ -45,8 +50,35 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInvalidCertificate(certificate: X509Certificate) {
|
||||
logger.v { "onInvalidCertificate() called with: certificate = $certificate" }
|
||||
val dialogMessage = getString(
|
||||
R.string.fragment_base_url_invalid_certificate_message,
|
||||
certificate.issuerDN.toString(),
|
||||
certificate.subjectDN.toString(),
|
||||
certificate.notBefore.toString(),
|
||||
certificate.notAfter.toString(),
|
||||
)
|
||||
val dialog = AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.fragment_base_url_invalid_certificate_title)
|
||||
.setMessage(dialogMessage)
|
||||
.setPositiveButton(R.string.fragment_base_url_invalid_certificate_accept) { _, _ ->
|
||||
viewModel.acceptInvalidCertificate(certificate)
|
||||
saveEnteredUrl()
|
||||
}.setNegativeButton(R.string.fragment_base_url_invalid_certificate_deny) { _, _ ->
|
||||
// Do nothing, let the user enter another address or try again
|
||||
}
|
||||
.create()
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun onProceedClick(view: View) {
|
||||
logger.v { "onProceedClick() called with: view = $view" }
|
||||
saveEnteredUrl()
|
||||
}
|
||||
|
||||
private fun saveEnteredUrl() {
|
||||
logger.v { "saveEnteredUrl() called" }
|
||||
val url = binding.urlInput.checkIfInputIsEmpty(
|
||||
inputLayout = binding.urlInputLayout,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
@@ -69,6 +101,8 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
|
||||
val cause = exception.cause?.message ?: exception.message
|
||||
getString(R.string.fragment_base_url_malformed_url, cause)
|
||||
}
|
||||
|
||||
is CertificateCombinedException -> getString(R.string.fragment_base_url_invalid_certificate_title)
|
||||
null -> null
|
||||
else -> getString(R.string.fragment_base_url_unknown_error)
|
||||
}
|
||||
|
||||
@@ -8,11 +8,16 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
|
||||
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
||||
import gq.kirmanak.mealient.datasource.CertificateCombinedException
|
||||
import gq.kirmanak.mealient.datasource.NetworkError
|
||||
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
|
||||
import gq.kirmanak.mealient.datasource.findCauseAsInstanceOf
|
||||
import gq.kirmanak.mealient.logging.Logger
|
||||
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
|
||||
import gq.kirmanak.mealient.ui.OperationUiState
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -21,12 +26,15 @@ class BaseURLViewModel @Inject constructor(
|
||||
private val authRepo: AuthRepo,
|
||||
private val recipeRepo: RecipeRepo,
|
||||
private val logger: Logger,
|
||||
private val shoppingListsRepo: ShoppingListsRepo,
|
||||
private val trustedCertificatesStore: TrustedCertificatesStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial())
|
||||
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState
|
||||
|
||||
private val invalidCertificatesChannel = Channel<X509Certificate>(Channel.UNLIMITED)
|
||||
val invalidCertificatesFlow = invalidCertificatesChannel.receiveAsFlow()
|
||||
|
||||
fun saveBaseUrl(baseURL: String) {
|
||||
logger.v { "saveBaseUrl() called with: baseURL = $baseURL" }
|
||||
_uiState.value = OperationUiState.Progress()
|
||||
@@ -49,7 +57,11 @@ class BaseURLViewModel @Inject constructor(
|
||||
|
||||
val result: Result<Unit> = serverInfoRepo.tryBaseURL(url).recoverCatching {
|
||||
logger.e(it) { "checkBaseURL: trying to recover, had prefix = $hasPrefix" }
|
||||
if (hasPrefix || it is NetworkError.NotMealie) {
|
||||
val certificateError = it.findCauseAsInstanceOf<CertificateCombinedException>()
|
||||
if (certificateError != null) {
|
||||
invalidCertificatesChannel.send(certificateError.serverCert)
|
||||
throw certificateError
|
||||
} else if (hasPrefix || it is NetworkError.NotMealie) {
|
||||
throw it
|
||||
} else {
|
||||
val unencryptedUrl = url.replace("https", "http")
|
||||
@@ -66,4 +78,8 @@ class BaseURLViewModel @Inject constructor(
|
||||
_uiState.value = OperationUiState.fromResult(result)
|
||||
}
|
||||
|
||||
fun acceptInvalidCertificate(certificate: X509Certificate) {
|
||||
logger.v { "acceptInvalidCertificate() called with: certificate = $certificate" }
|
||||
trustedCertificatesStore.addTrustedCertificate(certificate)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
<string name="fragment_base_url_malformed_url">Check URL format: %s</string>
|
||||
<string name="fragment_base_url_save">Proceed</string>
|
||||
<string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string>
|
||||
<string name="fragment_base_url_invalid_certificate_title">The identity of the server could not be verified</string>
|
||||
<string name="fragment_base_url_invalid_certificate_message">Do you trust this certificate?\n\nCertificate Information:\nIssuer: %1$s\nSubject: %2$s\nValid From: %3$s\nValid Until: %4$s</string>
|
||||
<string name="fragment_base_url_invalid_certificate_accept">Trust</string>
|
||||
<string name="fragment_base_url_invalid_certificate_deny">No</string>
|
||||
<string name="menu_navigation_drawer_login">Login</string>
|
||||
<string name="fragment_disclaimer_button_okay">Okay</string>
|
||||
<string name="view_holder_recipe_instructions_step">Step: %d</string>
|
||||
|
||||
@@ -5,7 +5,7 @@ import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||
import gq.kirmanak.mealient.data.baseurl.ServerInfoRepo
|
||||
import gq.kirmanak.mealient.data.recipes.RecipeRepo
|
||||
import gq.kirmanak.mealient.datasource.NetworkError
|
||||
import gq.kirmanak.mealient.shopping_lists.repo.ShoppingListsRepo
|
||||
import gq.kirmanak.mealient.datasource.TrustedCertificatesStore
|
||||
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
|
||||
import gq.kirmanak.mealient.test.BaseUnitTest
|
||||
import gq.kirmanak.mealient.ui.OperationUiState
|
||||
@@ -33,7 +33,7 @@ class BaseURLViewModelTest : BaseUnitTest() {
|
||||
lateinit var recipeRepo: RecipeRepo
|
||||
|
||||
@MockK(relaxUnitFun = true)
|
||||
lateinit var shoppingListsRepo: ShoppingListsRepo
|
||||
lateinit var trustedCertificatesStore: TrustedCertificatesStore
|
||||
|
||||
lateinit var subject: BaseURLViewModel
|
||||
|
||||
@@ -45,7 +45,7 @@ class BaseURLViewModelTest : BaseUnitTest() {
|
||||
authRepo = authRepo,
|
||||
recipeRepo = recipeRepo,
|
||||
logger = logger,
|
||||
shoppingListsRepo = shoppingListsRepo,
|
||||
trustedCertificatesStore = trustedCertificatesStore,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user