Skip to content

Commit

Permalink
Extract and use composable from TrustCertificateActivity
Browse files Browse the repository at this point in the history
  • Loading branch information
sunkup committed Mar 4, 2025
1 parent 9d9bfdb commit a85285d
Show file tree
Hide file tree
Showing 4 changed files with 232 additions and 50 deletions.
50 changes: 50 additions & 0 deletions lib/src/main/java/at/bitfire/cert4android/CertificateDetails.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package at.bitfire.cert4android

import java.security.cert.X509Certificate
import java.security.spec.MGF1ParameterSpec.SHA1
import java.security.spec.MGF1ParameterSpec.SHA256
import java.text.DateFormat

/**
* Certificate details.
* Create with [CertificateDetails.create] and use with [TrustCertificateDialog]
*/
data class CertificateDetails(
val issuedFor: String? = null,
val issuedBy: String? = null,
val validFrom: String? = null,
val validTo: String? = null,
val sha1: String? = null,
val sha256: String? = null,
) {
companion object {

/**
* Creates [CertificateDetails] from [X509Certificate].
*
* @param cert X509Certificate
* @return CertificateDetails
*/
fun create(cert: X509Certificate): CertificateDetails? {
val subject = cert.subjectAlternativeNames?.let { altNames ->
val sb = StringBuilder()
for (altName in altNames) {
val name = altName[1]
if (name is String)
sb.append("[").append(altName[0]).append("]").append(name).append(" ")
}
sb.toString()
} ?: /* use CN if alternative names are not available */ cert.subjectDN.name

val timeFormatter = DateFormat.getDateInstance(DateFormat.LONG)
return CertificateDetails(
issuedFor = subject,
issuedBy = cert.issuerDN.toString(),
validFrom = timeFormatter.format(cert.notBefore),
validTo = timeFormatter.format(cert.notAfter),
sha1 = "SHA1: " + CertUtils.fingerprint(cert, SHA1.digestAlgorithm),
sha256 = "SHA256: " + CertUtils.fingerprint(cert, SHA256.digestAlgorithm)
)
}
}
}
164 changes: 164 additions & 0 deletions lib/src/main/java/at/bitfire/cert4android/TrustCertificateDialog.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/***************************************************************************************************
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
**************************************************************************************************/

package at.bitfire.cert4android

import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp

/**
* Show this dialog to the user to make a decision on whether to trust the given certificate.
*
* @param certificateDetails Certificate details
* @param onSetTrustDecision Callback to set the users trust decision for given certificate
*/
@Composable
fun TrustCertificateDialog(
certificateDetails: CertificateDetails,
onSetTrustDecision: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.fillMaxWidth(),
) {
Column(
modifier = modifier
.padding(16.dp),
) {
Text(
text = stringResource(R.string.trust_certificate_x509_certificate_details),
style = MaterialTheme.typography.titleMedium,
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
if (certificateDetails.issuedFor != null)
InfoPack(R.string.trust_certificate_issued_for, certificateDetails.issuedFor)
if (certificateDetails.issuedBy != null)
InfoPack(R.string.trust_certificate_issued_by, certificateDetails.issuedBy)

val validFrom = certificateDetails.validFrom
val validTo = certificateDetails.validTo
if (validFrom != null && validTo != null)
InfoPack(
R.string.trust_certificate_validity_period,
stringResource(
R.string.trust_certificate_validity_period_value,
validFrom,
validTo
)
)

val sha1 = certificateDetails.sha1
val sha256 = certificateDetails.sha256
if (sha1 != null || sha256 != null) {
Text(
text = stringResource(R.string.trust_certificate_fingerprints).uppercase(),
style = MaterialTheme.typography.bodyMedium,
modifier = modifier.fillMaxWidth(),
)

if (sha1 != null)
Text(
text = sha1,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp, top = 4.dp),
)

if (sha256 != null)
Text(
text = sha256,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp, top = 4.dp),
)
}

var fingerprintVerified by remember { mutableStateOf(false) }
Row(
modifier = modifier
.fillMaxWidth()
.padding(8.dp),
) {
Checkbox(
checked = fingerprintVerified,
onCheckedChange = { fingerprintVerified = it }
)
Text(
text = stringResource(R.string.trust_certificate_fingerprint_verified),
modifier = modifier
.clickable {
fingerprintVerified = !fingerprintVerified
}
.weight(1f)
.padding(bottom = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}

Row(
modifier = modifier.fillMaxWidth(),
) {
TextButton(
enabled = fingerprintVerified,
onClick = {
onSetTrustDecision(true)
},
modifier = modifier
.weight(1f)
.padding(end = 16.dp)
) { Text(stringResource(R.string.trust_certificate_accept).uppercase()) }
TextButton(
onClick = {
onSetTrustDecision(false)
},
modifier = modifier
.weight(1f)
) { Text(stringResource(R.string.trust_certificate_reject).uppercase()) }
}
}
}
}

