1
1
package tech.httptoolkit.android
2
2
3
+ import android.Manifest
3
4
import android.app.Activity
5
+ import android.app.NotificationManager
4
6
import android.content.*
5
7
import android.content.pm.PackageManager
8
+ import android.content.pm.PackageManager.PERMISSION_GRANTED
6
9
import android.net.Uri
7
10
import android.net.VpnService
8
11
import android.os.Build
@@ -20,17 +23,19 @@ import android.view.View
20
23
import android.widget.Button
21
24
import android.widget.LinearLayout
22
25
import android.widget.TextView
26
+ import androidx.activity.result.contract.ActivityResultContracts
23
27
import androidx.annotation.MainThread
24
28
import androidx.annotation.RequiresApi
25
29
import androidx.annotation.StringRes
26
30
import androidx.appcompat.app.AppCompatActivity
27
31
import androidx.appcompat.view.ContextThemeWrapper
32
+ import androidx.core.app.ActivityCompat
33
+ import androidx.core.content.ContextCompat
28
34
import androidx.localbroadcastmanager.content.LocalBroadcastManager
29
35
import com.google.android.gms.common.GooglePlayServicesUtil
30
36
import com.google.android.material.dialog.MaterialAlertDialogBuilder
31
37
import io.sentry.Sentry
32
38
import kotlinx.coroutines.*
33
- import java.lang.RuntimeException
34
39
import java.net.ConnectException
35
40
import java.net.SocketTimeoutException
36
41
import java.security.cert.Certificate
@@ -42,6 +47,7 @@ const val INSTALL_CERT_REQUEST = 456
42
47
const val SCAN_REQUEST = 789
43
48
const val PICK_APPS_REQUEST = 499
44
49
const val PICK_PORTS_REQUEST = 443
50
+ const val ENABLE_NOTIFICATIONS_REQUEST = 101
45
51
46
52
enum class MainState {
47
53
DISCONNECTED ,
@@ -484,26 +490,34 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
484
490
override fun onActivityResult (requestCode : Int , resultCode : Int , data : Intent ? ) {
485
491
super .onActivityResult(requestCode, resultCode, data)
486
492
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
-
499
493
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
+ )
501
511
502
512
if (resultOk) {
503
513
if (requestCode == START_VPN_REQUEST && currentProxyConfig != null ) {
504
- Log .i(TAG , " Installing cert" )
514
+ Log .i(TAG , " Installing cert... " )
505
515
ensureCertificateTrusted(currentProxyConfig!! )
506
516
} 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..." )
507
521
startVpn()
508
522
} else if (requestCode == SCAN_REQUEST && data != null ) {
509
523
val url = data.getStringExtra(SCANNED_URL_EXTRA )!!
@@ -542,7 +556,16 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
542
556
// via prompt. We redo the manual step regardless: either (on modern Android) manual is
543
557
// required so this is just reshowing the instructions, or it was automated but that's not
544
558
// 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 )
546
569
} else {
547
570
Sentry .capture(" Non-OK result $resultCode for requestCode $requestCode " )
548
571
mainState = MainState .FAILED
@@ -706,6 +729,115 @@ class MainActivity : AppCompatActivity(), CoroutineScope by MainScope() {
706
729
}
707
730
}
708
731
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
+
709
841
private suspend fun promptToUpdate () {
710
842
withContext(Dispatchers .Main ) {
711
843
MaterialAlertDialogBuilder (this @MainActivity)
0 commit comments