@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.
- package/ElizaosCapacitorWebsiteBlocker.podspec +17 -0
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +35 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/DnsPacketCodec.kt +170 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/WebsiteBlockerBootReceiver.kt +39 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/WebsiteBlockerPlugin.kt +277 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/WebsiteBlockerStateStore.kt +205 -0
- package/android/src/main/java/ai/eliza/plugins/websiteblocker/WebsiteBlockerVpnService.kt +326 -0
- package/dist/esm/definitions.d.ts +83 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +18 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +100 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +115 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +118 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/WebsiteBlockerPlugin/WebsiteBlockerPlugin.swift +235 -0
- package/ios/Sources/WebsiteBlockerPlugin/WebsiteBlockerShared.swift +294 -0
- package/package.json +77 -0
|
@@ -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
|
+
}
|