@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,205 @@
|
|
|
1
|
+
package ai.eliza.plugins.websiteblocker
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
|
|
5
|
+
data class SavedWebsiteBlock(
|
|
6
|
+
val requestedWebsites: List<String>,
|
|
7
|
+
val blockedWebsites: List<String>,
|
|
8
|
+
val allowedWebsites: List<String>,
|
|
9
|
+
val matchMode: String,
|
|
10
|
+
val endsAtEpochMs: Long?,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
object WebsiteBlockerStateStore {
|
|
14
|
+
private const val PREFS_NAME = "eliza_website_blocker"
|
|
15
|
+
private const val KEY_REQUESTED_WEBSITES = "requested_websites"
|
|
16
|
+
private const val KEY_BLOCKED_WEBSITES = "blocked_websites"
|
|
17
|
+
private const val KEY_ALLOWED_WEBSITES = "allowed_websites"
|
|
18
|
+
private const val KEY_MATCH_MODE = "match_mode"
|
|
19
|
+
private const val KEY_WEBSITES = "websites"
|
|
20
|
+
private const val KEY_ENDS_AT = "ends_at_epoch_ms"
|
|
21
|
+
private const val MATCH_MODE_EXACT = "exact"
|
|
22
|
+
private const val MATCH_MODE_SUBDOMAIN = "subdomain"
|
|
23
|
+
|
|
24
|
+
private val X_TWITTER_REQUESTED_HOSTS = setOf("x.com", "twitter.com")
|
|
25
|
+
private val X_TWITTER_BLOCKED_HOSTS = setOf(
|
|
26
|
+
"x.com",
|
|
27
|
+
"www.x.com",
|
|
28
|
+
"mobile.x.com",
|
|
29
|
+
"twitter.com",
|
|
30
|
+
"www.twitter.com",
|
|
31
|
+
"mobile.twitter.com",
|
|
32
|
+
"t.co",
|
|
33
|
+
"abs.twimg.com",
|
|
34
|
+
"pbs.twimg.com",
|
|
35
|
+
"video.twimg.com",
|
|
36
|
+
"ton.twimg.com",
|
|
37
|
+
"platform.twitter.com",
|
|
38
|
+
"tweetdeck.twitter.com",
|
|
39
|
+
)
|
|
40
|
+
private val X_TWITTER_ALLOWED_HOSTS = setOf("api.x.com", "api.twitter.com")
|
|
41
|
+
private val GOOGLE_NEWS_REQUESTED_HOSTS = setOf("news.google.com")
|
|
42
|
+
private val GOOGLE_NEWS_BLOCKED_HOSTS = setOf("news.google.com")
|
|
43
|
+
private val GOOGLE_NEWS_ALLOWED_HOSTS = setOf(
|
|
44
|
+
"accounts.google.com",
|
|
45
|
+
"oauth2.googleapis.com",
|
|
46
|
+
"openidconnect.googleapis.com",
|
|
47
|
+
"www.googleapis.com",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
fun normalizeHostname(value: String): String? {
|
|
51
|
+
val trimmed = value.trim().trim('.').lowercase()
|
|
52
|
+
if (trimmed.isEmpty()) {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
if (!trimmed.contains('.')) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
if (!trimmed.matches(Regex("^[a-z0-9.-]+$"))) {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
if (trimmed.startsWith(".") || trimmed.endsWith(".")) {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
return trimmed
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private data class WebsiteBlockPolicy(
|
|
68
|
+
val requestedWebsites: List<String>,
|
|
69
|
+
val blockedWebsites: List<String>,
|
|
70
|
+
val allowedWebsites: List<String>,
|
|
71
|
+
val matchMode: String,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
private fun shouldAddWwwVariant(hostname: String): Boolean {
|
|
75
|
+
val parts = hostname.split(".")
|
|
76
|
+
return parts.size == 2 && parts[0] != "www"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private fun buildPolicy(requestedWebsites: Collection<String>): WebsiteBlockPolicy {
|
|
80
|
+
val normalizedRequested = requestedWebsites.mapNotNull(::normalizeHostname)
|
|
81
|
+
.distinct()
|
|
82
|
+
.sorted()
|
|
83
|
+
val blockedWebsites = linkedSetOf<String>()
|
|
84
|
+
val allowedWebsites = linkedSetOf<String>()
|
|
85
|
+
|
|
86
|
+
for (website in normalizedRequested) {
|
|
87
|
+
blockedWebsites += website
|
|
88
|
+
|
|
89
|
+
if (shouldAddWwwVariant(website)) {
|
|
90
|
+
blockedWebsites += "www.$website"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
when {
|
|
94
|
+
website in X_TWITTER_REQUESTED_HOSTS -> {
|
|
95
|
+
blockedWebsites += X_TWITTER_BLOCKED_HOSTS
|
|
96
|
+
allowedWebsites += X_TWITTER_ALLOWED_HOSTS
|
|
97
|
+
}
|
|
98
|
+
website in GOOGLE_NEWS_REQUESTED_HOSTS -> {
|
|
99
|
+
blockedWebsites += GOOGLE_NEWS_BLOCKED_HOSTS
|
|
100
|
+
allowedWebsites += GOOGLE_NEWS_ALLOWED_HOSTS
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return WebsiteBlockPolicy(
|
|
106
|
+
requestedWebsites = normalizedRequested,
|
|
107
|
+
blockedWebsites = blockedWebsites.mapNotNull(::normalizeHostname).distinct().sorted(),
|
|
108
|
+
allowedWebsites = allowedWebsites.mapNotNull(::normalizeHostname).distinct().sorted(),
|
|
109
|
+
matchMode = MATCH_MODE_EXACT,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun readNormalizedWebsiteSet(prefs: android.content.SharedPreferences, key: String): List<String> {
|
|
114
|
+
return prefs.getStringSet(key, null)
|
|
115
|
+
?.mapNotNull(::normalizeHostname)
|
|
116
|
+
?.distinct()
|
|
117
|
+
?.sorted()
|
|
118
|
+
.orEmpty()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
fun load(context: Context): SavedWebsiteBlock? {
|
|
122
|
+
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
123
|
+
val requestedWebsites = readNormalizedWebsiteSet(prefs, KEY_REQUESTED_WEBSITES)
|
|
124
|
+
.ifEmpty { readNormalizedWebsiteSet(prefs, KEY_WEBSITES) }
|
|
125
|
+
if (requestedWebsites.isEmpty()) {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
val policy = buildPolicy(requestedWebsites)
|
|
130
|
+
val blockedWebsites = readNormalizedWebsiteSet(prefs, KEY_BLOCKED_WEBSITES)
|
|
131
|
+
.ifEmpty { policy.blockedWebsites }
|
|
132
|
+
val allowedWebsites = readNormalizedWebsiteSet(prefs, KEY_ALLOWED_WEBSITES)
|
|
133
|
+
.ifEmpty { policy.allowedWebsites }
|
|
134
|
+
val matchMode = prefs.getString(KEY_MATCH_MODE, MATCH_MODE_EXACT)
|
|
135
|
+
?.lowercase()
|
|
136
|
+
?.takeIf { it == MATCH_MODE_SUBDOMAIN }
|
|
137
|
+
?: MATCH_MODE_EXACT
|
|
138
|
+
val endsAtValue = prefs.getLong(KEY_ENDS_AT, -1L)
|
|
139
|
+
val endsAt = if (endsAtValue > 0) endsAtValue else null
|
|
140
|
+
if (endsAt != null && endsAt <= System.currentTimeMillis()) {
|
|
141
|
+
clear(context)
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return SavedWebsiteBlock(
|
|
146
|
+
requestedWebsites = requestedWebsites,
|
|
147
|
+
blockedWebsites = blockedWebsites,
|
|
148
|
+
allowedWebsites = allowedWebsites,
|
|
149
|
+
matchMode = matchMode,
|
|
150
|
+
endsAtEpochMs = endsAt,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
fun save(
|
|
155
|
+
context: Context,
|
|
156
|
+
websites: Collection<String>,
|
|
157
|
+
endsAtEpochMs: Long?,
|
|
158
|
+
): SavedWebsiteBlock? {
|
|
159
|
+
val policy = buildPolicy(websites)
|
|
160
|
+
if (policy.requestedWebsites.isEmpty()) {
|
|
161
|
+
clear(context)
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
166
|
+
.edit()
|
|
167
|
+
.putStringSet(KEY_REQUESTED_WEBSITES, policy.requestedWebsites.toSet())
|
|
168
|
+
.putStringSet(KEY_BLOCKED_WEBSITES, policy.blockedWebsites.toSet())
|
|
169
|
+
.putStringSet(KEY_ALLOWED_WEBSITES, policy.allowedWebsites.toSet())
|
|
170
|
+
.putString(KEY_MATCH_MODE, policy.matchMode)
|
|
171
|
+
.putStringSet(KEY_WEBSITES, policy.requestedWebsites.toSet())
|
|
172
|
+
.putLong(KEY_ENDS_AT, endsAtEpochMs ?: -1L)
|
|
173
|
+
.apply()
|
|
174
|
+
return SavedWebsiteBlock(
|
|
175
|
+
requestedWebsites = policy.requestedWebsites,
|
|
176
|
+
blockedWebsites = policy.blockedWebsites,
|
|
177
|
+
allowedWebsites = policy.allowedWebsites,
|
|
178
|
+
matchMode = policy.matchMode,
|
|
179
|
+
endsAtEpochMs = endsAtEpochMs,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fun clear(context: Context) {
|
|
184
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
185
|
+
.edit()
|
|
186
|
+
.remove(KEY_REQUESTED_WEBSITES)
|
|
187
|
+
.remove(KEY_BLOCKED_WEBSITES)
|
|
188
|
+
.remove(KEY_ALLOWED_WEBSITES)
|
|
189
|
+
.remove(KEY_MATCH_MODE)
|
|
190
|
+
.remove(KEY_WEBSITES)
|
|
191
|
+
.remove(KEY_ENDS_AT)
|
|
192
|
+
.apply()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
fun isBlockedHostname(policy: SavedWebsiteBlock, queryName: String): Boolean {
|
|
196
|
+
val normalizedQuery = normalizeHostname(queryName) ?: return false
|
|
197
|
+
if (policy.allowedWebsites.any { allowed -> normalizedQuery == allowed }) {
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
return policy.blockedWebsites.any { blocked ->
|
|
201
|
+
normalizedQuery == blocked ||
|
|
202
|
+
(policy.matchMode == MATCH_MODE_SUBDOMAIN && normalizedQuery.endsWith(".$blocked"))
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
package ai.eliza.plugins.websiteblocker
|
|
2
|
+
|
|
3
|
+
import android.app.Notification
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.content.Intent
|
|
8
|
+
import android.content.pm.ServiceInfo
|
|
9
|
+
import android.net.ConnectivityManager
|
|
10
|
+
import android.net.VpnService
|
|
11
|
+
import android.os.Build
|
|
12
|
+
import android.os.Handler
|
|
13
|
+
import android.os.Looper
|
|
14
|
+
import android.os.ParcelFileDescriptor
|
|
15
|
+
import java.io.FileInputStream
|
|
16
|
+
import java.io.FileOutputStream
|
|
17
|
+
import java.net.DatagramPacket
|
|
18
|
+
import java.net.DatagramSocket
|
|
19
|
+
import java.net.Inet4Address
|
|
20
|
+
import java.net.InetAddress
|
|
21
|
+
import java.net.InetSocketAddress
|
|
22
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
23
|
+
|
|
24
|
+
class WebsiteBlockerVpnService : VpnService() {
|
|
25
|
+
companion object {
|
|
26
|
+
const val ACTION_START = "ai.eliza.websiteblocker.START"
|
|
27
|
+
const val ACTION_STOP = "ai.eliza.websiteblocker.STOP"
|
|
28
|
+
const val EXTRA_WEBSITES = "websites"
|
|
29
|
+
const val EXTRA_ENDS_AT = "ends_at"
|
|
30
|
+
private const val NOTIFICATION_CHANNEL_ID = "website_blocker_vpn"
|
|
31
|
+
private const val NOTIFICATION_ID = 9184
|
|
32
|
+
private const val VPN_ADDRESS = "10.77.0.1"
|
|
33
|
+
private const val DNS_ADDRESS = "10.77.0.2"
|
|
34
|
+
|
|
35
|
+
@Volatile
|
|
36
|
+
private var activeInstance: WebsiteBlockerVpnService? = null
|
|
37
|
+
|
|
38
|
+
fun isRunning(): Boolean = activeInstance != null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private var vpnInterface: ParcelFileDescriptor? = null
|
|
42
|
+
private var tunnelThread: Thread? = null
|
|
43
|
+
private val tunnelRunning = AtomicBoolean(false)
|
|
44
|
+
private val mainHandler = Handler(Looper.getMainLooper())
|
|
45
|
+
private var scheduledStop: Runnable? = null
|
|
46
|
+
private var shouldClearStateOnStop = false
|
|
47
|
+
@Volatile
|
|
48
|
+
private var blockedWebsites: Set<String> = emptySet()
|
|
49
|
+
@Volatile
|
|
50
|
+
private var allowedWebsites: Set<String> = emptySet()
|
|
51
|
+
@Volatile
|
|
52
|
+
private var matchMode: String = "exact"
|
|
53
|
+
@Volatile
|
|
54
|
+
private var activePolicy: SavedWebsiteBlock? = null
|
|
55
|
+
|
|
56
|
+
override fun onCreate() {
|
|
57
|
+
super.onCreate()
|
|
58
|
+
activeInstance = this
|
|
59
|
+
createNotificationChannel()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
63
|
+
val action = intent?.action ?: ACTION_START
|
|
64
|
+
if (action == ACTION_STOP) {
|
|
65
|
+
shouldClearStateOnStop = true
|
|
66
|
+
stopSelf()
|
|
67
|
+
return START_NOT_STICKY
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
val persisted = WebsiteBlockerStateStore.load(this)
|
|
71
|
+
val websites = intent?.getStringArrayListExtra(EXTRA_WEBSITES)
|
|
72
|
+
?.mapNotNull(WebsiteBlockerStateStore::normalizeHostname)
|
|
73
|
+
?.distinct()
|
|
74
|
+
?: persisted?.requestedWebsites
|
|
75
|
+
?: emptyList()
|
|
76
|
+
if (websites.isEmpty()) {
|
|
77
|
+
shouldClearStateOnStop = true
|
|
78
|
+
stopSelf()
|
|
79
|
+
return START_NOT_STICKY
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
val endsAt = when {
|
|
83
|
+
intent?.hasExtra(EXTRA_ENDS_AT) == true -> {
|
|
84
|
+
val value = intent.getLongExtra(EXTRA_ENDS_AT, -1L)
|
|
85
|
+
if (value > 0L) value else null
|
|
86
|
+
}
|
|
87
|
+
else -> persisted?.endsAtEpochMs
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
val savedBlock = WebsiteBlockerStateStore.save(this, websites, endsAt)
|
|
91
|
+
?: run {
|
|
92
|
+
shouldClearStateOnStop = true
|
|
93
|
+
stopSelf()
|
|
94
|
+
return START_NOT_STICKY
|
|
95
|
+
}
|
|
96
|
+
activePolicy = savedBlock
|
|
97
|
+
blockedWebsites = savedBlock.blockedWebsites.toSet()
|
|
98
|
+
allowedWebsites = savedBlock.allowedWebsites.toSet()
|
|
99
|
+
matchMode = savedBlock.matchMode
|
|
100
|
+
shouldClearStateOnStop = false
|
|
101
|
+
startForegroundNotification()
|
|
102
|
+
establishVpn()
|
|
103
|
+
startTunnelLoop()
|
|
104
|
+
scheduleStop(endsAt)
|
|
105
|
+
return START_STICKY
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
override fun onDestroy() {
|
|
109
|
+
super.onDestroy()
|
|
110
|
+
cancelScheduledStop()
|
|
111
|
+
stopTunnelLoop()
|
|
112
|
+
if (shouldClearStateOnStop) {
|
|
113
|
+
WebsiteBlockerStateStore.clear(this)
|
|
114
|
+
}
|
|
115
|
+
activeInstance = null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
override fun onRevoke() {
|
|
119
|
+
shouldClearStateOnStop = true
|
|
120
|
+
super.onRevoke()
|
|
121
|
+
stopSelf()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun establishVpn() {
|
|
125
|
+
if (vpnInterface != null) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
val builder = Builder()
|
|
130
|
+
.setSession("Eliza Website Blocker")
|
|
131
|
+
.setBlocking(true)
|
|
132
|
+
.setMtu(1500)
|
|
133
|
+
.addAddress(VPN_ADDRESS, 32)
|
|
134
|
+
.addRoute(DNS_ADDRESS, 32)
|
|
135
|
+
.addDnsServer(DNS_ADDRESS)
|
|
136
|
+
|
|
137
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
138
|
+
builder.setMetered(false)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
vpnInterface = builder.establish()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private fun startTunnelLoop() {
|
|
145
|
+
if (tunnelRunning.get()) {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
val descriptor = vpnInterface ?: return
|
|
150
|
+
val policy = activePolicy ?: WebsiteBlockerStateStore.load(this) ?: return
|
|
151
|
+
tunnelRunning.set(true)
|
|
152
|
+
tunnelThread = Thread {
|
|
153
|
+
val dnsAddress = InetAddress.getByName(DNS_ADDRESS) as Inet4Address
|
|
154
|
+
FileInputStream(descriptor.fileDescriptor).use { input ->
|
|
155
|
+
FileOutputStream(descriptor.fileDescriptor).use { output ->
|
|
156
|
+
val packetBuffer = ByteArray(32_767)
|
|
157
|
+
while (tunnelRunning.get()) {
|
|
158
|
+
val length = try {
|
|
159
|
+
input.read(packetBuffer)
|
|
160
|
+
} catch (_: Exception) {
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
if (length <= 0) {
|
|
164
|
+
continue
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
val query = DnsPacketCodec.parseUdpDnsQuery(packetBuffer, length, dnsAddress)
|
|
168
|
+
?: continue
|
|
169
|
+
val responsePayload = if (
|
|
170
|
+
WebsiteBlockerStateStore.isBlockedHostname(policy, query.queryName)
|
|
171
|
+
) {
|
|
172
|
+
DnsPacketCodec.buildBlockedDnsResponse(query.dnsPayload)
|
|
173
|
+
} else {
|
|
174
|
+
forwardDnsQuery(query.dnsPayload)
|
|
175
|
+
?: DnsPacketCodec.buildServerFailureDnsResponse(query.dnsPayload)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
val responsePacket = DnsPacketCodec.buildUdpDnsResponse(query, responsePayload)
|
|
179
|
+
try {
|
|
180
|
+
output.write(responsePacket)
|
|
181
|
+
} catch (_: Exception) {
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}.apply {
|
|
188
|
+
name = "ElizaWebsiteBlockerVpn"
|
|
189
|
+
isDaemon = true
|
|
190
|
+
start()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fun stopTunnelLoop() {
|
|
195
|
+
tunnelRunning.set(false)
|
|
196
|
+
tunnelThread?.interrupt()
|
|
197
|
+
tunnelThread = null
|
|
198
|
+
try {
|
|
199
|
+
vpnInterface?.close()
|
|
200
|
+
} catch (_: Exception) {
|
|
201
|
+
}
|
|
202
|
+
vpnInterface = null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private fun forwardDnsQuery(queryPayload: ByteArray): ByteArray? {
|
|
206
|
+
val upstreamServers = resolveUpstreamDnsServers()
|
|
207
|
+
for (server in upstreamServers) {
|
|
208
|
+
try {
|
|
209
|
+
DatagramSocket().use { socket ->
|
|
210
|
+
protect(socket)
|
|
211
|
+
socket.soTimeout = 3_000
|
|
212
|
+
socket.connect(InetSocketAddress(server, 53))
|
|
213
|
+
socket.send(DatagramPacket(queryPayload, queryPayload.size))
|
|
214
|
+
val responseBuffer = ByteArray(4_096)
|
|
215
|
+
val responsePacket = DatagramPacket(responseBuffer, responseBuffer.size)
|
|
216
|
+
socket.receive(responsePacket)
|
|
217
|
+
return responseBuffer.copyOf(responsePacket.length)
|
|
218
|
+
}
|
|
219
|
+
} catch (_: Exception) {
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private fun resolveUpstreamDnsServers(): List<InetAddress> {
|
|
226
|
+
val connectivityManager =
|
|
227
|
+
applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
|
228
|
+
val network = connectivityManager?.activeNetwork
|
|
229
|
+
val linkProperties = connectivityManager?.getLinkProperties(network)
|
|
230
|
+
val dnsServers = linkProperties?.dnsServers
|
|
231
|
+
?.filterIsInstance<Inet4Address>()
|
|
232
|
+
?.filter { it.hostAddress != DNS_ADDRESS }
|
|
233
|
+
.orEmpty()
|
|
234
|
+
if (dnsServers.isNotEmpty()) {
|
|
235
|
+
return dnsServers
|
|
236
|
+
}
|
|
237
|
+
return listOf(
|
|
238
|
+
InetAddress.getByName("1.1.1.1"),
|
|
239
|
+
InetAddress.getByName("8.8.8.8"),
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private fun scheduleStop(endsAtEpochMs: Long?) {
|
|
244
|
+
cancelScheduledStop()
|
|
245
|
+
if (endsAtEpochMs == null) {
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
val delayMs = endsAtEpochMs - System.currentTimeMillis()
|
|
250
|
+
if (delayMs <= 0) {
|
|
251
|
+
shouldClearStateOnStop = true
|
|
252
|
+
stopSelf()
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
val stopRunnable = Runnable {
|
|
257
|
+
shouldClearStateOnStop = true
|
|
258
|
+
stopSelf()
|
|
259
|
+
}
|
|
260
|
+
scheduledStop = stopRunnable
|
|
261
|
+
mainHandler.postDelayed(stopRunnable, delayMs)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private fun cancelScheduledStop() {
|
|
265
|
+
scheduledStop?.let { mainHandler.removeCallbacks(it) }
|
|
266
|
+
scheduledStop = null
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private fun startForegroundNotification() {
|
|
270
|
+
val notification = buildNotification()
|
|
271
|
+
if (Build.VERSION.SDK_INT >= 34) {
|
|
272
|
+
startForeground(
|
|
273
|
+
NOTIFICATION_ID,
|
|
274
|
+
notification,
|
|
275
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
|
|
276
|
+
)
|
|
277
|
+
} else {
|
|
278
|
+
startForeground(NOTIFICATION_ID, notification)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private fun buildNotification(): Notification {
|
|
283
|
+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
284
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
|
285
|
+
manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null
|
|
286
|
+
) {
|
|
287
|
+
createNotificationChannel()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
291
|
+
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
|
|
292
|
+
.setContentTitle("Eliza Website Blocker")
|
|
293
|
+
.setContentText("Blocking ${blockedWebsites.joinToString(", ")}")
|
|
294
|
+
.setSmallIcon(android.R.drawable.ic_lock_lock)
|
|
295
|
+
.setOngoing(true)
|
|
296
|
+
.build()
|
|
297
|
+
} else {
|
|
298
|
+
@Suppress("DEPRECATION")
|
|
299
|
+
Notification.Builder(this)
|
|
300
|
+
.setContentTitle("Eliza Website Blocker")
|
|
301
|
+
.setContentText("Blocking ${blockedWebsites.joinToString(", ")}")
|
|
302
|
+
.setSmallIcon(android.R.drawable.ic_lock_lock)
|
|
303
|
+
.setOngoing(true)
|
|
304
|
+
.build()
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private fun createNotificationChannel() {
|
|
309
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
|
310
|
+
return
|
|
311
|
+
}
|
|
312
|
+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
313
|
+
if (manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) {
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
manager.createNotificationChannel(
|
|
317
|
+
NotificationChannel(
|
|
318
|
+
NOTIFICATION_CHANNEL_ID,
|
|
319
|
+
"Eliza Website Blocker",
|
|
320
|
+
NotificationManager.IMPORTANCE_LOW,
|
|
321
|
+
).apply {
|
|
322
|
+
description = "Foreground notification while website blocking is active"
|
|
323
|
+
},
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type WebsiteBlockerPermissionStatus = "granted" | "denied" | "not-determined" | "not-applicable";
|
|
2
|
+
export type WebsiteBlockerEngine = "hosts-file" | "vpn-dns" | "network-extension" | "content-blocker";
|
|
3
|
+
export type WebsiteBlockerElevationMethod = "osascript" | "pkexec" | "powershell-runas" | "vpn-consent" | "system-settings" | null;
|
|
4
|
+
export interface WebsiteBlockerPermissionResult {
|
|
5
|
+
status: WebsiteBlockerPermissionStatus;
|
|
6
|
+
canRequest: boolean;
|
|
7
|
+
reason?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface WebsiteBlockerStatus {
|
|
10
|
+
available: boolean;
|
|
11
|
+
active: boolean;
|
|
12
|
+
hostsFilePath: string | null;
|
|
13
|
+
endsAt: string | null;
|
|
14
|
+
websites: string[];
|
|
15
|
+
requestedWebsites: string[];
|
|
16
|
+
blockedWebsites: string[];
|
|
17
|
+
allowedWebsites: string[];
|
|
18
|
+
matchMode: "exact" | "subdomain";
|
|
19
|
+
canUnblockEarly: boolean;
|
|
20
|
+
requiresElevation: boolean;
|
|
21
|
+
engine: WebsiteBlockerEngine;
|
|
22
|
+
platform: string;
|
|
23
|
+
supportsElevationPrompt: boolean;
|
|
24
|
+
elevationPromptMethod: WebsiteBlockerElevationMethod;
|
|
25
|
+
permissionStatus?: WebsiteBlockerPermissionStatus;
|
|
26
|
+
canRequestPermission?: boolean;
|
|
27
|
+
canOpenSystemSettings?: boolean;
|
|
28
|
+
reason?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface StartWebsiteBlockOptions {
|
|
31
|
+
websites?: string[] | string;
|
|
32
|
+
durationMinutes?: number | string | null;
|
|
33
|
+
text?: string;
|
|
34
|
+
}
|
|
35
|
+
export type StartWebsiteBlockResult = {
|
|
36
|
+
success: true;
|
|
37
|
+
endsAt: string | null;
|
|
38
|
+
request: {
|
|
39
|
+
websites: string[];
|
|
40
|
+
durationMinutes: number | null;
|
|
41
|
+
};
|
|
42
|
+
} | {
|
|
43
|
+
success: false;
|
|
44
|
+
error: string;
|
|
45
|
+
status?: {
|
|
46
|
+
active: boolean;
|
|
47
|
+
endsAt: string | null;
|
|
48
|
+
websites: string[];
|
|
49
|
+
requiresElevation: boolean;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export type StopWebsiteBlockResult = {
|
|
53
|
+
success: true;
|
|
54
|
+
removed: boolean;
|
|
55
|
+
status: {
|
|
56
|
+
active: boolean;
|
|
57
|
+
endsAt: string | null;
|
|
58
|
+
websites: string[];
|
|
59
|
+
canUnblockEarly: boolean;
|
|
60
|
+
requiresElevation: boolean;
|
|
61
|
+
};
|
|
62
|
+
} | {
|
|
63
|
+
success: false;
|
|
64
|
+
error: string;
|
|
65
|
+
status?: {
|
|
66
|
+
active: boolean;
|
|
67
|
+
endsAt: string | null;
|
|
68
|
+
websites: string[];
|
|
69
|
+
canUnblockEarly: boolean;
|
|
70
|
+
requiresElevation: boolean;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
export interface WebsiteBlockerPlugin {
|
|
74
|
+
getStatus(): Promise<WebsiteBlockerStatus>;
|
|
75
|
+
startBlock(options: StartWebsiteBlockOptions): Promise<StartWebsiteBlockResult>;
|
|
76
|
+
stopBlock(): Promise<StopWebsiteBlockResult>;
|
|
77
|
+
checkPermissions(): Promise<WebsiteBlockerPermissionResult>;
|
|
78
|
+
requestPermissions(): Promise<WebsiteBlockerPermissionResult>;
|
|
79
|
+
openSettings(): Promise<{
|
|
80
|
+
opened: boolean;
|
|
81
|
+
}>;
|
|
82
|
+
}
|
|
83
|
+
//# sourceMappingURL=definitions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"definitions.js","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAE1D,cAAc,eAAe,CAAC;AAK9B,eAAO,MAAM,cAAc,sBAK1B,CAAC"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { registerPlugin } from "@capacitor/core";
|
|
2
|
+
export * from "./definitions";
|
|
3
|
+
const loadWeb = () => import("./web").then((module) => new module.WebsiteBlockerWeb());
|
|
4
|
+
export const WebsiteBlocker = registerPlugin("ElizaWebsiteBlocker", {
|
|
5
|
+
web: loadWeb,
|
|
6
|
+
});
|
|
7
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAIjD,cAAc,eAAe,CAAC;AAE9B,MAAM,OAAO,GAAG,GAAG,EAAE,CACnB,MAAM,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,MAAM,CAAC,iBAAiB,EAAE,CAAC,CAAC;AAEnE,MAAM,CAAC,MAAM,cAAc,GAAG,cAAc,CAC1C,qBAAqB,EACrB;IACE,GAAG,EAAE,OAAO;CACb,CACF,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { WebPlugin } from "@capacitor/core";
|
|
2
|
+
import type { StartWebsiteBlockOptions, StartWebsiteBlockResult, StopWebsiteBlockResult, WebsiteBlockerPermissionResult, WebsiteBlockerStatus } from "./definitions";
|
|
3
|
+
export declare class WebsiteBlockerWeb extends WebPlugin {
|
|
4
|
+
private apiBase;
|
|
5
|
+
private apiToken;
|
|
6
|
+
private authHeaders;
|
|
7
|
+
private canReachApi;
|
|
8
|
+
private requestJson;
|
|
9
|
+
getStatus(): Promise<WebsiteBlockerStatus>;
|
|
10
|
+
startBlock(options: StartWebsiteBlockOptions): Promise<StartWebsiteBlockResult>;
|
|
11
|
+
stopBlock(): Promise<StopWebsiteBlockResult>;
|
|
12
|
+
checkPermissions(): Promise<WebsiteBlockerPermissionResult>;
|
|
13
|
+
requestPermissions(): Promise<WebsiteBlockerPermissionResult>;
|
|
14
|
+
openSettings(): Promise<{
|
|
15
|
+
opened: boolean;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=web.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"web.d.ts","sourceRoot":"","sources":["../../src/web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,KAAK,EACV,wBAAwB,EACxB,uBAAuB,EACvB,sBAAsB,EACtB,8BAA8B,EAC9B,oBAAoB,EACrB,MAAM,eAAe,CAAC;AAOvB,qBAAa,iBAAkB,SAAQ,SAAS;IAC9C,OAAO,CAAC,OAAO;IAWf,OAAO,CAAC,QAAQ;IAehB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,WAAW;YAeL,WAAW;IAqBnB,SAAS,IAAI,OAAO,CAAC,oBAAoB,CAAC;IAI1C,UAAU,CACd,OAAO,EAAE,wBAAwB,GAChC,OAAO,CAAC,uBAAuB,CAAC;IAU7B,SAAS,IAAI,OAAO,CAAC,sBAAsB,CAAC;IAS5C,gBAAgB,IAAI,OAAO,CAAC,8BAA8B,CAAC;IAa3D,kBAAkB,IAAI,OAAO,CAAC,8BAA8B,CAAC;IAe7D,YAAY,IAAI,OAAO,CAAC;QAAE,MAAM,EAAE,OAAO,CAAA;KAAE,CAAC;CAWnD"}
|