Merge pull request #7490 from thunderbird/improve_certificate_error_screen_1

Improve certificate error screen (part 1)
This commit is contained in:
cketti 2024-01-11 14:40:27 +01:00 committed by GitHub
commit ac2f54fa80
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 490 additions and 128 deletions

View file

@ -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(),
)
}
}

View file

@ -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
}
}
}

View file

@ -0,0 +1,6 @@
package app.k9mail.feature.account.server.certificate.domain.entity
data class FormattedServerCertificateError(
val hostname: String,
val serverCertificateProperties: ServerCertificateProperties,
)

View file

@ -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,
)

View file

@ -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)
}
}

View file

@ -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(),
)
}
}

View file

@ -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
}
}

View file

@ -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),
),
)
}

View file

@ -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)
}
}
}

View file

@ -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,
)
}
}

View file

@ -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>