@elizaos/capacitor-websiteblocker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ElizaosCapacitorWebsiteblocker'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license'] || { :type => 'MIT' }
10
+ s.homepage = 'https://elizaos.ai'
11
+ s.authors = { 'elizaOS' => 'dev@elizaos.ai' }
12
+ s.source = { :git => 'https://github.com/elizaOS/eliza.git', :tag => s.version.to_s }
13
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '15.0'
15
+ s.dependency 'Capacitor'
16
+ s.swift_version = '5.1'
17
+ end
@@ -0,0 +1,51 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
4
+ androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0'
5
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5'
6
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1'
7
+ }
8
+
9
+ apply plugin: 'com.android.library'
10
+ android {
11
+ namespace = "ai.eliza.plugins.websiteblocker"
12
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
13
+
14
+ defaultConfig {
15
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
16
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
17
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
18
+ }
19
+
20
+ buildTypes {
21
+ release {
22
+ minifyEnabled false
23
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
24
+ }
25
+ }
26
+
27
+ compileOptions {
28
+ sourceCompatibility JavaVersion.VERSION_17
29
+ targetCompatibility JavaVersion.VERSION_17
30
+ }
31
+
32
+ }
33
+
34
+ repositories {
35
+ google()
36
+ maven {
37
+ url = uri(rootProject.ext.mavenCentralMirrorUrl)
38
+ }
39
+ mavenCentral()
40
+ }
41
+
42
+ dependencies {
43
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
44
+ implementation project(':capacitor-android')
45
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
46
+ implementation "androidx.core:core-ktx:$androidxCoreVersion"
47
+
48
+ testImplementation "junit:junit:$junitVersion"
49
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
50
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
51
+ }
@@ -0,0 +1,35 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
4
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
5
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
6
+ <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
7
+
8
+ <application>
9
+ <service
10
+ android:name=".WebsiteBlockerVpnService"
11
+ android:enabled="true"
12
+ android:exported="false"
13
+ android:foregroundServiceType="specialUse"
14
+ android:permission="android.permission.BIND_VPN_SERVICE">
15
+ <intent-filter>
16
+ <action android:name="android.net.VpnService" />
17
+ </intent-filter>
18
+
19
+ <property
20
+ android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
21
+ android:value="website_blocking_vpn" />
22
+ </service>
23
+
24
+ <receiver
25
+ android:name=".WebsiteBlockerBootReceiver"
26
+ android:enabled="true"
27
+ android:exported="false">
28
+ <intent-filter>
29
+ <action android:name="android.intent.action.BOOT_COMPLETED" />
30
+ <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
31
+ <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
32
+ </intent-filter>
33
+ </receiver>
34
+ </application>
35
+ </manifest>
@@ -0,0 +1,170 @@
1
+ package ai.eliza.plugins.websiteblocker
2
+
3
+ import java.net.Inet4Address
4
+
5
+ data class DnsQueryPacket(
6
+ val sourceAddress: ByteArray,
7
+ val destinationAddress: ByteArray,
8
+ val sourcePort: Int,
9
+ val destinationPort: Int,
10
+ val dnsPayload: ByteArray,
11
+ val queryName: String,
12
+ )
13
+
14
+ object DnsPacketCodec {
15
+ private const val IPV4_HEADER_SIZE = 20
16
+ private const val UDP_HEADER_SIZE = 8
17
+
18
+ fun parseUdpDnsQuery(
19
+ packet: ByteArray,
20
+ length: Int,
21
+ expectedDnsAddress: Inet4Address,
22
+ ): DnsQueryPacket? {
23
+ if (length < IPV4_HEADER_SIZE + UDP_HEADER_SIZE + 12) {
24
+ return null
25
+ }
26
+
27
+ val version = (packet[0].toInt() ushr 4) and 0x0F
28
+ val headerLength = (packet[0].toInt() and 0x0F) * 4
29
+ if (version != 4 || headerLength < IPV4_HEADER_SIZE || length < headerLength + UDP_HEADER_SIZE) {
30
+ return null
31
+ }
32
+
33
+ val protocol = packet[9].toInt() and 0xFF
34
+ if (protocol != 17) {
35
+ return null
36
+ }
37
+
38
+ val destinationAddress = packet.copyOfRange(16, 20)
39
+ if (!destinationAddress.contentEquals(expectedDnsAddress.address)) {
40
+ return null
41
+ }
42
+
43
+ val udpOffset = headerLength
44
+ val sourcePort = readUInt16(packet, udpOffset)
45
+ val destinationPort = readUInt16(packet, udpOffset + 2)
46
+ if (destinationPort != 53) {
47
+ return null
48
+ }
49
+
50
+ val udpLength = readUInt16(packet, udpOffset + 4)
51
+ if (udpLength < UDP_HEADER_SIZE || udpOffset + udpLength > length) {
52
+ return null
53
+ }
54
+
55
+ val dnsOffset = udpOffset + UDP_HEADER_SIZE
56
+ val dnsPayload = packet.copyOfRange(dnsOffset, dnsOffset + udpLength - UDP_HEADER_SIZE)
57
+ val queryName = parseQueryName(dnsPayload) ?: return null
58
+
59
+ return DnsQueryPacket(
60
+ sourceAddress = packet.copyOfRange(12, 16),
61
+ destinationAddress = destinationAddress,
62
+ sourcePort = sourcePort,
63
+ destinationPort = destinationPort,
64
+ dnsPayload = dnsPayload,
65
+ queryName = queryName,
66
+ )
67
+ }
68
+
69
+ fun buildBlockedDnsResponse(queryPayload: ByteArray): ByteArray {
70
+ val response = queryPayload.copyOf()
71
+ response[2] = (response[2].toInt() or 0x80).toByte()
72
+ response[3] = ((response[3].toInt() and 0xF0) or 0x03).toByte()
73
+ response[6] = 0
74
+ response[7] = 0
75
+ response[8] = 0
76
+ response[9] = 0
77
+ response[10] = 0
78
+ response[11] = 0
79
+ return response
80
+ }
81
+
82
+ fun buildServerFailureDnsResponse(queryPayload: ByteArray): ByteArray {
83
+ val response = queryPayload.copyOf()
84
+ response[2] = (response[2].toInt() or 0x80).toByte()
85
+ response[3] = ((response[3].toInt() and 0xF0) or 0x02).toByte()
86
+ response[6] = 0
87
+ response[7] = 0
88
+ response[8] = 0
89
+ response[9] = 0
90
+ response[10] = 0
91
+ response[11] = 0
92
+ return response
93
+ }
94
+
95
+ fun buildUdpDnsResponse(query: DnsQueryPacket, dnsPayload: ByteArray): ByteArray {
96
+ val udpLength = UDP_HEADER_SIZE + dnsPayload.size
97
+ val totalLength = IPV4_HEADER_SIZE + udpLength
98
+ val response = ByteArray(totalLength)
99
+
100
+ response[0] = 0x45
101
+ response[1] = 0
102
+ writeUInt16(response, 2, totalLength)
103
+ writeUInt16(response, 4, 0)
104
+ writeUInt16(response, 6, 0)
105
+ response[8] = 64
106
+ response[9] = 17
107
+ writeUInt16(response, 10, 0)
108
+
109
+ System.arraycopy(query.destinationAddress, 0, response, 12, 4)
110
+ System.arraycopy(query.sourceAddress, 0, response, 16, 4)
111
+ writeUInt16(response, IPV4_HEADER_SIZE, query.destinationPort)
112
+ writeUInt16(response, IPV4_HEADER_SIZE + 2, query.sourcePort)
113
+ writeUInt16(response, IPV4_HEADER_SIZE + 4, udpLength)
114
+ writeUInt16(response, IPV4_HEADER_SIZE + 6, 0)
115
+ System.arraycopy(dnsPayload, 0, response, IPV4_HEADER_SIZE + UDP_HEADER_SIZE, dnsPayload.size)
116
+
117
+ val checksum = computeIpv4HeaderChecksum(response, IPV4_HEADER_SIZE)
118
+ writeUInt16(response, 10, checksum)
119
+ return response
120
+ }
121
+
122
+ private fun parseQueryName(payload: ByteArray): String? {
123
+ if (payload.size < 12) {
124
+ return null
125
+ }
126
+ var offset = 12
127
+ val labels = mutableListOf<String>()
128
+ while (offset < payload.size) {
129
+ val length = payload[offset].toInt() and 0xFF
130
+ if (length == 0) {
131
+ return labels.joinToString(".")
132
+ }
133
+ if (length and 0xC0 != 0 || offset + 1 + length > payload.size) {
134
+ return null
135
+ }
136
+ val label = payload.copyOfRange(offset + 1, offset + 1 + length)
137
+ .toString(Charsets.UTF_8)
138
+ labels += label
139
+ offset += length + 1
140
+ }
141
+ return null
142
+ }
143
+
144
+ private fun readUInt16(buffer: ByteArray, offset: Int): Int {
145
+ return ((buffer[offset].toInt() and 0xFF) shl 8) or
146
+ (buffer[offset + 1].toInt() and 0xFF)
147
+ }
148
+
149
+ private fun writeUInt16(buffer: ByteArray, offset: Int, value: Int) {
150
+ buffer[offset] = ((value ushr 8) and 0xFF).toByte()
151
+ buffer[offset + 1] = (value and 0xFF).toByte()
152
+ }
153
+
154
+ private fun computeIpv4HeaderChecksum(packet: ByteArray, headerLength: Int): Int {
155
+ var sum = 0
156
+ var offset = 0
157
+ while (offset < headerLength) {
158
+ if (offset == 10) {
159
+ offset += 2
160
+ continue
161
+ }
162
+ sum += readUInt16(packet, offset)
163
+ while (sum > 0xFFFF) {
164
+ sum = (sum and 0xFFFF) + (sum ushr 16)
165
+ }
166
+ offset += 2
167
+ }
168
+ return sum.inv() and 0xFFFF
169
+ }
170
+ }
@@ -0,0 +1,39 @@
1
+ package ai.eliza.plugins.websiteblocker
2
+
3
+ import android.content.BroadcastReceiver
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import androidx.core.content.ContextCompat
7
+
8
+ class WebsiteBlockerBootReceiver : BroadcastReceiver() {
9
+ override fun onReceive(context: Context, intent: Intent) {
10
+ val action = intent.action ?: return
11
+ if (
12
+ action != Intent.ACTION_BOOT_COMPLETED &&
13
+ action != Intent.ACTION_LOCKED_BOOT_COMPLETED &&
14
+ action != Intent.ACTION_MY_PACKAGE_REPLACED
15
+ ) {
16
+ return
17
+ }
18
+
19
+ val savedBlock = WebsiteBlockerStateStore.load(context) ?: return
20
+ if (android.net.VpnService.prepare(context) != null) {
21
+ return
22
+ }
23
+
24
+ ContextCompat.startForegroundService(
25
+ context,
26
+ Intent(context, WebsiteBlockerVpnService::class.java).apply {
27
+ this.action = WebsiteBlockerVpnService.ACTION_START
28
+ putStringArrayListExtra(
29
+ WebsiteBlockerVpnService.EXTRA_WEBSITES,
30
+ ArrayList(savedBlock.requestedWebsites),
31
+ )
32
+ putExtra(
33
+ WebsiteBlockerVpnService.EXTRA_ENDS_AT,
34
+ savedBlock.endsAtEpochMs ?: -1L,
35
+ )
36
+ },
37
+ )
38
+ }
39
+ }
@@ -0,0 +1,277 @@
1
+ package ai.eliza.plugins.websiteblocker
2
+
3
+ import android.content.Intent
4
+ import android.net.VpnService
5
+ import android.os.Build
6
+ import android.provider.Settings
7
+ import androidx.activity.result.ActivityResult
8
+ import androidx.core.content.ContextCompat
9
+ import com.getcapacitor.JSArray
10
+ import com.getcapacitor.JSObject
11
+ import com.getcapacitor.Plugin
12
+ import com.getcapacitor.PluginCall
13
+ import com.getcapacitor.PluginMethod
14
+ import com.getcapacitor.annotation.ActivityCallback
15
+ import com.getcapacitor.annotation.CapacitorPlugin
16
+ import java.time.Instant
17
+
18
+ @CapacitorPlugin(name = "ElizaWebsiteBlocker")
19
+ class WebsiteBlockerPlugin : Plugin() {
20
+ private data class PendingStartRequest(
21
+ val websites: List<String>,
22
+ val endsAtEpochMs: Long?,
23
+ )
24
+
25
+ private var pendingStartRequest: PendingStartRequest? = null
26
+
27
+ @PluginMethod
28
+ fun getStatus(call: PluginCall) {
29
+ call.resolve(buildStatus())
30
+ }
31
+
32
+ @PluginMethod
33
+ fun startBlock(call: PluginCall) {
34
+ val websites = extractWebsites(call)
35
+ if (websites.isEmpty()) {
36
+ call.resolve(JSObject().apply {
37
+ put("success", false)
38
+ put("error", "Provide at least one public website hostname, such as x.com or twitter.com.")
39
+ })
40
+ return
41
+ }
42
+
43
+ val durationMinutes = parseDurationMinutes(call)
44
+ val endsAtEpochMs = durationMinutes?.let { System.currentTimeMillis() + it * 60_000 }
45
+ val permissionIntent = VpnService.prepare(context)
46
+ if (permissionIntent != null) {
47
+ pendingStartRequest = PendingStartRequest(websites, endsAtEpochMs)
48
+ startActivityForResult(call, permissionIntent, "handleVpnPermissionResult")
49
+ return
50
+ }
51
+
52
+ startBlockInternal(websites, endsAtEpochMs)
53
+ call.resolve(buildStartResult(websites, durationMinutes, endsAtEpochMs))
54
+ }
55
+
56
+ @PluginMethod
57
+ fun stopBlock(call: PluginCall) {
58
+ WebsiteBlockerStateStore.clear(context)
59
+ context.stopService(Intent(context, WebsiteBlockerVpnService::class.java).apply {
60
+ action = WebsiteBlockerVpnService.ACTION_STOP
61
+ })
62
+
63
+ call.resolve(JSObject().apply {
64
+ put("success", true)
65
+ put("removed", true)
66
+ put("status", JSObject().apply {
67
+ put("active", false)
68
+ put("endsAt", null)
69
+ put("websites", JSArray())
70
+ put("requestedWebsites", JSArray())
71
+ put("blockedWebsites", JSArray())
72
+ put("allowedWebsites", JSArray())
73
+ put("matchMode", "exact")
74
+ put("canUnblockEarly", true)
75
+ put("requiresElevation", permissionRequiresConsent())
76
+ })
77
+ })
78
+ }
79
+
80
+ @PluginMethod
81
+ override fun checkPermissions(call: PluginCall) {
82
+ call.resolve(buildPermissionResult())
83
+ }
84
+
85
+ @PluginMethod
86
+ override fun requestPermissions(call: PluginCall) {
87
+ val permissionIntent = VpnService.prepare(context)
88
+ if (permissionIntent == null) {
89
+ call.resolve(buildPermissionResult())
90
+ return
91
+ }
92
+ startActivityForResult(call, permissionIntent, "handleVpnPermissionResult")
93
+ }
94
+
95
+ @PluginMethod
96
+ fun openSettings(call: PluginCall) {
97
+ val activity = activity
98
+ if (activity == null) {
99
+ call.resolve(JSObject().apply {
100
+ put("opened", false)
101
+ })
102
+ return
103
+ }
104
+
105
+ activity.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
106
+ call.resolve(JSObject().apply {
107
+ put("opened", true)
108
+ })
109
+ }
110
+
111
+ @ActivityCallback
112
+ private fun handleVpnPermissionResult(call: PluginCall, result: ActivityResult) {
113
+ if (result.resultCode != android.app.Activity.RESULT_OK) {
114
+ if (pendingStartRequest != null) {
115
+ pendingStartRequest = null
116
+ call.resolve(JSObject().apply {
117
+ put("success", false)
118
+ put("error", "Android VPN consent was not granted.")
119
+ })
120
+ return
121
+ }
122
+
123
+ call.resolve(buildPermissionResult())
124
+ return
125
+ }
126
+
127
+ val pendingStart = pendingStartRequest
128
+ if (pendingStart != null) {
129
+ pendingStartRequest = null
130
+ startBlockInternal(
131
+ pendingStart.websites,
132
+ pendingStart.endsAtEpochMs,
133
+ )
134
+ call.resolve(
135
+ buildStartResult(
136
+ pendingStart.websites,
137
+ durationMinutesFromEndsAt(pendingStart.endsAtEpochMs),
138
+ pendingStart.endsAtEpochMs,
139
+ ),
140
+ )
141
+ return
142
+ }
143
+
144
+ call.resolve(buildPermissionResult())
145
+ }
146
+
147
+ private fun startBlockInternal(
148
+ websites: List<String>,
149
+ endsAtEpochMs: Long?,
150
+ ) {
151
+ WebsiteBlockerStateStore.save(context, websites, endsAtEpochMs)
152
+ val serviceIntent = Intent(context, WebsiteBlockerVpnService::class.java).apply {
153
+ action = WebsiteBlockerVpnService.ACTION_START
154
+ putStringArrayListExtra(
155
+ WebsiteBlockerVpnService.EXTRA_WEBSITES,
156
+ ArrayList(websites),
157
+ )
158
+ putExtra(
159
+ WebsiteBlockerVpnService.EXTRA_ENDS_AT,
160
+ endsAtEpochMs ?: -1L,
161
+ )
162
+ }
163
+ ContextCompat.startForegroundService(context, serviceIntent)
164
+ }
165
+
166
+ private fun durationMinutesFromEndsAt(endsAtEpochMs: Long?): Long? {
167
+ if (endsAtEpochMs == null) return null
168
+ val remainingMs = endsAtEpochMs - System.currentTimeMillis()
169
+ return if (remainingMs <= 0) 0 else kotlin.math.ceil(remainingMs / 60_000.0).toLong()
170
+ }
171
+
172
+ private fun buildStartResult(
173
+ websites: List<String>,
174
+ durationMinutes: Long?,
175
+ endsAtEpochMs: Long?,
176
+ ): JSObject {
177
+ return JSObject().apply {
178
+ put("success", true)
179
+ put(
180
+ "endsAt",
181
+ endsAtEpochMs?.let { Instant.ofEpochMilli(it).toString() },
182
+ )
183
+ put("request", JSObject().apply {
184
+ put("websites", JSArray(websites))
185
+ put("durationMinutes", durationMinutes)
186
+ })
187
+ }
188
+ }
189
+
190
+ private fun buildPermissionResult(): JSObject {
191
+ val granted = !permissionRequiresConsent()
192
+ return JSObject().apply {
193
+ put("status", if (granted) "granted" else "not-determined")
194
+ put("canRequest", !granted)
195
+ if (!granted) {
196
+ put(
197
+ "reason",
198
+ "Android needs VPN consent before Eliza can block websites system-wide on this phone.",
199
+ )
200
+ }
201
+ }
202
+ }
203
+
204
+ private fun buildStatus(): JSObject {
205
+ val saved = WebsiteBlockerStateStore.load(context)
206
+ val permission = buildPermissionResult()
207
+ return JSObject().apply {
208
+ put("available", true)
209
+ put("active", saved != null)
210
+ put("hostsFilePath", null)
211
+ put(
212
+ "endsAt",
213
+ saved?.endsAtEpochMs?.let { Instant.ofEpochMilli(it).toString() },
214
+ )
215
+ put("websites", JSArray(saved?.requestedWebsites ?: emptyList<String>()))
216
+ put("requestedWebsites", JSArray(saved?.requestedWebsites ?: emptyList<String>()))
217
+ put("blockedWebsites", JSArray(saved?.blockedWebsites ?: emptyList<String>()))
218
+ put("allowedWebsites", JSArray(saved?.allowedWebsites ?: emptyList<String>()))
219
+ put("matchMode", saved?.matchMode ?: "exact")
220
+ put("canUnblockEarly", true)
221
+ put("requiresElevation", permissionRequiresConsent())
222
+ put("engine", "vpn-dns")
223
+ put("platform", "android")
224
+ put("supportsElevationPrompt", permissionRequiresConsent())
225
+ put(
226
+ "elevationPromptMethod",
227
+ if (permissionRequiresConsent()) "vpn-consent" else null,
228
+ )
229
+ put("permissionStatus", permission.getString("status"))
230
+ put("canRequestPermission", permission.getBool("canRequest"))
231
+ put("canOpenSystemSettings", true)
232
+ val reason = when {
233
+ saved != null && !WebsiteBlockerVpnService.isRunning() ->
234
+ "Website blocking is configured and the VPN service is reconnecting."
235
+ permissionRequiresConsent() ->
236
+ permission.getString("reason")
237
+ else -> null
238
+ }
239
+ if (reason != null) {
240
+ put("reason", reason)
241
+ }
242
+ }
243
+ }
244
+
245
+ private fun extractWebsites(call: PluginCall): List<String> {
246
+ val websites = mutableListOf<String>()
247
+ val explicitWebsites = call.data.optJSONArray("websites")
248
+ if (explicitWebsites != null) {
249
+ for (index in 0 until explicitWebsites.length()) {
250
+ val value = explicitWebsites.optString(index)
251
+ WebsiteBlockerStateStore.normalizeHostname(value)?.let(websites::add)
252
+ }
253
+ }
254
+
255
+ val text = call.getString("text")
256
+ if (!text.isNullOrBlank()) {
257
+ text.split(Regex("[\\s,]+"))
258
+ .mapNotNull(WebsiteBlockerStateStore::normalizeHostname)
259
+ .forEach(websites::add)
260
+ }
261
+
262
+ return websites.distinct()
263
+ }
264
+
265
+ private fun parseDurationMinutes(call: PluginCall): Long? {
266
+ val rawValue = call.data.opt("durationMinutes") ?: return null
267
+ return when (rawValue) {
268
+ is Number -> rawValue.toLong()
269
+ is String -> rawValue.toLongOrNull()
270
+ else -> null
271
+ }?.takeIf { it > 0 }
272
+ }
273
+
274
+ private fun permissionRequiresConsent(): Boolean {
275
+ return VpnService.prepare(context) != null
276
+ }
277
+ }