@Composable
fun InfoPack(
@StringRes labelStringRes: Int,
text: String,
modifier: Modifier = Modifier
) {
Text(
text = stringResource(labelStringRes).uppercase(),
style = MaterialTheme.typography.bodyMedium,
modifier = modifier
.fillMaxWidth(),
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
)
}
9 changes: 1 addition & 8 deletions lib/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<string name="certificate_notification_connection_security">Connection security</string>
<string name="certificate_notification_user_interaction">Please review the certificate</string>

<string name="service_rejected_temporarily">Certificate temporarily rejected</string>

<!-- TrustCertificateActivity -->
<string name="trust_certificate_unknown_certificate_found">cert4android has encountered an unknown certificate. Do you want to trust it?</string>
<!-- TrustCertificateDialog -->
<string name="trust_certificate_x509_certificate_details">X509 certificate details</string>
<string name="trust_certificate_issued_for">Issued for</string>
<string name="trust_certificate_issued_by">Issued by</string>
Expand All @@ -18,6 +12,5 @@
<string name="trust_certificate_accept">Accept</string>
<string name="trust_certificate_reject">Reject</string>
<string name="trust_certificate_reset_info">You can reset all custom certificates in the app settings.</string>
<string name="trust_certificate_press_back_to_reject">Press Back again to reject certificate</string>

</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,23 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import at.bitfire.cert4android.Cert4Android
import at.bitfire.cert4android.TrustCertificateDialog
import at.bitfire.cert4android.CertificateDetails
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.cert4android.CustomCertStore
import kotlinx.coroutines.CompletableDeferred
Expand All @@ -42,49 +41,21 @@ import kotlinx.coroutines.launch
import org.apache.http.conn.ssl.AllowAllHostnameVerifier
import org.apache.http.conn.ssl.StrictHostnameVerifier
import java.net.URL
import java.security.cert.X509Certificate
import javax.net.ssl.HttpsURLConnection

/**
* Example implementation for testing and to showcase usage of [CustomCertManager].
*/
class MainActivity : ComponentActivity() {

private val model by viewModels<Model>()


override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Cert4Android.theme {
@Composable
fun TrustDecisionDialog(cert: X509Certificate, onDismiss: (Boolean) -> Unit) {
AlertDialog(
onDismissRequest = { onDismiss(false) },
title = { Text(text = "Trust Decision") },
text = { Text("Do you trust this certificate?\n\n ${cert.subjectDN.name}") },
confirmButton = {
Button(onClick = {
onDismiss(true)
}) {
Text("Trust")
}
},
dismissButton = {
Button(onClick = {
onDismiss(false)
}) {
Text("Distrust")
}
},
properties = DialogProperties(dismissOnClickOutside = false)
)
}

val snackBarHostState = remember { SnackbarHostState() }

val certificateState = model.certificateFlow.collectAsState()
val certificate = certificateState.value

if (certificate != null)
TrustDecisionDialog(certificate, model::setUserDecision)
val certificateDetails = model.certificateDetailsFlow.collectAsStateWithLifecycle().value

Box(Modifier.fillMaxSize()) {
Column(
Expand Down Expand Up @@ -146,6 +117,10 @@ class MainActivity : ComponentActivity() {
}
}
}

if (certificateDetails != null)
TrustCertificateDialog(certificateDetails, model::registerUserDecision)

SnackbarHost(
snackBarHostState,
modifier = Modifier.align(Alignment.BottomCenter)
Expand All @@ -160,15 +135,15 @@ class MainActivity : ComponentActivity() {

val resultMessage = MutableLiveData<String>()

private val _certificateFlow = MutableStateFlow<X509Certificate?>(null)
val certificateFlow: StateFlow<X509Certificate?> = _certificateFlow
private val _certificateDetailsFlow = MutableStateFlow<CertificateDetails?>(null)
val certificateDetailsFlow: StateFlow<CertificateDetails?> = _certificateDetailsFlow

@Volatile
private var userDecision: CompletableDeferred<Boolean> = CompletableDeferred()

fun setUserDecision(decision: Boolean) {
fun registerUserDecision(decision: Boolean) {
userDecision.complete(decision)
_certificateFlow.value = null
_certificateDetailsFlow.value = null
}


Expand Down Expand Up @@ -196,8 +171,8 @@ class MainActivity : ComponentActivity() {
// Reset user decision
userDecision = CompletableDeferred()

// Show TrustDecisionDialog with certificate to user
_certificateFlow.value = cert
// Show TrustDecisionDialog with certificate details to user
_certificateDetailsFlow.value = CertificateDetails.create(cert)

// Wait for user decision and return it
userDecision.await()
Expand Down

0 comments on commit a85285d

Please sign in to comment.