Merge pull request #7490 from thunderbird/improve_certificate_error_screen_1
Improve certificate error screen (part 1)
This commit is contained in:
commit
ac2f54fa80
11 changed files with 490 additions and 128 deletions
|
@ -3,6 +3,7 @@ package app.k9mail.feature.account.server.certificate
|
|||
import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertificateErrorRepository
|
||||
import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract
|
||||
import app.k9mail.feature.account.server.certificate.domain.usecase.AddServerCertificateException
|
||||
import app.k9mail.feature.account.server.certificate.domain.usecase.FormatServerCertificateError
|
||||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorViewModel
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.module.Module
|
||||
|
@ -20,10 +21,15 @@ val featureAccountServerCertificateModule: Module = module {
|
|||
)
|
||||
}
|
||||
|
||||
factory<ServerCertificateDomainContract.UseCase.FormatServerCertificateError> {
|
||||
FormatServerCertificateError()
|
||||
}
|
||||
|
||||
viewModel {
|
||||
ServerCertificateErrorViewModel(
|
||||
certificateErrorRepository = get(),
|
||||
addServerCertificateException = get(),
|
||||
formatServerCertificateError = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package app.k9mail.feature.account.server.certificate.domain
|
||||
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError
|
||||
import java.security.cert.X509Certificate
|
||||
|
||||
|
@ -17,5 +18,9 @@ interface ServerCertificateDomainContract {
|
|||
fun interface AddServerCertificateException {
|
||||
suspend fun addCertificate(hostname: String, port: Int, certificate: X509Certificate?)
|
||||
}
|
||||
|
||||
fun interface FormatServerCertificateError {
|
||||
operator fun invoke(serverCertificateError: ServerCertificateError): FormattedServerCertificateError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
package app.k9mail.feature.account.server.certificate.domain.entity
|
||||
|
||||
data class FormattedServerCertificateError(
|
||||
val hostname: String,
|
||||
val serverCertificateProperties: ServerCertificateProperties,
|
||||
)
|
|
@ -0,0 +1,12 @@
|
|||
package app.k9mail.feature.account.server.certificate.domain.entity
|
||||
|
||||
data class ServerCertificateProperties(
|
||||
val subjectAlternativeNames: List<String>,
|
||||
val notValidBefore: String,
|
||||
val notValidAfter: String,
|
||||
val subject: String,
|
||||
val issuer: String,
|
||||
val fingerprintSha1: String,
|
||||
val fingerprintSha256: String,
|
||||
val fingerprintSha512: String,
|
||||
)
|
|
@ -0,0 +1,79 @@
|
|||
package app.k9mail.feature.account.server.certificate.domain.usecase
|
||||
|
||||
import app.k9mail.feature.account.server.certificate.domain.ServerCertificateDomainContract.UseCase
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.filter.Hex
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.cert.CertificateEncodingException
|
||||
import java.security.cert.X509Certificate
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
class FormatServerCertificateError(
|
||||
private val dateFormat: DateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.SHORT),
|
||||
) : UseCase.FormatServerCertificateError {
|
||||
override operator fun invoke(serverCertificateError: ServerCertificateError): FormattedServerCertificateError {
|
||||
val certificate = serverCertificateError.certificateChain.firstOrNull()
|
||||
?: error("Certificate chain must not be empty")
|
||||
|
||||
val notValidBeforeInstant = Instant.fromEpochMilliseconds(certificate.notBefore.time)
|
||||
val notValidAfterInstant = Instant.fromEpochMilliseconds(certificate.notAfter.time)
|
||||
|
||||
val subjectAlternativeNames = certificate.subjectAlternativeNames.map { it[1].toString() }
|
||||
|
||||
val notValidBefore = dateFormat.format(Date(notValidBeforeInstant.toEpochMilliseconds()))
|
||||
val notValidAfter = dateFormat.format(Date(notValidAfterInstant.toEpochMilliseconds()))
|
||||
|
||||
// TODO: Parse the name to be able to display the components in a more structured way.
|
||||
val subject = certificate.subjectX500Principal.toString()
|
||||
val issuer = certificate.issuerX500Principal.toString()
|
||||
|
||||
val fingerprintSha1 = getFingerprint(certificate, algorithm = "SHA-1")
|
||||
val fingerprintSha256 = getFingerprint(certificate, algorithm = "SHA-256")
|
||||
val fingerprintSha512 = getFingerprint(certificate, algorithm = "SHA-512")
|
||||
|
||||
return FormattedServerCertificateError(
|
||||
hostname = serverCertificateError.hostname,
|
||||
serverCertificateProperties = ServerCertificateProperties(
|
||||
subjectAlternativeNames,
|
||||
notValidBefore,
|
||||
notValidAfter,
|
||||
subject,
|
||||
issuer,
|
||||
fingerprintSha1,
|
||||
fingerprintSha256,
|
||||
fingerprintSha512,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getFingerprint(certificate: X509Certificate, algorithm: String): String {
|
||||
val fingerprint = computeFingerprint(certificate, algorithm)
|
||||
return formatFingerprint(fingerprint)
|
||||
}
|
||||
|
||||
private fun computeFingerprint(certificate: X509Certificate, algorithm: String): ByteArray {
|
||||
val digest = try {
|
||||
MessageDigest.getInstance(algorithm)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
Timber.e(e, "Error while initializing MessageDigest (%s)", algorithm)
|
||||
return "??".toByteArray()
|
||||
}
|
||||
|
||||
return try {
|
||||
digest.digest(certificate.encoded)
|
||||
} catch (e: CertificateEncodingException) {
|
||||
Timber.e(e, "Error while encoding certificate")
|
||||
"??".toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatFingerprint(fingerprint: ByteArray): String {
|
||||
return Hex.encodeHex(fingerprint)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
package app.k9mail.feature.account.server.certificate.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.k9mail.core.ui.compose.common.baseline.withBaseline
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.Icon
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody1
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline4
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextSubtitle1
|
||||
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
|
||||
import app.k9mail.core.ui.compose.theme.IconsWithBaseline
|
||||
import app.k9mail.core.ui.compose.theme.K9Theme
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.feature.account.server.certificate.R
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties
|
||||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.State
|
||||
|
||||
@Composable
|
||||
internal fun ServerCertificateErrorContent(
|
||||
innerPadding: PaddingValues,
|
||||
state: State,
|
||||
scrollState: ScrollState,
|
||||
) {
|
||||
ResponsiveWidthContainer(modifier = Modifier.padding(innerPadding)) {
|
||||
Column(
|
||||
modifier = Modifier.verticalScroll(scrollState),
|
||||
) {
|
||||
CertificateErrorOverview(state)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state.isShowServerCertificate,
|
||||
label = "ServerCertificateViewVisibility",
|
||||
) { isShowServerCertificate ->
|
||||
if (isShowServerCertificate) {
|
||||
ServerCertificateView(
|
||||
serverCertificateProperties = state.certificateError!!.serverCertificateProperties,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CertificateErrorOverview(state: State) {
|
||||
Column(
|
||||
modifier = Modifier.padding(all = MainTheme.spacings.double),
|
||||
) {
|
||||
WarningTitle()
|
||||
TextSubtitle1(stringResource(R.string.account_server_certificate_unknown_error_subtitle))
|
||||
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.quadruple))
|
||||
|
||||
state.certificateError?.let { certificateError ->
|
||||
CertificateErrorDescription(certificateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WarningTitle() {
|
||||
Row {
|
||||
val warningIcon = IconsWithBaseline.Filled.warning
|
||||
val iconSize = MainTheme.sizes.medium
|
||||
val iconScalingFactor = iconSize / warningIcon.image.defaultHeight
|
||||
val iconBaseline = warningIcon.baseline * iconScalingFactor
|
||||
|
||||
Icon(
|
||||
imageVector = warningIcon.image,
|
||||
tint = MainTheme.colors.warning,
|
||||
modifier = Modifier
|
||||
.padding(end = MainTheme.spacings.default)
|
||||
.requiredSize(iconSize)
|
||||
.withBaseline(iconBaseline)
|
||||
.alignByBaseline(),
|
||||
)
|
||||
TextHeadline4(
|
||||
text = stringResource(R.string.account_server_certificate_warning_title),
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CertificateErrorDescription(certificateError: FormattedServerCertificateError) {
|
||||
TextBody1(
|
||||
text = stringResource(
|
||||
id = R.string.account_server_certificate_unknown_error_description_format,
|
||||
certificateError.hostname,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun ServerCertificateErrorContentPreview() {
|
||||
val state = State(
|
||||
isShowServerCertificate = true,
|
||||
certificateError = FormattedServerCertificateError(
|
||||
hostname = "mail.domain.example",
|
||||
serverCertificateProperties = ServerCertificateProperties(
|
||||
subjectAlternativeNames = listOf("*.domain.example", "domain.example"),
|
||||
notValidBefore = "January 1, 2023, 12:00 AM",
|
||||
notValidAfter = "December 31, 2023, 11:59 PM",
|
||||
subject = "CN=*.domain.example",
|
||||
issuer = "CN=test, O=MZLA",
|
||||
fingerprintSha1 = "33ab5639bfd8e7b95eb1d8d0b87781d4ffea4d5d",
|
||||
fingerprintSha256 = "1894a19c85ba153acbf743ac4e43fc004c891604b26f8c69e1e83ea2afc7c48f",
|
||||
fingerprintSha512 = "81381f1dacd4824a6c503fd07057763099c12b8309d0abcec4000c9060cbbfa67988b2ada669ab" +
|
||||
"4837fcd3d4ea6e2b8db2b9da9197d5112fb369fd006da545de",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
K9Theme {
|
||||
ServerCertificateErrorContent(
|
||||
innerPadding = PaddingValues(all = 0.dp),
|
||||
state = state,
|
||||
scrollState = rememberScrollState(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,22 +1,25 @@
|
|||
package app.k9mail.feature.account.server.certificate.ui
|
||||
|
||||
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.FormattedServerCertificateError
|
||||
|
||||
interface ServerCertificateErrorContract {
|
||||
|
||||
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
|
||||
|
||||
data class State(
|
||||
val errorText: String = "",
|
||||
val isShowServerCertificate: Boolean = false,
|
||||
val certificateError: FormattedServerCertificateError? = null,
|
||||
)
|
||||
|
||||
sealed interface Event {
|
||||
object OnCertificateAcceptedClicked : Event
|
||||
object OnBackClicked : Event
|
||||
data object OnShowAdvancedClicked : Event
|
||||
data object OnCertificateAcceptedClicked : Event
|
||||
data object OnBackClicked : Event
|
||||
}
|
||||
|
||||
sealed interface Effect {
|
||||
object NavigateCertificateAccepted : Effect
|
||||
object NavigateBack : Effect
|
||||
data object NavigateCertificateAccepted : Effect
|
||||
data object NavigateBack : Effect
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,43 +1,40 @@
|
|||
package app.k9mail.feature.account.server.certificate.ui
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import app.k9mail.core.ui.compose.common.PreviewDevices
|
||||
import app.k9mail.core.ui.compose.common.baseline.withBaseline
|
||||
import app.k9mail.core.ui.compose.common.mvi.observe
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.Icon
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.Surface
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.button.Button
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonOutlined
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody1
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline4
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextSubtitle1
|
||||
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
|
||||
import app.k9mail.core.ui.compose.designsystem.template.Scaffold
|
||||
import app.k9mail.core.ui.compose.theme.IconsWithBaseline
|
||||
import app.k9mail.core.ui.compose.theme.K9Theme
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.feature.account.server.certificate.R
|
||||
import app.k9mail.feature.account.server.certificate.data.InMemoryServerCertificateErrorRepository
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateError
|
||||
import app.k9mail.feature.account.server.certificate.domain.usecase.FormatServerCertificateError
|
||||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.Effect
|
||||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.Event
|
||||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.State
|
||||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.ViewModel
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
// Note: This is a placeholder with mostly hardcoded text.
|
||||
// TODO: Replace with final design.
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ServerCertificateErrorScreen(
|
||||
onCertificateAccepted: () -> Unit,
|
||||
|
@ -45,6 +42,8 @@ fun ServerCertificateErrorScreen(
|
|||
modifier: Modifier = Modifier,
|
||||
viewModel: ViewModel = koinViewModel<ServerCertificateErrorViewModel>(),
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val (state, dispatch) = viewModel.observe { effect ->
|
||||
when (effect) {
|
||||
is Effect.NavigateCertificateAccepted -> onCertificateAccepted()
|
||||
|
@ -58,69 +57,68 @@ fun ServerCertificateErrorScreen(
|
|||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
ResponsiveWidthContainer(
|
||||
modifier = Modifier
|
||||
.padding(start = MainTheme.spacings.double, end = MainTheme.spacings.double),
|
||||
) {
|
||||
Column {
|
||||
Button(
|
||||
text = "Go back (recommended)",
|
||||
onClick = { dispatch(Event.OnBackClicked) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
ButtonOutlined(
|
||||
text = "Accept consequences and continue",
|
||||
onClick = { dispatch(Event.OnCertificateAcceptedClicked) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
ButtonBar(
|
||||
state = state.value,
|
||||
dispatch = dispatch,
|
||||
scrollState = scrollState,
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { innerPadding ->
|
||||
ResponsiveWidthContainer(modifier = Modifier.padding(innerPadding)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(all = MainTheme.spacings.double),
|
||||
) {
|
||||
Row {
|
||||
val warningIcon = IconsWithBaseline.Filled.warning
|
||||
val iconSize = MainTheme.sizes.medium
|
||||
val iconScalingFactor = iconSize / warningIcon.image.defaultHeight
|
||||
val iconBaseline = warningIcon.baseline * iconScalingFactor
|
||||
ServerCertificateErrorContent(
|
||||
innerPadding = innerPadding,
|
||||
state = state.value,
|
||||
scrollState = scrollState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = warningIcon.image,
|
||||
tint = MainTheme.colors.warning,
|
||||
modifier = Modifier
|
||||
.padding(end = MainTheme.spacings.default)
|
||||
.requiredSize(iconSize)
|
||||
.withBaseline(iconBaseline)
|
||||
.alignByBaseline(),
|
||||
)
|
||||
TextHeadline4(
|
||||
text = "Warning",
|
||||
modifier = Modifier.alignByBaseline(),
|
||||
)
|
||||
}
|
||||
@Composable
|
||||
private fun ButtonBar(
|
||||
state: State,
|
||||
dispatch: (Event) -> Unit,
|
||||
scrollState: ScrollState,
|
||||
) {
|
||||
val elevation by animateDpAsState(
|
||||
targetValue = if (scrollState.canScrollForward) 8.dp else 0.dp,
|
||||
label = "BottomBarElevation",
|
||||
)
|
||||
|
||||
TextSubtitle1("Invalid certificate")
|
||||
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.quadruple))
|
||||
|
||||
TextBody1(
|
||||
text = "The server presented an invalid TLS certificate. " +
|
||||
"Sometimes, this is because of a server misconfiguration. " +
|
||||
"Sometimes it is because someone is trying to attack you or your mail server. " +
|
||||
"If you're not sure what's up, click \"Go back\" and contact the folks who manage your " +
|
||||
"mail server.",
|
||||
Surface(elevation = elevation) {
|
||||
ResponsiveWidthContainer(
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = MainTheme.spacings.double,
|
||||
end = MainTheme.spacings.double,
|
||||
top = MainTheme.spacings.half,
|
||||
bottom = MainTheme.spacings.half,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.animateContentSize()) {
|
||||
Button(
|
||||
text = stringResource(R.string.account_server_certificate_button_back),
|
||||
onClick = { dispatch(Event.OnBackClicked) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.quadruple))
|
||||
|
||||
TextBody1(state.value.errorText)
|
||||
Crossfade(
|
||||
targetState = state.isShowServerCertificate,
|
||||
label = "ContinueButton",
|
||||
) { isShowServerCertificate ->
|
||||
if (isShowServerCertificate) {
|
||||
ButtonOutlined(
|
||||
text = stringResource(R.string.account_server_certificate_button_continue),
|
||||
onClick = { dispatch(Event.OnCertificateAcceptedClicked) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
ButtonOutlined(
|
||||
text = stringResource(R.string.account_server_certificate_button_advanced),
|
||||
onClick = { dispatch(Event.OnShowAdvancedClicked) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -177,6 +175,8 @@ internal fun ServerCertificateErrorScreenK9Preview() {
|
|||
viewModel = ServerCertificateErrorViewModel(
|
||||
addServerCertificateException = { _, _, _ -> },
|
||||
certificateErrorRepository = InMemoryServerCertificateErrorRepository(serverCertificateError),
|
||||
formatServerCertificateError = FormatServerCertificateError(),
|
||||
initialState = State(isShowServerCertificate = false),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,31 +8,40 @@ import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertifi
|
|||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.Effect
|
||||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.Event
|
||||
import app.k9mail.feature.account.server.certificate.ui.ServerCertificateErrorContract.State
|
||||
import com.fsck.k9.logging.Timber
|
||||
import com.fsck.k9.mail.filter.Hex
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.security.cert.CertificateEncodingException
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ServerCertificateErrorViewModel(
|
||||
private val certificateErrorRepository: ServerCertificateDomainContract.ServerCertificateErrorRepository,
|
||||
private val addServerCertificateException: UseCase.AddServerCertificateException,
|
||||
private val formatServerCertificateError: UseCase.FormatServerCertificateError,
|
||||
initialState: State = State(),
|
||||
) : BaseViewModel<State, Event, Effect>(initialState), ServerCertificateErrorContract.ViewModel {
|
||||
private val serverCertificateError: ServerCertificateError? = certificateErrorRepository.getCertificateError()
|
||||
|
||||
init {
|
||||
setErrorMessage(buildErrorMessage(serverCertificateError))
|
||||
serverCertificateError?.let { serverCertificateError ->
|
||||
updateState {
|
||||
it.copy(
|
||||
certificateError = formatServerCertificateError(serverCertificateError),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun event(event: Event) {
|
||||
when (event) {
|
||||
Event.OnShowAdvancedClicked -> showAdvanced()
|
||||
Event.OnCertificateAcceptedClicked -> acceptCertificate()
|
||||
Event.OnBackClicked -> navigateBack()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showAdvanced() {
|
||||
updateState {
|
||||
it.copy(isShowServerCertificate = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun acceptCertificate() {
|
||||
val certificateError = requireNotNull(serverCertificateError)
|
||||
|
||||
|
@ -55,51 +64,4 @@ class ServerCertificateErrorViewModel(
|
|||
private fun navigateCertificateAccepted() {
|
||||
emitEffect(Effect.NavigateCertificateAccepted)
|
||||
}
|
||||
|
||||
private fun buildErrorMessage(serverCertificateError: ServerCertificateError?): String {
|
||||
val certificate = serverCertificateError?.certificateChain?.firstOrNull() ?: return ""
|
||||
|
||||
return buildString {
|
||||
certificate.subjectAlternativeNames?.let { subjectAlternativeNames ->
|
||||
append("Subject alternative names:\n")
|
||||
for (subjectAlternativeName in subjectAlternativeNames) {
|
||||
append("- ").append(subjectAlternativeName[1]).append("\n")
|
||||
}
|
||||
}
|
||||
append("\n")
|
||||
|
||||
append("Not valid before: ").append(certificate.notBefore).append("\n")
|
||||
append("Not valid after: ").append(certificate.notAfter).append("\n")
|
||||
append("\n")
|
||||
|
||||
append("Subject: ").append(certificate.subjectDN).append("\n")
|
||||
append("Issuer: ").append(certificate.issuerX500Principal).append("\n")
|
||||
append("\n")
|
||||
|
||||
for (algorithm in arrayOf("SHA-1", "SHA-256", "SHA-512")) {
|
||||
val digest = try {
|
||||
MessageDigest.getInstance(algorithm)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
Timber.e(e, "Error while initializing MessageDigest (%s)", algorithm)
|
||||
null
|
||||
}
|
||||
|
||||
if (digest != null) {
|
||||
digest.reset()
|
||||
try {
|
||||
val hash = Hex.encodeHex(digest.digest(certificate.encoded))
|
||||
append("Fingerprint (").append(algorithm).append("): \n").append(hash).append("\n")
|
||||
} catch (e: CertificateEncodingException) {
|
||||
Timber.e(e, "Error while encoding certificate")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setErrorMessage(errorText: String) {
|
||||
updateState {
|
||||
it.copy(errorText = errorText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
package app.k9mail.feature.account.server.certificate.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBody1
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadline6
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextOverline
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextSubtitle2
|
||||
import app.k9mail.core.ui.compose.theme.K9Theme
|
||||
import app.k9mail.core.ui.compose.theme.MainTheme
|
||||
import app.k9mail.feature.account.server.certificate.R
|
||||
import app.k9mail.feature.account.server.certificate.domain.entity.ServerCertificateProperties
|
||||
|
||||
@Composable
|
||||
fun ServerCertificateView(
|
||||
serverCertificateProperties: ServerCertificateProperties,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(
|
||||
start = MainTheme.spacings.double,
|
||||
end = MainTheme.spacings.double,
|
||||
top = MainTheme.spacings.double,
|
||||
),
|
||||
) {
|
||||
TextHeadline6(stringResource(R.string.account_server_certificate_section_title))
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
|
||||
|
||||
TextSubtitle2(stringResource(R.string.account_server_certificate_subject_alternative_names))
|
||||
for (subjectAlternativeName in serverCertificateProperties.subjectAlternativeNames) {
|
||||
BulletedListItem(subjectAlternativeName)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
|
||||
|
||||
TextSubtitle2(stringResource(R.string.account_server_certificate_not_valid_before))
|
||||
TextBody1(text = serverCertificateProperties.notValidBefore)
|
||||
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.default))
|
||||
|
||||
TextSubtitle2(stringResource(R.string.account_server_certificate_not_valid_after))
|
||||
TextBody1(text = serverCertificateProperties.notValidAfter)
|
||||
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
|
||||
|
||||
TextSubtitle2(stringResource(R.string.account_server_certificate_subject))
|
||||
TextBody1(text = serverCertificateProperties.subject)
|
||||
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
|
||||
|
||||
TextSubtitle2(stringResource(R.string.account_server_certificate_issuer))
|
||||
TextBody1(text = serverCertificateProperties.issuer)
|
||||
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
|
||||
|
||||
TextOverline(text = stringResource(R.string.account_server_certificate_fingerprints_section))
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.default))
|
||||
|
||||
TextSubtitle2(text = "SHA-1")
|
||||
TextBody1(text = serverCertificateProperties.fingerprintSha1)
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
|
||||
|
||||
TextSubtitle2(text = "SHA-256")
|
||||
TextBody1(text = serverCertificateProperties.fingerprintSha256)
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
|
||||
|
||||
TextSubtitle2(text = "SHA-512")
|
||||
TextBody1(text = serverCertificateProperties.fingerprintSha512)
|
||||
Spacer(modifier = Modifier.height(MainTheme.spacings.double))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BulletedListItem(text: String) {
|
||||
Row {
|
||||
TextBody1(
|
||||
text = "\u2022",
|
||||
modifier = Modifier.padding(horizontal = MainTheme.spacings.half),
|
||||
)
|
||||
TextBody1(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun ServerCertificateViewPreview() {
|
||||
val serverCertificateProperties = ServerCertificateProperties(
|
||||
subjectAlternativeNames = listOf("*.domain.example", "domain.example"),
|
||||
notValidBefore = "January 1, 2023, 12:00 AM",
|
||||
notValidAfter = "December 31, 2023, 11:59 PM",
|
||||
subject = "CN=*.domain.example",
|
||||
issuer = "CN=test, O=MZLA",
|
||||
fingerprintSha1 = "33ab5639bfd8e7b95eb1d8d0b87781d4ffea4d5d",
|
||||
fingerprintSha256 = "1894a19c85ba153acbf743ac4e43fc004c891604b26f8c69e1e83ea2afc7c48f",
|
||||
fingerprintSha512 = "81381f1dacd4824a6c503fd07057763099c12b8309d0abcec4000c9060cbbfa67988b2ada669ab4837fcd3d4" +
|
||||
"ea6e2b8db2b9da9197d5112fb369fd006da545de",
|
||||
)
|
||||
|
||||
K9Theme {
|
||||
ServerCertificateView(
|
||||
serverCertificateProperties = serverCertificateProperties,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<!-- Title of the server certificate error screen -->
|
||||
<string name="account_server_certificate_warning_title">Warning</string>
|
||||
|
||||
<!-- Subtitle of the server certificate error screen when we're not sure what the exact error is -->
|
||||
<string name="account_server_certificate_unknown_error_subtitle">Certificate error</string>
|
||||
|
||||
<!-- Description text in the server certificate error screen when we're not sure what the exact error is -->
|
||||
<string name="account_server_certificate_unknown_error_description_format">The app detected a potential security threat and did not continue to connect to <xliff:g id="serverName">%s</xliff:g>.\nIf you continue, attackers could try to steal information like your password or emails.</string>
|
||||
|
||||
<!-- Text of the back button in the server certificate error screen. Going back and not accepting the invalid certificate is the recommended action. -->
|
||||
<string name="account_server_certificate_button_back">Go back (recommended)</string>
|
||||
|
||||
<!-- Text of the "more" button in the server certificate error screen. Pressing it will display details about the server certificate and only then allow the user to accept the certificate. -->
|
||||
<string name="account_server_certificate_button_advanced">Advanced</string>
|
||||
|
||||
<!-- Text of the continue button in the server certificate error screen. Pressing it will accept the invalid certificate and continue to connect to the server. -->
|
||||
<string name="account_server_certificate_button_continue">Accept risk and continue</string>
|
||||
|
||||
<!-- Title of the section displaying details of the server certificate -->
|
||||
<string name="account_server_certificate_section_title">Server certificate</string>
|
||||
|
||||
<!-- Heading for the list of subject alternative names (SAN) from the server certificate. This is a technical term. If in doubt, do not translate. See https://en.wikipedia.org/wiki/Subject_Alternative_Name -->
|
||||
<string name="account_server_certificate_subject_alternative_names">Subject alternative names</string>
|
||||
|
||||
<!-- Heading for the date/time when the certificate starts being valid -->
|
||||
<string name="account_server_certificate_not_valid_before">Not valid before</string>
|
||||
|
||||
<!-- Heading for the date/time when the certificate stops being valid -->
|
||||
<string name="account_server_certificate_not_valid_after">Not valid after</string>
|
||||
|
||||
<!-- Heading for the "subject" information from the server certificate. This is a technical term. If in doubt, do not translate. See https://en.wikipedia.org/wiki/Public_key_certificate -->
|
||||
<string name="account_server_certificate_subject">Subject</string>
|
||||
|
||||
<!-- Heading for the "issuer" information from the server certificate. This is a technical term. If in doubt, do not translate. See https://en.wikipedia.org/wiki/Public_key_certificate -->
|
||||
<string name="account_server_certificate_issuer">Issuer</string>
|
||||
|
||||
<!-- Heading for the "fingerprints" of the server certificate. This is a technical term. If in doubt, do not translate. See https://en.wikipedia.org/wiki/Fingerprint_(computing) -->
|
||||
<string name="account_server_certificate_fingerprints_section">Fingerprints</string>
|
||||
</resources>
|
Loading…
Reference in a new issue