chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
@@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="ai.eliza.plugins.websiteblocker.WebsiteBlockerShowcaseActivity"
android:exported="true"
android:label="Website Blocker State Test"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
</application>
</manifest>
@@ -0,0 +1,65 @@
package ai.eliza.plugins.websiteblocker
import android.app.Activity
import android.os.Build
import android.os.Bundle
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.ScrollView
import android.widget.TextView
class WebsiteBlockerShowcaseActivity : Activity() {
private lateinit var textView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
)
}
WebsiteBlockerStateStore.clear(applicationContext)
WebsiteBlockerStateStore.save(
applicationContext,
listOf("x.com", "news.google.com"),
System.currentTimeMillis() + 30 * 60_000L,
)
val state = requireNotNull(WebsiteBlockerStateStore.load(applicationContext))
textView = TextView(this).apply {
textSize = 18f
setPadding(36, 36, 36, 36)
text = buildString {
appendLine("Website Blocker State")
appendLine("Package: ${applicationContext.packageName}")
appendLine("Active: true")
appendLine("Requested: ${state.requestedWebsites.joinToString()}")
appendLine("Blocked count: ${state.blockedWebsites.size}")
appendLine("Allowed count: ${state.allowedWebsites.size}")
appendLine("Match mode: ${state.matchMode}")
appendLine("Blocked x.com: ${WebsiteBlockerStateStore.isBlockedHostname(state, "x.com")}")
appendLine("Blocked t.co: ${WebsiteBlockerStateStore.isBlockedHostname(state, "t.co")}")
appendLine("Allowed api.x.com: ${WebsiteBlockerStateStore.isBlockedHostname(state, "api.x.com")}")
appendLine("Allowed accounts.google.com: ${WebsiteBlockerStateStore.isBlockedHostname(state, "accounts.google.com")}")
}
}
setContentView(
ScrollView(this).apply {
addView(
textView,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
),
)
},
)
}
fun snapshotText(): String = textView.text.toString()
}
@@ -0,0 +1,162 @@
package ai.eliza.plugins.websiteblocker
import android.content.Context
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.net.Inet4Address
import java.net.InetAddress
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class WebsiteBlockerStateStoreInstrumentedTest {
private val context: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext
@After
fun clearState() {
WebsiteBlockerStateStore.clear(context)
}
@Test
fun saveLoadAndBlockDecisionUseDeviceSharedPreferences() {
WebsiteBlockerStateStore.clear(context)
val saved = WebsiteBlockerStateStore.save(
context,
listOf(" X.COM. ", "news.google.com", "localhost"),
System.currentTimeMillis() + 60_000L,
)
assertNotNull(saved)
val loaded = WebsiteBlockerStateStore.load(context)
assertNotNull(loaded)
requireNotNull(loaded)
assertEquals(listOf("news.google.com", "x.com"), loaded.requestedWebsites)
assertTrue(loaded.blockedWebsites.contains("t.co"))
assertTrue(loaded.blockedWebsites.contains("twitter.com"))
assertTrue(loaded.blockedWebsites.contains("news.google.com"))
assertTrue(loaded.allowedWebsites.contains("api.x.com"))
assertTrue(loaded.allowedWebsites.contains("accounts.google.com"))
assertEquals("exact", loaded.matchMode)
assertTrue(WebsiteBlockerStateStore.isBlockedHostname(loaded, "x.com"))
assertTrue(WebsiteBlockerStateStore.isBlockedHostname(loaded, "t.co"))
assertTrue(WebsiteBlockerStateStore.isBlockedHostname(loaded, "news.google.com"))
assertFalse(WebsiteBlockerStateStore.isBlockedHostname(loaded, "api.x.com"))
assertFalse(WebsiteBlockerStateStore.isBlockedHostname(loaded, "accounts.google.com"))
}
@Test
fun elapsedBlockExpiresAndClearsDeviceState() {
WebsiteBlockerStateStore.clear(context)
WebsiteBlockerStateStore.save(
context,
listOf("example.com"),
System.currentTimeMillis() - 1_000L,
)
assertNull(WebsiteBlockerStateStore.load(context))
assertNull(WebsiteBlockerStateStore.load(context))
}
@Test
fun dnsCodecParsesQueryAndBuildsBlockedUdpResponseOnDevice() {
val dnsAddress = InetAddress.getByName("10.77.0.2") as Inet4Address
val packet = buildUdpDnsQuery(
sourceAddress = byteArrayOf(10, 0, 0, 2),
destinationAddress = dnsAddress.address,
sourcePort = 45_321,
queryName = "x.com",
)
val parsed = DnsPacketCodec.parseUdpDnsQuery(packet, packet.size, dnsAddress)
assertNotNull(parsed)
requireNotNull(parsed)
assertEquals("x.com", parsed.queryName)
assertEquals(45_321, parsed.sourcePort)
assertEquals(53, parsed.destinationPort)
assertArrayEquals(byteArrayOf(10, 0, 0, 2), parsed.sourceAddress)
assertArrayEquals(dnsAddress.address, parsed.destinationAddress)
val blockedPayload = DnsPacketCodec.buildBlockedDnsResponse(parsed.dnsPayload)
assertEquals(0x80, blockedPayload[2].toInt() and 0x80)
assertEquals(3, blockedPayload[3].toInt() and 0x0F)
val response = DnsPacketCodec.buildUdpDnsResponse(parsed, blockedPayload)
assertArrayEquals(dnsAddress.address, response.copyOfRange(12, 16))
assertArrayEquals(byteArrayOf(10, 0, 0, 2), response.copyOfRange(16, 20))
assertEquals(53, readUInt16(response, 20))
assertEquals(45_321, readUInt16(response, 22))
}
@Test
fun showcaseActivityRendersPersistedPolicy() {
ActivityScenario.launch(WebsiteBlockerShowcaseActivity::class.java).use { scenario ->
scenario.onActivity { activity ->
val text = activity.snapshotText()
assertTrue(text.contains("Website Blocker State"))
assertTrue(text.contains("Active: true"))
assertTrue(text.contains("Blocked x.com: true"))
assertTrue(text.contains("Allowed api.x.com: false"))
}
}
}
private fun buildUdpDnsQuery(
sourceAddress: ByteArray,
destinationAddress: ByteArray,
sourcePort: Int,
queryName: String,
): ByteArray {
val dnsPayload = buildDnsQueryPayload(queryName)
val udpLength = 8 + dnsPayload.size
val totalLength = 20 + udpLength
val packet = ByteArray(totalLength)
packet[0] = 0x45
packet[8] = 64
packet[9] = 17
writeUInt16(packet, 2, totalLength)
System.arraycopy(sourceAddress, 0, packet, 12, 4)
System.arraycopy(destinationAddress, 0, packet, 16, 4)
writeUInt16(packet, 20, sourcePort)
writeUInt16(packet, 22, 53)
writeUInt16(packet, 24, udpLength)
System.arraycopy(dnsPayload, 0, packet, 28, dnsPayload.size)
return packet
}
private fun buildDnsQueryPayload(queryName: String): ByteArray {
val labels = queryName.split(".")
val nameBytes = labels.flatMap { label ->
listOf(label.length.toByte()) + label.toByteArray(Charsets.UTF_8).toList()
} + listOf(0.toByte())
val payload = ByteArray(12 + nameBytes.size + 4)
writeUInt16(payload, 0, 0x4D53)
writeUInt16(payload, 2, 0x0100)
writeUInt16(payload, 4, 1)
nameBytes.forEachIndexed { index, value -> payload[12 + index] = value }
val questionOffset = 12 + nameBytes.size
writeUInt16(payload, questionOffset, 1)
writeUInt16(payload, questionOffset + 2, 1)
return payload
}
private fun readUInt16(buffer: ByteArray, offset: Int): Int {
return ((buffer[offset].toInt() and 0xFF) shl 8) or
(buffer[offset + 1].toInt() and 0xFF)
}
private fun writeUInt16(buffer: ByteArray, offset: Int, value: Int) {
buffer[offset] = ((value ushr 8) and 0xFF).toByte()
buffer[offset + 1] = (value and 0xFF).toByte()
}
}
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application>
<service
android:name=".WebsiteBlockerVpnService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="website_blocking_vpn" />
</service>
<receiver
android:name=".WebsiteBlockerBootReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,170 @@
package ai.eliza.plugins.websiteblocker
import java.net.Inet4Address
data class DnsQueryPacket(
val sourceAddress: ByteArray,
val destinationAddress: ByteArray,
val sourcePort: Int,
val destinationPort: Int,
val dnsPayload: ByteArray,
val queryName: String,
)
object DnsPacketCodec {
private const val IPV4_HEADER_SIZE = 20
private const val UDP_HEADER_SIZE = 8
fun parseUdpDnsQuery(
packet: ByteArray,
length: Int,
expectedDnsAddress: Inet4Address,
): DnsQueryPacket? {
if (length < IPV4_HEADER_SIZE + UDP_HEADER_SIZE + 12) {
return null
}
val version = (packet[0].toInt() ushr 4) and 0x0F
val headerLength = (packet[0].toInt() and 0x0F) * 4
if (version != 4 || headerLength < IPV4_HEADER_SIZE || length < headerLength + UDP_HEADER_SIZE) {
return null
}
val protocol = packet[9].toInt() and 0xFF
if (protocol != 17) {
return null
}
val destinationAddress = packet.copyOfRange(16, 20)
if (!destinationAddress.contentEquals(expectedDnsAddress.address)) {
return null
}
val udpOffset = headerLength
val sourcePort = readUInt16(packet, udpOffset)
val destinationPort = readUInt16(packet, udpOffset + 2)
if (destinationPort != 53) {
return null
}
val udpLength = readUInt16(packet, udpOffset + 4)
if (udpLength < UDP_HEADER_SIZE || udpOffset + udpLength > length) {
return null
}
val dnsOffset = udpOffset + UDP_HEADER_SIZE
val dnsPayload = packet.copyOfRange(dnsOffset, dnsOffset + udpLength - UDP_HEADER_SIZE)
val queryName = parseQueryName(dnsPayload) ?: return null
return DnsQueryPacket(
sourceAddress = packet.copyOfRange(12, 16),
destinationAddress = destinationAddress,
sourcePort = sourcePort,
destinationPort = destinationPort,
dnsPayload = dnsPayload,
queryName = queryName,
)
}
fun buildBlockedDnsResponse(queryPayload: ByteArray): ByteArray {
val response = queryPayload.copyOf()
response[2] = (response[2].toInt() or 0x80).toByte()
response[3] = ((response[3].toInt() and 0xF0) or 0x03).toByte()
response[6] = 0
response[7] = 0
response[8] = 0
response[9] = 0
response[10] = 0
response[11] = 0
return response
}
fun buildServerFailureDnsResponse(queryPayload: ByteArray): ByteArray {
val response = queryPayload.copyOf()
response[2] = (response[2].toInt() or 0x80).toByte()
response[3] = ((response[3].toInt() and 0xF0) or 0x02).toByte()
response[6] = 0
response[7] = 0
response[8] = 0
response[9] = 0
response[10] = 0
response[11] = 0
return response
}
fun buildUdpDnsResponse(query: DnsQueryPacket, dnsPayload: ByteArray): ByteArray {
val udpLength = UDP_HEADER_SIZE + dnsPayload.size
val totalLength = IPV4_HEADER_SIZE + udpLength
val response = ByteArray(totalLength)
response[0] = 0x45
response[1] = 0
writeUInt16(response, 2, totalLength)
writeUInt16(response, 4, 0)
writeUInt16(response, 6, 0)
response[8] = 64
response[9] = 17
writeUInt16(response, 10, 0)
System.arraycopy(query.destinationAddress, 0, response, 12, 4)
System.arraycopy(query.sourceAddress, 0, response, 16, 4)
writeUInt16(response, IPV4_HEADER_SIZE, query.destinationPort)
writeUInt16(response, IPV4_HEADER_SIZE + 2, query.sourcePort)
writeUInt16(response, IPV4_HEADER_SIZE + 4, udpLength)
writeUInt16(response, IPV4_HEADER_SIZE + 6, 0)
System.arraycopy(dnsPayload, 0, response, IPV4_HEADER_SIZE + UDP_HEADER_SIZE, dnsPayload.size)
val checksum = computeIpv4HeaderChecksum(response, IPV4_HEADER_SIZE)
writeUInt16(response, 10, checksum)
return response
}
private fun parseQueryName(payload: ByteArray): String? {
if (payload.size < 12) {
return null
}
var offset = 12
val labels = mutableListOf<String>()
while (offset < payload.size) {
val length = payload[offset].toInt() and 0xFF
if (length == 0) {
return labels.joinToString(".")
}
if (length and 0xC0 != 0 || offset + 1 + length > payload.size) {
return null
}
val label = payload.copyOfRange(offset + 1, offset + 1 + length)
.toString(Charsets.UTF_8)
labels += label
offset += length + 1
}
return null
}
private fun readUInt16(buffer: ByteArray, offset: Int): Int {
return ((buffer[offset].toInt() and 0xFF) shl 8) or
(buffer[offset + 1].toInt() and 0xFF)
}
private fun writeUInt16(buffer: ByteArray, offset: Int, value: Int) {
buffer[offset] = ((value ushr 8) and 0xFF).toByte()
buffer[offset + 1] = (value and 0xFF).toByte()
}
private fun computeIpv4HeaderChecksum(packet: ByteArray, headerLength: Int): Int {
var sum = 0
var offset = 0
while (offset < headerLength) {
if (offset == 10) {
offset += 2
continue
}
sum += readUInt16(packet, offset)
while (sum > 0xFFFF) {
sum = (sum and 0xFFFF) + (sum ushr 16)
}
offset += 2
}
return sum.inv() and 0xFFFF
}
}
@@ -0,0 +1,39 @@
package ai.eliza.plugins.websiteblocker
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
class WebsiteBlockerBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action ?: return
if (
action != Intent.ACTION_BOOT_COMPLETED &&
action != Intent.ACTION_LOCKED_BOOT_COMPLETED &&
action != Intent.ACTION_MY_PACKAGE_REPLACED
) {
return
}
val savedBlock = WebsiteBlockerStateStore.load(context) ?: return
if (android.net.VpnService.prepare(context) != null) {
return
}
ContextCompat.startForegroundService(
context,
Intent(context, WebsiteBlockerVpnService::class.java).apply {
this.action = WebsiteBlockerVpnService.ACTION_START
putStringArrayListExtra(
WebsiteBlockerVpnService.EXTRA_WEBSITES,
ArrayList(savedBlock.requestedWebsites),
)
putExtra(
WebsiteBlockerVpnService.EXTRA_ENDS_AT,
savedBlock.endsAtEpochMs ?: -1L,
)
},
)
}
}
@@ -0,0 +1,291 @@
package ai.eliza.plugins.websiteblocker
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.provider.Settings
import androidx.activity.result.ActivityResult
import androidx.core.content.ContextCompat
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.ActivityCallback
import com.getcapacitor.annotation.CapacitorPlugin
import java.time.Instant
@CapacitorPlugin(name = "ElizaWebsiteBlocker")
class WebsiteBlockerPlugin : Plugin() {
private data class PendingStartRequest(
val websites: List<String>,
val endsAtEpochMs: Long?,
)
private var pendingStartRequest: PendingStartRequest? = null
@PluginMethod
fun getStatus(call: PluginCall) {
call.resolve(buildStatus())
}
@PluginMethod
fun startBlock(call: PluginCall) {
val websites = extractWebsites(call)
if (websites.isEmpty()) {
call.resolve(JSObject().apply {
put("success", false)
put("error", "Provide at least one public website hostname, such as x.com or twitter.com.")
})
return
}
val durationMinutes = parseDurationMinutes(call)
val endsAtEpochMs = durationMinutes?.let { System.currentTimeMillis() + it * 60_000 }
val permissionIntent = VpnService.prepare(context)
if (permissionIntent != null) {
pendingStartRequest = PendingStartRequest(websites, endsAtEpochMs)
startActivityForResult(call, permissionIntent, "handleVpnPermissionResult")
return
}
startBlockInternal(websites, endsAtEpochMs)
call.resolve(buildStartResult(websites, durationMinutes, endsAtEpochMs))
}
@PluginMethod
fun stopBlock(call: PluginCall) {
WebsiteBlockerStateStore.clear(context)
context.stopService(Intent(context, WebsiteBlockerVpnService::class.java).apply {
action = WebsiteBlockerVpnService.ACTION_STOP
})
call.resolve(JSObject().apply {
put("success", true)
put("removed", true)
put("status", JSObject().apply {
put("active", false)
put("endsAt", null)
put("websites", JSArray())
put("requestedWebsites", JSArray())
put("blockedWebsites", JSArray())
put("allowedWebsites", JSArray())
put("matchMode", "exact")
put("canUnblockEarly", true)
put("requiresElevation", permissionRequiresConsent())
})
})
}
@PluginMethod
override fun checkPermissions(call: PluginCall) {
call.resolve(buildPermissionResult())
}
@PluginMethod
override fun requestPermissions(call: PluginCall) {
val permissionIntent = VpnService.prepare(context)
if (permissionIntent == null) {
call.resolve(buildPermissionResult())
return
}
startActivityForResult(call, permissionIntent, "handleVpnPermissionResult")
}
@PluginMethod
fun openSettings(call: PluginCall) {
val activity = activity
if (activity == null) {
call.resolve(JSObject().apply {
put("opened", false)
put("target", "vpn")
put("actualTarget", "vpn")
put("reason", "Android activity is unavailable.")
})
return
}
activity.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
call.resolve(JSObject().apply {
put("opened", true)
put("target", "vpn")
put("actualTarget", "vpn")
put("reason", null)
})
}
@ActivityCallback
private fun handleVpnPermissionResult(call: PluginCall, result: ActivityResult) {
if (result.resultCode != android.app.Activity.RESULT_OK) {
if (pendingStartRequest != null) {
pendingStartRequest = null
call.resolve(JSObject().apply {
put("success", false)
put("error", "Android VPN consent was not granted.")
})
return
}
call.resolve(buildPermissionResult())
return
}
val pendingStart = pendingStartRequest
if (pendingStart != null) {
pendingStartRequest = null
startBlockInternal(
pendingStart.websites,
pendingStart.endsAtEpochMs,
)
call.resolve(
buildStartResult(
pendingStart.websites,
durationMinutesFromEndsAt(pendingStart.endsAtEpochMs),
pendingStart.endsAtEpochMs,
),
)
return
}
call.resolve(buildPermissionResult())
}
private fun startBlockInternal(
websites: List<String>,
endsAtEpochMs: Long?,
) {
WebsiteBlockerStateStore.save(context, websites, endsAtEpochMs)
val serviceIntent = Intent(context, WebsiteBlockerVpnService::class.java).apply {
action = WebsiteBlockerVpnService.ACTION_START
putStringArrayListExtra(
WebsiteBlockerVpnService.EXTRA_WEBSITES,
ArrayList(websites),
)
putExtra(
WebsiteBlockerVpnService.EXTRA_ENDS_AT,
endsAtEpochMs ?: -1L,
)
}
ContextCompat.startForegroundService(context, serviceIntent)
}
private fun durationMinutesFromEndsAt(endsAtEpochMs: Long?): Long? {
if (endsAtEpochMs == null) return null
val remainingMs = endsAtEpochMs - System.currentTimeMillis()
return if (remainingMs <= 0) 0 else kotlin.math.ceil(remainingMs / 60_000.0).toLong()
}
private fun buildStartResult(
websites: List<String>,
durationMinutes: Long?,
endsAtEpochMs: Long?,
): JSObject {
return JSObject().apply {
put("success", true)
put(
"endsAt",
endsAtEpochMs?.let { Instant.ofEpochMilli(it).toString() },
)
put("request", JSObject().apply {
put("websites", JSArray(websites))
put("durationMinutes", durationMinutes)
})
}
}
private fun buildPermissionResult(): JSObject {
val granted = !permissionRequiresConsent()
return JSObject().apply {
put("status", if (granted) "granted" else "not-determined")
put("canRequest", !granted)
put("canOpenSettings", true)
put("settingsTarget", "vpn")
put("engine", "vpn-dns")
if (!granted) {
put(
"reason",
"Android needs VPN consent before Eliza can block websites system-wide on this phone.",
)
}
}
}
private fun buildStatus(): JSObject {
val saved = WebsiteBlockerStateStore.load(context)
val permission = buildPermissionResult()
val active = saved != null
return JSObject().apply {
put("status", if (active) "active" else "inactive")
put("available", true)
put("active", active)
put("hostsFilePath", null)
put(
"endsAt",
saved?.endsAtEpochMs?.let { Instant.ofEpochMilli(it).toString() },
)
put("websites", JSArray(saved?.requestedWebsites ?: emptyList<String>()))
put("requestedWebsites", JSArray(saved?.requestedWebsites ?: emptyList<String>()))
put("blockedWebsites", JSArray(saved?.blockedWebsites ?: emptyList<String>()))
put("allowedWebsites", JSArray(saved?.allowedWebsites ?: emptyList<String>()))
put("matchMode", saved?.matchMode ?: "exact")
put("canUnblockEarly", true)
put("requiresElevation", permissionRequiresConsent())
put("engine", "vpn-dns")
put("platform", "android")
put("supportsElevationPrompt", permissionRequiresConsent())
put(
"elevationPromptMethod",
if (permissionRequiresConsent()) "vpn-consent" else null,
)
put("permissionStatus", permission.getString("status"))
put("canRequest", permission.getBool("canRequest"))
put("canOpenSettings", permission.getBool("canOpenSettings"))
put("settingsTarget", permission.opt("settingsTarget"))
put("canRequestPermission", permission.getBool("canRequest"))
put("canOpenSystemSettings", true)
val reason = when {
saved != null && !WebsiteBlockerVpnService.isRunning() ->
"Website blocking is configured and the VPN service is reconnecting."
permissionRequiresConsent() ->
permission.getString("reason")
else -> null
}
if (reason != null) {
put("reason", reason)
}
}
}
private fun extractWebsites(call: PluginCall): List<String> {
val websites = mutableListOf<String>()
val explicitWebsites = call.data.optJSONArray("websites")
if (explicitWebsites != null) {
for (index in 0 until explicitWebsites.length()) {
val value = explicitWebsites.optString(index)
WebsiteBlockerStateStore.normalizeHostname(value)?.let(websites::add)
}
}
val text = call.getString("text")
if (!text.isNullOrBlank()) {
text.split(Regex("[\\s,]+"))
.mapNotNull(WebsiteBlockerStateStore::normalizeHostname)
.forEach(websites::add)
}
return websites.distinct()
}
private fun parseDurationMinutes(call: PluginCall): Long? {
val rawValue = call.data.opt("durationMinutes") ?: return null
return when (rawValue) {
is Number -> rawValue.toLong()
is String -> rawValue.toLongOrNull()
else -> null
}?.takeIf { it > 0 }
}
private fun permissionRequiresConsent(): Boolean {
return VpnService.prepare(context) != null
}
}
@@ -0,0 +1,205 @@
package ai.eliza.plugins.websiteblocker
import android.content.Context
data class SavedWebsiteBlock(
val requestedWebsites: List<String>,
val blockedWebsites: List<String>,
val allowedWebsites: List<String>,
val matchMode: String,
val endsAtEpochMs: Long?,
)
object WebsiteBlockerStateStore {
private const val PREFS_NAME = "eliza_website_blocker"
private const val KEY_REQUESTED_WEBSITES = "requested_websites"
private const val KEY_BLOCKED_WEBSITES = "blocked_websites"
private const val KEY_ALLOWED_WEBSITES = "allowed_websites"
private const val KEY_MATCH_MODE = "match_mode"
private const val KEY_WEBSITES = "websites"
private const val KEY_ENDS_AT = "ends_at_epoch_ms"
private const val MATCH_MODE_EXACT = "exact"
private const val MATCH_MODE_SUBDOMAIN = "subdomain"
private val X_TWITTER_REQUESTED_HOSTS = setOf("x.com", "twitter.com")
private val X_TWITTER_BLOCKED_HOSTS = setOf(
"x.com",
"www.x.com",
"mobile.x.com",
"twitter.com",
"www.twitter.com",
"mobile.twitter.com",
"t.co",
"abs.twimg.com",
"pbs.twimg.com",
"video.twimg.com",
"ton.twimg.com",
"platform.twitter.com",
"tweetdeck.twitter.com",
)
private val X_TWITTER_ALLOWED_HOSTS = setOf("api.x.com", "api.twitter.com")
private val GOOGLE_NEWS_REQUESTED_HOSTS = setOf("news.google.com")
private val GOOGLE_NEWS_BLOCKED_HOSTS = setOf("news.google.com")
private val GOOGLE_NEWS_ALLOWED_HOSTS = setOf(
"accounts.google.com",
"oauth2.googleapis.com",
"openidconnect.googleapis.com",
"www.googleapis.com",
)
fun normalizeHostname(value: String): String? {
val trimmed = value.trim().trim('.').lowercase()
if (trimmed.isEmpty()) {
return null
}
if (!trimmed.contains('.')) {
return null
}
if (!trimmed.matches(Regex("^[a-z0-9.-]+$"))) {
return null
}
if (trimmed.startsWith(".") || trimmed.endsWith(".")) {
return null
}
return trimmed
}
private data class WebsiteBlockPolicy(
val requestedWebsites: List<String>,
val blockedWebsites: List<String>,
val allowedWebsites: List<String>,
val matchMode: String,
)
private fun shouldAddWwwVariant(hostname: String): Boolean {
val parts = hostname.split(".")
return parts.size == 2 && parts[0] != "www"
}
private fun buildPolicy(requestedWebsites: Collection<String>): WebsiteBlockPolicy {
val normalizedRequested = requestedWebsites.mapNotNull(::normalizeHostname)
.distinct()
.sorted()
val blockedWebsites = linkedSetOf<String>()
val allowedWebsites = linkedSetOf<String>()
for (website in normalizedRequested) {
blockedWebsites += website
if (shouldAddWwwVariant(website)) {
blockedWebsites += "www.$website"
}
when {
website in X_TWITTER_REQUESTED_HOSTS -> {
blockedWebsites += X_TWITTER_BLOCKED_HOSTS
allowedWebsites += X_TWITTER_ALLOWED_HOSTS
}
website in GOOGLE_NEWS_REQUESTED_HOSTS -> {
blockedWebsites += GOOGLE_NEWS_BLOCKED_HOSTS
allowedWebsites += GOOGLE_NEWS_ALLOWED_HOSTS
}
}
}
return WebsiteBlockPolicy(
requestedWebsites = normalizedRequested,
blockedWebsites = blockedWebsites.mapNotNull(::normalizeHostname).distinct().sorted(),
allowedWebsites = allowedWebsites.mapNotNull(::normalizeHostname).distinct().sorted(),
matchMode = MATCH_MODE_EXACT,
)
}
private fun readNormalizedWebsiteSet(prefs: android.content.SharedPreferences, key: String): List<String> {
return prefs.getStringSet(key, null)
?.mapNotNull(::normalizeHostname)
?.distinct()
?.sorted()
.orEmpty()
}
fun load(context: Context): SavedWebsiteBlock? {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val requestedWebsites = readNormalizedWebsiteSet(prefs, KEY_REQUESTED_WEBSITES)
.ifEmpty { readNormalizedWebsiteSet(prefs, KEY_WEBSITES) }
if (requestedWebsites.isEmpty()) {
return null
}
val policy = buildPolicy(requestedWebsites)
val blockedWebsites = readNormalizedWebsiteSet(prefs, KEY_BLOCKED_WEBSITES)
.ifEmpty { policy.blockedWebsites }
val allowedWebsites = readNormalizedWebsiteSet(prefs, KEY_ALLOWED_WEBSITES)
.ifEmpty { policy.allowedWebsites }
val matchMode = prefs.getString(KEY_MATCH_MODE, MATCH_MODE_EXACT)
?.lowercase()
?.takeIf { it == MATCH_MODE_SUBDOMAIN }
?: MATCH_MODE_EXACT
val endsAtValue = prefs.getLong(KEY_ENDS_AT, -1L)
val endsAt = if (endsAtValue > 0) endsAtValue else null
if (endsAt != null && endsAt <= System.currentTimeMillis()) {
clear(context)
return null
}
return SavedWebsiteBlock(
requestedWebsites = requestedWebsites,
blockedWebsites = blockedWebsites,
allowedWebsites = allowedWebsites,
matchMode = matchMode,
endsAtEpochMs = endsAt,
)
}
fun save(
context: Context,
websites: Collection<String>,
endsAtEpochMs: Long?,
): SavedWebsiteBlock? {
val policy = buildPolicy(websites)
if (policy.requestedWebsites.isEmpty()) {
clear(context)
return null
}
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putStringSet(KEY_REQUESTED_WEBSITES, policy.requestedWebsites.toSet())
.putStringSet(KEY_BLOCKED_WEBSITES, policy.blockedWebsites.toSet())
.putStringSet(KEY_ALLOWED_WEBSITES, policy.allowedWebsites.toSet())
.putString(KEY_MATCH_MODE, policy.matchMode)
.putStringSet(KEY_WEBSITES, policy.requestedWebsites.toSet())
.putLong(KEY_ENDS_AT, endsAtEpochMs ?: -1L)
.apply()
return SavedWebsiteBlock(
requestedWebsites = policy.requestedWebsites,
blockedWebsites = policy.blockedWebsites,
allowedWebsites = policy.allowedWebsites,
matchMode = policy.matchMode,
endsAtEpochMs = endsAtEpochMs,
)
}
fun clear(context: Context) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.remove(KEY_REQUESTED_WEBSITES)
.remove(KEY_BLOCKED_WEBSITES)
.remove(KEY_ALLOWED_WEBSITES)
.remove(KEY_MATCH_MODE)
.remove(KEY_WEBSITES)
.remove(KEY_ENDS_AT)
.apply()
}
fun isBlockedHostname(policy: SavedWebsiteBlock, queryName: String): Boolean {
val normalizedQuery = normalizeHostname(queryName) ?: return false
if (policy.allowedWebsites.any { allowed -> normalizedQuery == allowed }) {
return false
}
return policy.blockedWebsites.any { blocked ->
normalizedQuery == blocked ||
(policy.matchMode == MATCH_MODE_SUBDOMAIN && normalizedQuery.endsWith(".$blocked"))
}
}
}
@@ -0,0 +1,326 @@
package ai.eliza.plugins.websiteblocker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.net.VpnService
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelFileDescriptor
import java.io.FileInputStream
import java.io.FileOutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.Inet4Address
import java.net.InetAddress
import java.net.InetSocketAddress
import java.util.concurrent.atomic.AtomicBoolean
class WebsiteBlockerVpnService : VpnService() {
companion object {
const val ACTION_START = "ai.eliza.websiteblocker.START"
const val ACTION_STOP = "ai.eliza.websiteblocker.STOP"
const val EXTRA_WEBSITES = "websites"
const val EXTRA_ENDS_AT = "ends_at"
private const val NOTIFICATION_CHANNEL_ID = "website_blocker_vpn"
private const val NOTIFICATION_ID = 9184
private const val VPN_ADDRESS = "10.77.0.1"
private const val DNS_ADDRESS = "10.77.0.2"
@Volatile
private var activeInstance: WebsiteBlockerVpnService? = null
fun isRunning(): Boolean = activeInstance != null
}
private var vpnInterface: ParcelFileDescriptor? = null
private var tunnelThread: Thread? = null
private val tunnelRunning = AtomicBoolean(false)
private val mainHandler = Handler(Looper.getMainLooper())
private var scheduledStop: Runnable? = null
private var shouldClearStateOnStop = false
@Volatile
private var blockedWebsites: Set<String> = emptySet()
@Volatile
private var allowedWebsites: Set<String> = emptySet()
@Volatile
private var matchMode: String = "exact"
@Volatile
private var activePolicy: SavedWebsiteBlock? = null
override fun onCreate() {
super.onCreate()
activeInstance = this
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action ?: ACTION_START
if (action == ACTION_STOP) {
shouldClearStateOnStop = true
stopSelf()
return START_NOT_STICKY
}
val persisted = WebsiteBlockerStateStore.load(this)
val websites = intent?.getStringArrayListExtra(EXTRA_WEBSITES)
?.mapNotNull(WebsiteBlockerStateStore::normalizeHostname)
?.distinct()
?: persisted?.requestedWebsites
?: emptyList()
if (websites.isEmpty()) {
shouldClearStateOnStop = true
stopSelf()
return START_NOT_STICKY
}
val endsAt = when {
intent?.hasExtra(EXTRA_ENDS_AT) == true -> {
val value = intent.getLongExtra(EXTRA_ENDS_AT, -1L)
if (value > 0L) value else null
}
else -> persisted?.endsAtEpochMs
}
val savedBlock = WebsiteBlockerStateStore.save(this, websites, endsAt)
?: run {
shouldClearStateOnStop = true
stopSelf()
return START_NOT_STICKY
}
activePolicy = savedBlock
blockedWebsites = savedBlock.blockedWebsites.toSet()
allowedWebsites = savedBlock.allowedWebsites.toSet()
matchMode = savedBlock.matchMode
shouldClearStateOnStop = false
startForegroundNotification()
establishVpn()
startTunnelLoop()
scheduleStop(endsAt)
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
cancelScheduledStop()
stopTunnelLoop()
if (shouldClearStateOnStop) {
WebsiteBlockerStateStore.clear(this)
}
activeInstance = null
}
override fun onRevoke() {
shouldClearStateOnStop = true
super.onRevoke()
stopSelf()
}
private fun establishVpn() {
if (vpnInterface != null) {
return
}
val builder = Builder()
.setSession("Eliza Website Blocker")
.setBlocking(true)
.setMtu(1500)
.addAddress(VPN_ADDRESS, 32)
.addRoute(DNS_ADDRESS, 32)
.addDnsServer(DNS_ADDRESS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
builder.setMetered(false)
}
vpnInterface = builder.establish()
}
private fun startTunnelLoop() {
if (tunnelRunning.get()) {
return
}
val descriptor = vpnInterface ?: return
val policy = activePolicy ?: WebsiteBlockerStateStore.load(this) ?: return
tunnelRunning.set(true)
tunnelThread = Thread {
val dnsAddress = InetAddress.getByName(DNS_ADDRESS) as Inet4Address
FileInputStream(descriptor.fileDescriptor).use { input ->
FileOutputStream(descriptor.fileDescriptor).use { output ->
val packetBuffer = ByteArray(32_767)
while (tunnelRunning.get()) {
val length = try {
input.read(packetBuffer)
} catch (_: Exception) {
break
}
if (length <= 0) {
continue
}
val query = DnsPacketCodec.parseUdpDnsQuery(packetBuffer, length, dnsAddress)
?: continue
val responsePayload = if (
WebsiteBlockerStateStore.isBlockedHostname(policy, query.queryName)
) {
DnsPacketCodec.buildBlockedDnsResponse(query.dnsPayload)
} else {
forwardDnsQuery(query.dnsPayload)
?: DnsPacketCodec.buildServerFailureDnsResponse(query.dnsPayload)
}
val responsePacket = DnsPacketCodec.buildUdpDnsResponse(query, responsePayload)
try {
output.write(responsePacket)
} catch (_: Exception) {
break
}
}
}
}
}.apply {
name = "ElizaWebsiteBlockerVpn"
isDaemon = true
start()
}
}
private fun stopTunnelLoop() {
tunnelRunning.set(false)
tunnelThread?.interrupt()
tunnelThread = null
try {
vpnInterface?.close()
} catch (_: Exception) {
}
vpnInterface = null
}
private fun forwardDnsQuery(queryPayload: ByteArray): ByteArray? {
val upstreamServers = resolveUpstreamDnsServers()
for (server in upstreamServers) {
try {
DatagramSocket().use { socket ->
protect(socket)
socket.soTimeout = 3_000
socket.connect(InetSocketAddress(server, 53))
socket.send(DatagramPacket(queryPayload, queryPayload.size))
val responseBuffer = ByteArray(4_096)
val responsePacket = DatagramPacket(responseBuffer, responseBuffer.size)
socket.receive(responsePacket)
return responseBuffer.copyOf(responsePacket.length)
}
} catch (_: Exception) {
}
}
return null
}
private fun resolveUpstreamDnsServers(): List<InetAddress> {
val connectivityManager =
applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
val network = connectivityManager?.activeNetwork
val linkProperties = connectivityManager?.getLinkProperties(network)
val dnsServers = linkProperties?.dnsServers
?.filterIsInstance<Inet4Address>()
?.filter { it.hostAddress != DNS_ADDRESS }
.orEmpty()
if (dnsServers.isNotEmpty()) {
return dnsServers
}
return listOf(
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("8.8.8.8"),
)
}
private fun scheduleStop(endsAtEpochMs: Long?) {
cancelScheduledStop()
if (endsAtEpochMs == null) {
return
}
val delayMs = endsAtEpochMs - System.currentTimeMillis()
if (delayMs <= 0) {
shouldClearStateOnStop = true
stopSelf()
return
}
val stopRunnable = Runnable {
shouldClearStateOnStop = true
stopSelf()
}
scheduledStop = stopRunnable
mainHandler.postDelayed(stopRunnable, delayMs)
}
private fun cancelScheduledStop() {
scheduledStop?.let { mainHandler.removeCallbacks(it) }
scheduledStop = null
}
private fun startForegroundNotification() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= 34) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun buildNotification(): Notification {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null
) {
createNotificationChannel()
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("Eliza Website Blocker")
.setContentText("Blocking ${blockedWebsites.joinToString(", ")}")
.setSmallIcon(android.R.drawable.ic_lock_lock)
.setOngoing(true)
.build()
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
.setContentTitle("Eliza Website Blocker")
.setContentText("Blocking ${blockedWebsites.joinToString(", ")}")
.setSmallIcon(android.R.drawable.ic_lock_lock)
.setOngoing(true)
.build()
}
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Eliza Website Blocker",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Foreground notification while website blocking is active"
},
)
}
}
@@ -0,0 +1,123 @@
package ai.eliza.plugins.websiteblocker
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class WebsiteBlockerStateStoreTest {
@Test
fun `normalizeHostname trims casing and trailing dots`() {
assertEquals(
"twitter.com",
WebsiteBlockerStateStore.normalizeHostname(" TWITTER.COM. "),
)
}
@Test
fun `normalizeHostname rejects invalid inputs`() {
assertNull(WebsiteBlockerStateStore.normalizeHostname(""))
assertNull(WebsiteBlockerStateStore.normalizeHostname("localhost"))
assertNull(WebsiteBlockerStateStore.normalizeHostname("exa mple.com"))
assertNull(WebsiteBlockerStateStore.normalizeHostname("https://x.com"))
}
@Test
fun `isBlockedHostname honors allowlist precedence for X API hosts`() {
val policy = SavedWebsiteBlock(
requestedWebsites = listOf("x.com"),
blockedWebsites = listOf(
"x.com",
"www.x.com",
"mobile.x.com",
"twitter.com",
"www.twitter.com",
"mobile.twitter.com",
"t.co",
"abs.twimg.com",
"pbs.twimg.com",
"video.twimg.com",
"ton.twimg.com",
"platform.twitter.com",
"tweetdeck.twitter.com",
),
allowedWebsites = listOf("api.x.com", "api.twitter.com"),
matchMode = "exact",
endsAtEpochMs = null,
)
assertTrue(WebsiteBlockerStateStore.isBlockedHostname(policy, "x.com"))
assertTrue(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"twitter.com",
),
)
assertFalse(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"api.x.com",
),
)
assertFalse(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"api.twitter.com",
),
)
assertFalse(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"nottwitter.com",
),
)
}
@Test
fun `isBlockedHostname keeps Google News scoped`() {
val policy = SavedWebsiteBlock(
requestedWebsites = listOf("news.google.com"),
blockedWebsites = listOf("news.google.com"),
allowedWebsites = listOf(
"accounts.google.com",
"oauth2.googleapis.com",
"openidconnect.googleapis.com",
"www.googleapis.com",
),
matchMode = "exact",
endsAtEpochMs = null,
)
assertTrue(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"news.google.com",
),
)
assertFalse(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"accounts.google.com",
),
)
assertFalse(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"oauth2.googleapis.com",
),
)
assertFalse(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"openidconnect.googleapis.com",
),
)
assertFalse(
WebsiteBlockerStateStore.isBlockedHostname(
policy,
"www.googleapis.com",
),
)
}
}