11package tech.httptoolkit.android
22
3+ import android.Manifest
34import android.app.Activity
5+ import android.app.NotificationManager
46import android.content.*
57import android.content.pm.PackageManager
8+ import android.content.pm.PackageManager.PERMISSION_GRANTED
69import android.net.Uri
710import android.net.VpnService
811import android.os.Build
@@ -20,17 +23,19 @@ import android.view.View
2023import android.widget.Button
2124import android.widget.LinearLayout
2225import android.widget.TextView
26+ import androidx.activity.result.contract.ActivityResultContracts
2327import androidx.annotation.MainThread
2428import androidx.annotation.RequiresApi
2529import androidx.annotation.StringRes
2630import androidx.appcompat.app.AppCompatActivity
2731import androidx.appcompat.view.ContextThemeWrapper
32+ import androidx.core.app.ActivityCompat
33+ import androidx.core.content.ContextCompat
2834import androidx.localbroadcastmanager.content.LocalBroadcastManager
2935import com.google.android.gms.common.GooglePlayServicesUtil
3036import com.google.android.material.dialog.MaterialAlertDialogBuilder
3137import io.sentry.Sentry
3238import kotlinx.coroutines.*
33- import java.lang.RuntimeException
3439import java.net.ConnectException
3540import java.net.SocketTimeoutException
3641import java.security.cert.Certificate
@@ -42,6 +47,7 @@ const val INSTALL_CERT_REQUEST = 456
4247const val SCAN_REQUEST = 789
4348const val PICK_APPS_REQUEST = 499
4449const val PICK_PORTS_REQUEST = 443
50+ const val ENABLE_NOTIFICATIONS_REQUEST = 101
4551
4652enum class MainState {
4753 DISCONNECTED ,
@@ -484,26 +490,34 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
484490 override fun onActivityResult (requestCode : Int , resultCode : Int , data : Intent ? ) {
485491 super .onActivityResult(requestCode, resultCode, data)
486492
487- Log .i(TAG , " onActivityResult" )
488- Log .i(TAG , when (requestCode) {
489- START_VPN_REQUEST -> " start-vpn"
490- INSTALL_CERT_REQUEST -> " install-cert"
491- SCAN_REQUEST -> " scan-request"
492- PICK_APPS_REQUEST -> " pick-apps"
493- PICK_PORTS_REQUEST -> " pick-ports"
494- else -> requestCode.toString()
495- })
496-
497- Log .i(TAG , if (resultCode == RESULT_OK ) " ok" else resultCode.toString())
498-
499493 val resultOk = resultCode == RESULT_OK ||
500- (requestCode == INSTALL_CERT_REQUEST && whereIsCertTrusted(currentProxyConfig!! ) != null )
494+ (requestCode == INSTALL_CERT_REQUEST && whereIsCertTrusted(currentProxyConfig!! ) != null ) ||
495+ (requestCode == ENABLE_NOTIFICATIONS_REQUEST && areNotificationsEnabled())
496+
497+ Log .i(TAG , " onActivityResult: " + (
498+ when (requestCode) {
499+ START_VPN_REQUEST -> " start-vpn"
500+ INSTALL_CERT_REQUEST -> " install-cert"
501+ SCAN_REQUEST -> " scan-request"
502+ PICK_APPS_REQUEST -> " pick-apps"
503+ PICK_PORTS_REQUEST -> " pick-ports"
504+ ENABLE_NOTIFICATIONS_REQUEST -> " enable-notifications"
505+ else -> requestCode.toString()
506+ }
507+ ) + " - result: " + (
508+ if (resultOk) " ok" else resultCode.toString()
509+ )
510+ )
501511
502512 if (resultOk) {
503513 if (requestCode == START_VPN_REQUEST && currentProxyConfig != null ) {
504- Log .i(TAG , " Installing cert" )
514+ Log .i(TAG , " Installing cert... " )
505515 ensureCertificateTrusted(currentProxyConfig!! )
506516 } else if (requestCode == INSTALL_CERT_REQUEST ) {
517+ Log .i(TAG ," Cert installed, checking notification perms..." )
518+ ensureNotificationsEnabled()
519+ } else if (requestCode == ENABLE_NOTIFICATIONS_REQUEST ) {
520+ Log .i(TAG ," Notifications OK, starting VPN..." )
507521 startVpn()
508522 } else if (requestCode == SCAN_REQUEST && data != null ) {
509523 val url = data.getStringExtra(SCANNED_URL_EXTRA )!!
@@ -542,7 +556,16 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
542556 // via prompt. We redo the manual step regardless: either (on modern Android) manual is
543557 // required so this is just reshowing the instructions, or it was automated but that's not
544558 // working for some reason, in which case manual setup is a best-effort fallback.
545- launch { promptToManuallyInstallCert(currentProxyConfig!! .certificate, repeatPrompt = true ) }
559+ launch {
560+ promptToManuallyInstallCert(
561+ currentProxyConfig!! .certificate,
562+ repeatPrompt = true
563+ )
564+ }
565+ } else if (requestCode == ENABLE_NOTIFICATIONS_REQUEST ) {
566+ // If we tried to enable notifications, and it didn't work (the user
567+ // ignored us) then try try again.
568+ requestNotificationPermission(true )
546569 } else {
547570 Sentry .capture(" Non-OK result $resultCode for requestCode $requestCode " )
548571 mainState = MainState .FAILED
@@ -706,6 +729,115 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
706729 }
707730 }
708731
732+ private fun ensureNotificationsEnabled () {
733+ if (areNotificationsEnabled()) {
734+ onActivityResult(ENABLE_NOTIFICATIONS_REQUEST , RESULT_OK , null )
735+ } else {
736+ // This should only be called on the first attempt, generally, so we assume we
737+ // haven't been rejected yet:
738+ requestNotificationPermission(false )
739+ }
740+ }
741+
742+ private fun areNotificationsEnabled (): Boolean {
743+ // In Android 13+ notification permissions are blocked (even for foreground services) until
744+ // we specifically request them.
745+ if (
746+ Build .VERSION .SDK_INT >= Build .VERSION_CODES .TIRAMISU &&
747+ ContextCompat .checkSelfPermission(this , Manifest .permission.POST_NOTIFICATIONS ) != PERMISSION_GRANTED
748+ ) {
749+ return false
750+ }
751+
752+ val notificationManager = getSystemService(NOTIFICATION_SERVICE ) as NotificationManager
753+ val appNotificationsEnabled = if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .N ) {
754+ notificationManager.areNotificationsEnabled()
755+ } else {
756+ true
757+ }
758+
759+ if (! appNotificationsEnabled) return false
760+
761+ // For Android < 26 you can only enable/disable notifications globally:
762+ if (Build .VERSION .SDK_INT < Build .VERSION_CODES .O ) return true
763+
764+ // For Android 26+ you can disable individual channels: here we check our VPN notification
765+ // channel is not disabled (if it's already been created).
766+ val channel = notificationManager.getNotificationChannel(VPN_NOTIFICATION_CHANNEL_ID )
767+ return channel == null || channel.importance != NotificationManager .IMPORTANCE_NONE
768+ }
769+
770+ private fun requestNotificationPermission (previouslyRejected : Boolean ) {
771+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .TIRAMISU ) {
772+ val shouldExplain = ActivityCompat .shouldShowRequestPermissionRationale(
773+ this @MainActivity,
774+ Manifest .permission.POST_NOTIFICATIONS
775+ )
776+
777+ if (shouldExplain) {
778+ // ShouldExplain means that we've asked before, but been rejected, but we are
779+ // still allowed to ask again. Be more insistent, and do so:
780+ showNotificationPermissionRequiredPrompt() { ->
781+ Log .i(TAG ," Asking for POST_NOTIFICATIONS after prompt" )
782+ notificationPermissionHandler.launch(Manifest .permission.POST_NOTIFICATIONS )
783+ }
784+ return
785+ } else if (! previouslyRejected) {
786+ // This means we're asking for the first time - no detailed rationale and no
787+ // fallbacks required, just ask for permission:
788+ Log .i(TAG ," Asking for POST_NOTIFICATIONS directly" )
789+ notificationPermissionHandler.launch(Manifest .permission.POST_NOTIFICATIONS )
790+ return
791+ }
792+ // Otherwise, continue to the non-Tiramisu settings approach:
793+ }
794+
795+ // Pre-Tiramisu, we can't use POST_NOTIFICATIONS. Alternatively, if Tiramisu but we've
796+ // been completely rejected already, we can't show a normal prompt. Either way, we need
797+ // to send the user to the settings page to fix this manually.
798+
799+ // But if we have to send you to settings, we always want to show a prompt first:
800+ showNotificationPermissionRequiredPrompt { ->
801+ Log .i(TAG ," Sending to settings to fix notification permissions" )
802+ val intent = Intent (
803+ Settings .ACTION_APPLICATION_DETAILS_SETTINGS ,
804+ Uri .fromParts(" package" , packageName, null )
805+ )
806+ startActivityForResult(intent, ENABLE_NOTIFICATIONS_REQUEST )
807+ }
808+ }
809+
810+ private fun showNotificationPermissionRequiredPrompt (nextStep : () -> Unit ) {
811+ Log .i(TAG ," Showing notifications-required prompt" )
812+ launch {
813+ withContext(Dispatchers .Main ) {
814+ MaterialAlertDialogBuilder (this @MainActivity)
815+ .setTitle(" Notification permission is required" )
816+ .setIcon(R .drawable.ic_exclamation_triangle)
817+ .setMessage(
818+ " Please allow notifications to use HTTP Toolkit. This is used " +
819+ " exclusively for VPN connection status indicators."
820+ )
821+ .setPositiveButton(" Ok" ) { _, _ -> }
822+ .setOnDismissListener { _ ->
823+ // Dismiss is called on both click-away and 'Ok'
824+ nextStep()
825+ }
826+ .show()
827+ }
828+ }
829+ }
830+
831+ private val notificationPermissionHandler = registerForActivityResult(ActivityResultContracts .RequestPermission ()) { isGranted ->
832+ if (isGranted && areNotificationsEnabled()) { // Note permission might be accepted but channels disabled
833+ Log .i(TAG , " Notifications permission prompt accepted" )
834+ onActivityResult(ENABLE_NOTIFICATIONS_REQUEST , RESULT_OK , null )
835+ } else {
836+ Log .w(TAG , " Notifications permission prompt rejected" )
837+ requestNotificationPermission(true )
838+ }
839+ }
840+
709841 private suspend fun promptToUpdate () {
710842 withContext(Dispatchers .Main ) {
711843 MaterialAlertDialogBuilder (this @MainActivity)
0 commit comments