@@ -2,12 +2,13 @@ package tech.httptoolkit.android
22
33import android.content.*
44import android.content.pm.PackageManager
5- import android.content.res.Configuration
65import android.net.Uri
76import android.net.VpnService
87import android.os.Build
98import android.os.Bundle
9+ import android.os.ParcelFileDescriptor
1010import android.os.PowerManager
11+ import android.provider.MediaStore
1112import android.provider.Settings
1213import android.security.KeyChain
1314import android.security.KeyChain.EXTRA_CERTIFICATE
@@ -17,6 +18,7 @@ import android.view.View
1718import android.widget.Button
1819import android.widget.LinearLayout
1920import android.widget.TextView
21+ import androidx.annotation.RequiresApi
2022import androidx.annotation.StringRes
2123import androidx.appcompat.app.AppCompatActivity
2224import androidx.appcompat.view.ContextThemeWrapper
@@ -25,6 +27,8 @@ import com.google.android.gms.common.GooglePlayServicesUtil
2527import com.google.android.material.dialog.MaterialAlertDialogBuilder
2628import io.sentry.Sentry
2729import kotlinx.coroutines.*
30+ import java.lang.RuntimeException
31+ import java.security.cert.Certificate
2832import java.security.cert.X509Certificate
2933
3034
@@ -43,6 +47,8 @@ enum class MainState {
4347private const val ACTIVATE_INTENT = " tech.httptoolkit.android.ACTIVATE"
4448private const val DEACTIVATE_INTENT = " tech.httptoolkit.android.DEACTIVATE"
4549
50+ private val PROMPTED_CERT_SETUP_SUPPORTED = Build .VERSION .SDK_INT < Build .VERSION_CODES .R ;
51+
4652class MainActivity : AppCompatActivity (), CoroutineScope by MainScope() {
4753
4854 private lateinit var app: HttpToolkitApplication
@@ -308,7 +314,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
308314 Log .i(TAG , if (vpnIntent != null ) " got intent" else " no intent" )
309315 val vpnNotConfigured = vpnIntent != null
310316
311- if (whereIsCertTrusted(config) == null ) {
317+ if (whereIsCertTrusted(config) == null && PROMPTED_CERT_SETUP_SUPPORTED ) {
312318 // The cert isn't trusted, and the VPN may need setup, so there'll be a series of prompts
313319 // here. Explain them beforehand, so users understand what's going on.
314320 withContext(Dispatchers .Main ) {
@@ -446,14 +452,19 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
446452 SCAN_REQUEST -> " scan-request"
447453 else -> requestCode.toString()
448454 })
455+
449456 Log .i(TAG , if (resultCode == RESULT_OK ) " ok" else resultCode.toString())
450457
451- if (resultCode == RESULT_OK ) {
458+ val resultOk = resultCode == RESULT_OK ||
459+ (requestCode == INSTALL_CERT_REQUEST && whereIsCertTrusted(currentProxyConfig!! ) != null )
460+
461+ if (resultOk) {
452462 if (requestCode == START_VPN_REQUEST && currentProxyConfig != null ) {
453463 Log .i(TAG , " Installing cert" )
454464 ensureCertificateTrusted(currentProxyConfig!! )
455465 } else if (requestCode == INSTALL_CERT_REQUEST ) {
456466 Log .i(TAG , " Starting VPN" )
467+ app.trackEvent(" Setup" , " installed-cert-successfully" )
457468 startService(Intent (this , ProxyVpnService ::class .java).apply {
458469 action = START_VPN_ACTION
459470 putExtra(PROXY_CONFIG_EXTRA , currentProxyConfig)
@@ -512,20 +523,77 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
512523 app.trackEvent(" Setup" , " installing-cert" )
513524 Log .i(TAG , " Certificate not trusted, prompting to install" )
514525
515- // Install the required cert into the user CA store. Notably, if the cert is already
516- // installed as a system cert but disabled, this will get triggered, and will enable
517- // the cert, rather than adding a user cert.
518- val certInstallIntent = KeyChain .createInstallIntent()
519- certInstallIntent.putExtra(EXTRA_NAME , " HTTP Toolkit CA" )
520- certInstallIntent.putExtra(EXTRA_CERTIFICATE , proxyConfig.certificate.encoded)
521- startActivityForResult(certInstallIntent, INSTALL_CERT_REQUEST )
526+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .R ) {
527+ app.trackEvent(" Setup" , " installing-cert-manually" )
528+ // Android 11+, with no trusted cert: we need to download the cert to Downloads and
529+ // then tell the user how to install it manually:
530+ launch { promptToManuallyInstallCert(proxyConfig.certificate) }
531+ } else {
532+ // Up until Android 11, we can prompt the user to install the CA cert into the user
533+ // CA store. Notably, if the cert is already installed as a system cert but
534+ // disabled, this will get triggered, and will enable the cert, rather than adding
535+ // a normal user cert.
536+ app.trackEvent(" Setup" , " installing-cert-automatically" )
537+ val certInstallIntent = KeyChain .createInstallIntent()
538+ certInstallIntent.putExtra(EXTRA_NAME , " HTTP Toolkit CA" )
539+ certInstallIntent.putExtra(EXTRA_CERTIFICATE , proxyConfig.certificate.encoded)
540+ startActivityForResult(certInstallIntent, INSTALL_CERT_REQUEST )
541+ }
522542 } else {
523543 app.trackEvent(" Setup" , " existing-$existingTrust -cert" )
524544 Log .i(TAG , " Certificate already trusted, continuing" )
525545 onActivityResult(INSTALL_CERT_REQUEST , RESULT_OK , null )
526546 }
527547 }
528548
549+ @RequiresApi(Build .VERSION_CODES .Q )
550+ private suspend fun promptToManuallyInstallCert (cert : Certificate ) {
551+ // Get ready to save the cert to downloads:
552+ val downloadsUri = MediaStore .Downloads .getContentUri(MediaStore .VOLUME_EXTERNAL_PRIMARY )
553+
554+ val contentDetails = ContentValues ().apply {
555+ put(MediaStore .Downloads .DISPLAY_NAME , " HTTP Toolkit Certificate.crt" )
556+ put(MediaStore .Downloads .MIME_TYPE , " application/x-x509-ca-cert" )
557+ put(MediaStore .Downloads .IS_PENDING , 1 )
558+ }
559+
560+ val certUri = contentResolver.insert(downloadsUri, contentDetails)
561+ ? : throw RuntimeException (" Could not get download cert URI" )
562+
563+ // Write cert contents to a file:
564+ withContext(Dispatchers .IO ) {
565+ contentResolver.openFileDescriptor(certUri, " w" , null ).use { f ->
566+ ParcelFileDescriptor .AutoCloseOutputStream (f).write(cert.encoded)
567+ }
568+ }
569+
570+ // All done, mark it as such:
571+ contentDetails.clear()
572+ contentDetails.put(MediaStore .Downloads .IS_PENDING , 0 )
573+ contentResolver.update(certUri, contentDetails, null , null )
574+
575+ withContext(Dispatchers .Main ) {
576+ MaterialAlertDialogBuilder (this @MainActivity)
577+ .setTitle(" Manual setup required" )
578+ .setIcon(R .drawable.ic_exclamation_triangle)
579+ .setMessage(
580+ """
581+ Android ${Build .VERSION .RELEASE } doesn't allow automatic certificate setup.
582+
583+ To allow HTTP Toolkit to intercept HTTPS traffic:
584+
585+ - Open "Encryption & Credentials" in your security settings
586+ - Select "Install a certificate", and then "CA Certificate"
587+ - Select the HTTP Toolkit certificate
588+ """ .trimIndent()
589+ )
590+ .setPositiveButton(" Open security settings now" ) { _, _ ->
591+ startActivityForResult(Intent (Settings .ACTION_SECURITY_SETTINGS ), INSTALL_CERT_REQUEST )
592+ }
593+ .show()
594+ }
595+ }
596+
529597 private suspend fun promptToUpdate () {
530598 withContext(Dispatchers .Main ) {
531599 MaterialAlertDialogBuilder (this @MainActivity)
0 commit comments