Skip to content

Commit d52a828

Browse files
committed
Ensure the VPN notification remains enabled wherever possible
This used to be unavoidable, but Android 13 has loosened the rules, and with the recent SDK update notification were no longer appearing by default. This fixes that, and covers various other scenarios, ensuring the VPN refuses to start until notifications are activated. The goal here is partly UX and partly security. This isn't intended to be used to observe anybody without their knowledge, and this makes that harder. The resulting UX is also generally better - it's much clearer when interception is happening, and it's much easier to turn it on & off.
1 parent cb5c7d9 commit d52a828

File tree

4 files changed

+153
-23
lines changed

4 files changed

+153
-23
lines changed

app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ dependencies {
4040
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"
4141
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.3"
4242
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.3"
43-
implementation 'androidx.appcompat:appcompat:1.1.0'
43+
implementation 'androidx.appcompat:appcompat:1.3.0'
4444
implementation 'androidx.core:core-ktx:1.1.0'
4545
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
4646
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0'

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
<uses-permission android:name="android.permission.INTERNET" />
88
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
99
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
10+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
1011
<uses-permission android:name="android.permission.CAMERA" />
1112
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
1213
tools:ignore="QueryAllPackagesPermission" />

app/src/main/java/tech/httptoolkit/android/MainActivity.kt

Lines changed: 148 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package tech.httptoolkit.android
22

3+
import android.Manifest
34
import android.app.Activity
5+
import android.app.NotificationManager
46
import android.content.*
57
import android.content.pm.PackageManager
8+
import android.content.pm.PackageManager.PERMISSION_GRANTED
69
import android.net.Uri
710
import android.net.VpnService
811
import android.os.Build
@@ -20,17 +23,19 @@ import android.view.View
2023
import android.widget.Button
2124
import android.widget.LinearLayout
2225
import android.widget.TextView
26+
import androidx.activity.result.contract.ActivityResultContracts
2327
import androidx.annotation.MainThread
2428
import androidx.annotation.RequiresApi
2529
import androidx.annotation.StringRes
2630
import androidx.appcompat.app.AppCompatActivity
2731
import androidx.appcompat.view.ContextThemeWrapper
32+
import androidx.core.app.ActivityCompat
33+
import androidx.core.content.ContextCompat
2834
import androidx.localbroadcastmanager.content.LocalBroadcastManager
2935
import com.google.android.gms.common.GooglePlayServicesUtil
3036
import com.google.android.material.dialog.MaterialAlertDialogBuilder
3137
import io.sentry.Sentry
3238
import kotlinx.coroutines.*
33-
import java.lang.RuntimeException
3439
import java.net.ConnectException
3540
import java.net.SocketTimeoutException
3641
import java.security.cert.Certificate
@@ -42,6 +47,7 @@ const val INSTALL_CERT_REQUEST = 456
4247
const val SCAN_REQUEST = 789
4348
const val PICK_APPS_REQUEST = 499
4449
const val PICK_PORTS_REQUEST = 443
50+
const val ENABLE_NOTIFICATIONS_REQUEST = 101
4551

4652
enum 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)

app/src/main/java/tech/httptoolkit/android/ProxyVpnService.kt

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ private const val ALL_ROUTES = "0.0.0.0"
2020
private const val VPN_IP_ADDRESS = "169.254.61.43" // Random link-local IP, this will be the tunnel's IP
2121

2222
private const val NOTIFICATION_ID = 45456
23-
private const val NOTIFICATION_CHANNEL_ID = "vpn-notifications"
23+
const val VPN_NOTIFICATION_CHANNEL_ID = "vpn-notifications"
2424

2525
const val START_VPN_ACTION = "tech.httptoolkit.android.START_VPN_ACTION"
2626
const val STOP_VPN_ACTION = "tech.httptoolkit.android.STOP_VPN_ACTION"
@@ -128,14 +128,14 @@ class ProxyVpnService : VpnService(), IProtectSocket {
128128
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
129129
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
130130
val notificationChannel = NotificationChannel(
131-
NOTIFICATION_CHANNEL_ID,
131+
VPN_NOTIFICATION_CHANNEL_ID,
132132
"VPN Status",
133133
NotificationManager.IMPORTANCE_DEFAULT
134134
)
135135
notificationManager.createNotificationChannel(notificationChannel)
136136
}
137137

138-
val notification: Notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
138+
val notification: Notification = NotificationCompat.Builder(this, VPN_NOTIFICATION_CHANNEL_ID)
139139
.setContentIntent(pendingActivityIntent)
140140
.setContentTitle(getString(R.string.vpn_active_notification_title))
141141
.setContentText(getString(R.string.vpn_active_notification_content))
@@ -145,7 +145,6 @@ class ProxyVpnService : VpnService(), IProtectSocket {
145145
.build()
146146

147147
startForeground(NOTIFICATION_ID, notification)
148-
149148
}
150149

151150
private fun startVpn(
@@ -242,8 +241,6 @@ class ProxyVpnService : VpnService(), IProtectSocket {
242241

243242
SocketProtector.getInstance().setProtector(this)
244243

245-
246-
247244
// TODO: Should we support *?
248245

249246
vpnRunnable = ProxyVpnRunnable(

0 commit comments

Comments
 (0)