Skip to content

Commit f3d1cc0

Browse files
committed
Provide callback instead of doing UI tasks of calling app
1 parent 62ae0e6 commit f3d1cc0

File tree

4 files changed

+206
-205
lines changed

4 files changed

+206
-205
lines changed

lib/src/main/java/at/bitfire/cert4android/CustomCertManager.kt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,14 @@ import javax.net.ssl.X509TrustManager
1818
* Initializes Conscrypt when it is first loaded.
1919
*
2020
* @param trustSystemCerts whether system certificates will be trusted
21-
* @param appInForeground - `true`: if needed, directly launches [TrustCertificateActivity] and shows notification (if possible)
22-
* - `false`: if needed, shows notification (if possible)
23-
* - `null`: non-interactive mode: does not show notification or launch activity
21+
* @param getUserDecision anonymous function to retrieve user decision on whether to trust a
22+
* certificate; should return *true* if the user trusts the certificate
2423
*/
2524
@SuppressLint("CustomX509TrustManager")
2625
class CustomCertManager @JvmOverloads constructor(
2726
context: Context,
2827
val trustSystemCerts: Boolean = true,
29-
var appInForeground: StateFlow<Boolean>?
28+
private val getUserDecision: suspend (X509Certificate) -> Boolean
3029
): X509TrustManager {
3130

3231
val certStore = CustomCertStore.getInstance(context)
@@ -47,7 +46,7 @@ class CustomCertManager @JvmOverloads constructor(
4746
*/
4847
@Throws(CertificateException::class)
4948
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
50-
if (!certStore.isTrusted(chain, authType, trustSystemCerts, appInForeground))
49+
if (!certStore.isTrusted(chain, authType, trustSystemCerts, getUserDecision))
5150
throw CertificateException("Certificate chain not trusted")
5251
}
5352

@@ -71,7 +70,7 @@ class CustomCertManager @JvmOverloads constructor(
7170
// Allow users to explicitly accept certificates that have a bad hostname here
7271
(session.peerCertificates.firstOrNull() as? X509Certificate)?.let { cert ->
7372
// Check without trusting system certificates so that the user will be asked even for system-trusted certificates
74-
if (certStore.isTrusted(arrayOf(cert), "RSA", false, appInForeground))
73+
if (certStore.isTrusted(arrayOf(cert), "RSA", false, getUserDecision))
7574
return true
7675
}
7776

lib/src/main/java/at/bitfire/cert4android/CustomCertStore.kt

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import android.annotation.SuppressLint
88
import android.content.Context
99
import androidx.annotation.VisibleForTesting
1010
import kotlinx.coroutines.TimeoutCancellationException
11-
import kotlinx.coroutines.flow.StateFlow
1211
import kotlinx.coroutines.runBlocking
1312
import kotlinx.coroutines.withTimeout
1413
import org.conscrypt.Conscrypt
@@ -77,7 +76,12 @@ class CustomCertStore internal constructor(
7776
/**
7877
* Determines whether a certificate chain is trusted.
7978
*/
80-
fun isTrusted(chain: Array<X509Certificate>, authType: String, trustSystemCerts: Boolean, appInForeground: StateFlow<Boolean>?): Boolean {
79+
fun isTrusted(
80+
chain: Array<X509Certificate>,
81+
authType: String,
82+
trustSystemCerts: Boolean,
83+
getUserDecision: suspend (X509Certificate) -> Boolean
84+
): Boolean {
8185
if (chain.isEmpty())
8286
throw IllegalArgumentException("Certificate chain must not be empty")
8387
val cert = chain[0]
@@ -103,17 +107,12 @@ class CustomCertStore internal constructor(
103107
}
104108
}
105109

106-
if (appInForeground == null) {
107-
Cert4Android.log.log(Level.INFO, "Certificate not known and running in non-interactive mode, rejecting")
108-
return false
109-
}
110-
111110
return runBlocking {
112111
val ui = UserDecisionRegistry.getInstance(context)
113112

114113
try {
115114
withTimeout(userTimeout) {
116-
ui.check(cert, appInForeground.value)
115+
ui.check(cert, getUserDecision)
117116
}
118117
} catch (_: TimeoutCancellationException) {
119118
Cert4Android.log.log(Level.WARNING, "User timeout while waiting for certificate decision, rejecting")

lib/src/main/java/at/bitfire/cert4android/UserDecisionRegistry.kt

Lines changed: 27 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
package at.bitfire.cert4android
22

33
import android.annotation.SuppressLint
4-
import android.app.PendingIntent
54
import android.content.Context
6-
import android.content.Intent
7-
import androidx.core.app.NotificationCompat
8-
import androidx.core.app.TaskStackBuilder
5+
import kotlinx.coroutines.runBlocking
96
import kotlinx.coroutines.suspendCancellableCoroutine
107
import java.security.cert.X509Certificate
118
import kotlin.coroutines.Continuation
@@ -41,113 +38,46 @@ class UserDecisionRegistry private constructor(
4138
* Thread-safe, can handle multiple requests for various certificates and/or the same certificate at once.
4239
*
4340
* @param cert certificate to ask user about
44-
* @param appInForeground whether the app is currently in foreground = whether it can directly launch an Activity
41+
* @param getUserDecision anonymous function to retrieve user decision
4542
* @return *true* if the user explicitly trusts the certificate, *false* if unknown or untrusted
4643
*/
47-
suspend fun check(cert: X509Certificate, appInForeground: Boolean): Boolean = suspendCancellableCoroutine { cont ->
48-
// check whether we're able to retrieve user feedback (= start an Activity and/or show a notification)
49-
val notificationsPermitted = NotificationUtils.notificationsPermitted(context)
50-
val userDecisionPossible = appInForeground || notificationsPermitted
51-
52-
if (userDecisionPossible) {
53-
// User decision possible → remember request in pendingDecisions so that a later decision will be applied to this request
54-
55-
cont.invokeOnCancellation {
56-
// remove from pending decisions on cancellation
57-
synchronized(pendingDecisions) {
58-
pendingDecisions[cert]?.remove(cont)
59-
}
60-
61-
val nm = NotificationUtils.createChannels(context)
62-
nm.cancel(CertUtils.getTag(cert), NotificationUtils.ID_CERT_DECISION)
63-
}
64-
65-
val requestDecision: Boolean
44+
suspend fun check(cert: X509Certificate, getUserDecision: suspend (X509Certificate) -> Boolean): Boolean = suspendCancellableCoroutine { cont ->
45+
cont.invokeOnCancellation {
46+
// remove from pending decisions on cancellation
6647
synchronized(pendingDecisions) {
67-
if (pendingDecisions.containsKey(cert)) {
68-
// There are already pending decisions for this request, just add our request
69-
pendingDecisions[cert]!! += cont
70-
requestDecision = false
71-
} else {
72-
// First decision for this certificate, show UI
73-
pendingDecisions[cert] = mutableListOf(cont)
74-
requestDecision = true
75-
}
48+
pendingDecisions[cert]?.remove(cont)
7649
}
50+
}
7751

78-
if (requestDecision)
79-
requestDecision(cert, launchActivity = appInForeground, showNotification = notificationsPermitted)
80-
81-
} else {
82-
// We're not able to retrieve user feedback, directly reject request
83-
Cert4Android.log.warning("App not in foreground and missing notification permission, rejecting certificate")
84-
cont.resume(false)
52+
val requestDecision: Boolean
53+
synchronized(pendingDecisions) {
54+
if (pendingDecisions.containsKey(cert)) {
55+
// There are already pending decisions for this request, just add our request
56+
pendingDecisions[cert]!! += cont
57+
requestDecision = false
58+
} else {
59+
// First decision for this certificate, show UI
60+
pendingDecisions[cert] = mutableListOf(cont)
61+
requestDecision = true
62+
}
8563
}
64+
65+
if (requestDecision)
66+
runBlocking {
67+
requestDecision(cert, getUserDecision)
68+
}
8669
}
8770

8871
/**
89-
* Starts UI for retrieving feedback (accept/reject) for a certificate from the user.
72+
* ...
9073
*
91-
* Ensure that required permissions are granted/conditions are met before setting [launchActivity]
92-
* or [showNotification].
93-
*
94-
* @param cert certificate to ask user about
95-
* @param launchActivity whether to launch a [TrustCertificateActivity]
96-
* @param showNotification whether to show a certificate notification (caller must check notification permissions before passing *true*)
97-
*
98-
* @throws IllegalArgumentException when both [launchActivity] and [showNotification] are *false*
9974
*/
100-
@SuppressLint("MissingPermission")
101-
internal fun requestDecision(cert: X509Certificate, launchActivity: Boolean, showNotification: Boolean) {
102-
if (!launchActivity && !showNotification)
103-
throw IllegalArgumentException("User decision requires certificate Activity and/or notification")
104-
105-
val rawCert = cert.encoded
106-
val decisionIntent = Intent(context, TrustCertificateActivity::class.java).apply {
107-
putExtra(TrustCertificateActivity.EXTRA_CERTIFICATE, rawCert)
108-
}
109-
110-
if (showNotification) {
111-
val rejectIntent = Intent(context, TrustCertificateActivity::class.java).apply {
112-
putExtra(TrustCertificateActivity.EXTRA_CERTIFICATE, rawCert)
113-
putExtra(TrustCertificateActivity.EXTRA_TRUSTED, false)
114-
}
115-
116-
val id = rawCert.contentHashCode()
117-
val notify = NotificationCompat.Builder(context, NotificationUtils.CHANNEL_CERTIFICATES)
118-
.setPriority(NotificationCompat.PRIORITY_HIGH)
119-
.setSmallIcon(R.drawable.ic_lock_open_white)
120-
.setContentTitle(context.getString(R.string.certificate_notification_connection_security))
121-
.setContentText(context.getString(R.string.certificate_notification_user_interaction))
122-
.setSubText(cert.subjectDN.name)
123-
.setCategory(NotificationCompat.CATEGORY_SERVICE)
124-
.setContentIntent(
125-
TaskStackBuilder.create(context)
126-
.addNextIntent(decisionIntent)
127-
.getPendingIntent(id, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
128-
)
129-
.setDeleteIntent(
130-
TaskStackBuilder.create(context)
131-
.addNextIntent(rejectIntent)
132-
.getPendingIntent(id + 1, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
133-
)
134-
.build()
135-
136-
val nm = NotificationUtils.createChannels(context)
137-
nm.notify(CertUtils.getTag(cert), NotificationUtils.ID_CERT_DECISION, notify)
138-
}
139-
140-
if (launchActivity) {
141-
decisionIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
142-
context.startActivity(decisionIntent)
143-
}
75+
internal suspend fun requestDecision(cert: X509Certificate, getUserDecision: suspend (X509Certificate) -> Boolean) {
76+
val userDecision = getUserDecision(cert)
77+
onUserDecision(cert, userDecision)
14478
}
14579

14680
fun onUserDecision(cert: X509Certificate, trusted: Boolean) {
147-
// cancel notification
148-
val nm = NotificationUtils.createChannels(context)
149-
nm.cancel(CertUtils.getTag(cert), NotificationUtils.ID_CERT_DECISION)
150-
15181
// save decision
15282
val customCertStore = CustomCertStore.getInstance(context)
15383
if (trusted)

0 commit comments

Comments
 (0)