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,86 @@
package ai.eliza.plugins.appblocker
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
/**
* On-device instrumented test for `@elizaos/capacitor-appblocker`'s persisted
* block state (#9967).
*
* Drives [AppBlockerStateStore] against the device's real `SharedPreferences` —
* the persistence + time-based expiry that decides whether an app is actually
* blocked. This is exactly the native side-effect a mocked Capacitor bridge in
* desktop Chromium cannot exercise; the block-correctness logic ran on no test,
* on no device. Each test isolates by clearing the store before and after.
*/
@RunWith(AndroidJUnit4::class)
class AppBlockerStateStoreInstrumentedTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun clearBefore() = AppBlockerStateStore.clear(context)
@After
fun clearAfter() = AppBlockerStateStore.clear(context)
@Test
fun saveThenLoad_roundTripsSortedPackagesAndExpiry() {
val endsAt = System.currentTimeMillis() + 3_600_000L
AppBlockerStateStore.save(context, listOf("com.example.b", "com.example.a"), endsAt)
val saved = AppBlockerStateStore.load(context)
assertEquals(listOf("com.example.a", "com.example.b"), saved?.packageNames)
assertEquals(endsAt, saved?.endsAtEpochMs)
}
@Test
fun save_withNoExpiry_persistsIndefiniteBlock() {
AppBlockerStateStore.save(context, listOf("com.example.app"), null)
val saved = AppBlockerStateStore.load(context)
assertEquals(listOf("com.example.app"), saved?.packageNames)
assertNull("an indefinite block has no expiry", saved?.endsAtEpochMs)
}
@Test
fun load_returnsNullAndClearsWhenExpired() {
// A block whose window has already elapsed must not keep blocking — load
// detects the past expiry, clears the store, and reports no active block.
val past = System.currentTimeMillis() - 1_000L
AppBlockerStateStore.save(context, listOf("com.example.app"), past)
assertNull("expired block is inactive", AppBlockerStateStore.load(context))
// and it was actually cleared, not merely filtered on read
assertFalse(AppBlockerStateStore.isBlocked(context, "com.example.app"))
}
@Test
fun isBlocked_reflectsTheSavedSetThenClears() {
AppBlockerStateStore.save(
context,
listOf("com.example.a", "com.example.b"),
System.currentTimeMillis() + 3_600_000L,
)
assertTrue(AppBlockerStateStore.isBlocked(context, "com.example.a"))
assertTrue(AppBlockerStateStore.isBlocked(context, "com.example.b"))
assertFalse(AppBlockerStateStore.isBlocked(context, "com.example.c"))
AppBlockerStateStore.clear(context)
assertFalse("clearing lifts every block", AppBlockerStateStore.isBlocked(context, "com.example.a"))
}
@Test
fun load_returnsNullWhenNothingSaved() {
assertNull(AppBlockerStateStore.load(context))
assertFalse(AppBlockerStateStore.isBlocked(context, "com.example.app"))
}
}
@@ -0,0 +1,62 @@
package ai.eliza.plugins.appblocker
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
/**
* On-device read test for the launchable-app enumeration (issue #9967).
*
* Drives the real [android.content.pm.PackageManager] query via
* [InstalledAppsReader] — the same read [AppBlockerPlugin.getInstalledApps]
* exposes — and asserts the result is real, well-formed, de-duplicated, and
* sorted. Permission-free, so it asserts positively on any device/emulator.
*/
@RunWith(AndroidJUnit4::class)
class InstalledAppsReaderInstrumentedTest {
private val context: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext
@Test
fun listLaunchableApps_returnsRealWellFormedApps() {
val apps = InstalledAppsReader(context).listLaunchableApps()
// Every Android device/emulator ships launchable system apps.
assertTrue("device has at least one launchable app", apps.isNotEmpty())
for (app in apps) {
assertTrue("package name is non-empty", app.packageName.isNotEmpty())
assertTrue("display name is non-empty for ${app.packageName}", app.displayName.isNotEmpty())
}
// De-duplicated by package name.
assertEquals(
"no duplicate packages",
apps.map { it.packageName }.distinct().size,
apps.size,
)
// Sorted case-insensitively by display name (the plugin's contract).
val names = apps.map { it.displayName.lowercase() }
assertEquals("sorted by display name", names.sortedBy { it }, names)
}
@Test
fun listLaunchableApps_includesAKnownLauncherApp() {
val apps = InstalledAppsReader(context).listLaunchableApps()
// The Settings app is launchable on every standard Android image — a
// concrete cross-check that the real PackageManager query ran (not a stub).
assertTrue(
"a known launchable system app (Settings or a launcher) is present",
apps.any {
it.packageName == "com.android.settings" ||
it.packageName.contains("launcher")
},
)
}
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<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" />
<application>
<service
android:name=".AppBlockerForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="app_blocking_monitor" />
</service>
</application>
</manifest>
@@ -0,0 +1,345 @@
package ai.eliza.plugins.appblocker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.PixelFormat
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.Settings
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.app.NotificationCompat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class AppBlockerForegroundService : Service() {
private val handler = Handler(Looper.getMainLooper())
private var polling = false
private var ownPackageName = ""
private var overlayView: View? = null
private var windowManager: WindowManager? = null
private val pollRunnable = object : Runnable {
override fun run() {
if (!polling) {
return
}
checkForegroundApp()
if (polling) {
handler.postDelayed(this, POLL_INTERVAL_MS)
}
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
ownPackageName = packageName
windowManager = getSystemService(WINDOW_SERVICE) as? WindowManager
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopPolling()
hideBlockingOverlay()
stopForegroundCompat()
stopSelf()
return START_NOT_STICKY
}
ACTION_START, null -> Unit
else -> return START_NOT_STICKY
}
val saved = AppBlockerStateStore.load(this)
if (saved == null || saved.packageNames.isEmpty()) {
stopSelf()
return START_NOT_STICKY
}
startForeground(NOTIFICATION_ID, buildNotification(saved))
startPolling()
val endsAtEpochMs = saved.endsAtEpochMs
if (endsAtEpochMs != null) {
val delayMs = endsAtEpochMs - System.currentTimeMillis()
if (delayMs > 0) {
handler.postDelayed({
AppBlockerStateStore.clear(this)
stopPolling()
hideBlockingOverlay()
stopForegroundCompat()
stopSelf()
}, delayMs)
} else {
AppBlockerStateStore.clear(this)
stopSelf()
return START_NOT_STICKY
}
}
return START_STICKY
}
override fun onDestroy() {
stopPolling()
hideBlockingOverlay()
super.onDestroy()
}
private fun startPolling() {
if (polling) {
return
}
polling = true
handler.post(pollRunnable)
}
private fun stopPolling() {
polling = false
handler.removeCallbacks(pollRunnable)
}
private fun checkForegroundApp() {
val saved = AppBlockerStateStore.load(this)
if (saved == null || saved.packageNames.isEmpty()) {
hideBlockingOverlay()
stopPolling()
stopForegroundCompat()
stopSelf()
return
}
if (!Settings.canDrawOverlays(this)) {
hideBlockingOverlay()
return
}
val foregroundPackage = getForegroundPackage() ?: return
val shouldBlock = foregroundPackage != ownPackageName &&
foregroundPackage != "com.android.launcher" &&
!foregroundPackage.contains("launcher", ignoreCase = true) &&
AppBlockerStateStore.isBlocked(this, foregroundPackage)
if (shouldBlock) {
showBlockingOverlay(saved)
} else {
hideBlockingOverlay()
}
}
private fun getForegroundPackage(): String? {
val usageStatsManager = getSystemService("usagestats") as? UsageStatsManager ?: return null
val now = System.currentTimeMillis()
val usageEvents = usageStatsManager.queryEvents(now - 2_000, now)
val event = UsageEvents.Event()
var packageName: String? = null
while (usageEvents.hasNextEvent()) {
usageEvents.getNextEvent(event)
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
packageName = event.packageName
}
}
return packageName
}
private fun showBlockingOverlay(saved: SavedAppBlock) {
if (overlayView != null) {
updateOverlayMessage(saved)
return
}
val overlayCard = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setPadding(64, 64, 64, 64)
setBackgroundColor(Color.parseColor("#E8EFE2"))
}
val titleView = TextView(this).apply {
id = View.generateViewId()
text = "App Blocked"
textSize = 28f
setTextColor(Color.parseColor("#132011"))
gravity = Gravity.CENTER
}
val messageView = TextView(this).apply {
id = View.generateViewId()
tag = "message"
textSize = 16f
setTextColor(Color.parseColor("#2D3C2B"))
gravity = Gravity.CENTER
setPadding(0, 24, 0, 32)
}
val homeButton = Button(this).apply {
text = "Go Home"
setOnClickListener { goHome() }
}
overlayCard.addView(
titleView,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
),
)
overlayCard.addView(
messageView,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
),
)
overlayCard.addView(
homeButton,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
),
)
val overlayRoot = FrameLayout(this).apply {
setBackgroundColor(Color.parseColor("#CC132011"))
addView(
overlayCard,
FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.CENTER,
).apply {
marginStart = 48
marginEnd = 48
},
)
}
val windowType = if (Build.VERSION.SDK_INT >= 26) {
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
@Suppress("DEPRECATION")
WindowManager.LayoutParams.TYPE_PHONE
}
val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.MATCH_PARENT,
windowType,
768,
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.CENTER
}
updateOverlayMessage(saved, messageView)
try {
windowManager?.addView(overlayRoot, layoutParams)
overlayView = overlayRoot
} catch (_: Exception) {
overlayView = null
}
}
private fun updateOverlayMessage(saved: SavedAppBlock, messageView: TextView? = findOverlayMessageView()) {
val message = saved.endsAtEpochMs?.let { endsAtEpochMs ->
val formatter = SimpleDateFormat("h:mm a", Locale.getDefault())
"This app is blocked by Eliza until ${formatter.format(Date(endsAtEpochMs))}."
} ?: "This app is blocked by Eliza until you unblock it."
messageView?.text = message
}
private fun findOverlayMessageView(): TextView? {
val root = overlayView as? FrameLayout ?: return null
return root.findViewWithTag("message") as? TextView
}
private fun hideBlockingOverlay() {
val currentOverlay = overlayView ?: return
try {
windowManager?.removeView(currentOverlay)
overlayView = null
} catch (_: Exception) {
overlayView = null
}
}
private fun goHome() {
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
startActivity(intent)
} catch (_: ActivityNotFoundException) {
}
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"App Blocker",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Eliza is monitoring and blocking selected apps."
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager?.createNotificationChannel(channel)
}
private fun buildNotification(saved: SavedAppBlock): Notification {
val count = saved.packageNames.size
val countSuffix = if (count == 1) "" else "s"
val contentText = if (saved.endsAtEpochMs != null) {
val formatter = SimpleDateFormat("h:mm a", Locale.getDefault())
val endsAt = formatter.format(Date(saved.endsAtEpochMs))
"Blocking $count app$countSuffix until $endsAt."
} else {
val pronoun = if (count == 1) "it" else "them"
"Blocking $count app$countSuffix until you unblock $pronoun."
}
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("App Blocker Active")
.setContentText(contentText)
.setSmallIcon(android.R.drawable.ic_lock_lock)
.setOngoing(true)
.build()
}
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= 24) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
companion object {
const val ACTION_START = "ai.eliza.plugins.appblocker.ACTION_START"
const val ACTION_STOP = "ai.eliza.plugins.appblocker.ACTION_STOP"
private const val CHANNEL_ID = "eliza_app_blocker"
private const val NOTIFICATION_ID = 9201
private const val POLL_INTERVAL_MS = 500L
}
}
@@ -0,0 +1,272 @@
package ai.eliza.plugins.appblocker
import android.app.AppOpsManager
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Process
import android.provider.Settings
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.CapacitorPlugin
import java.time.Instant
@CapacitorPlugin(name = "ElizaAppBlocker")
class AppBlockerPlugin : Plugin() {
// The PackageManager launchable-app enumeration lives in InstalledAppsReader
// so it is exercisable by an instrumented androidTest without a Capacitor
// Bridge (issue #9967).
private val installedAppsReader by lazy { InstalledAppsReader(context) }
@PluginMethod
override fun checkPermissions(call: PluginCall) {
call.resolve(buildPermissionResult())
}
@PluginMethod
override fun requestPermissions(call: PluginCall) {
if (!hasUsageAccess()) {
openSettings(Settings.ACTION_USAGE_ACCESS_SETTINGS, null)
} else if (!canDrawOverlays()) {
openSettings(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}"),
)
}
call.resolve(buildPermissionResult())
}
@PluginMethod
fun getInstalledApps(call: PluginCall) {
// The PackageManager launchable-app query is delegated to
// InstalledAppsReader so it can be exercised by an instrumented
// androidTest without a Capacitor Bridge (issue #9967); the JS shape
// below is unchanged.
val apps = installedAppsReader.listLaunchableApps().map { app ->
JSObject().apply {
put("packageName", app.packageName)
put("displayName", app.displayName)
}
}
call.resolve(
JSObject().apply {
put("apps", JSArray(apps))
},
)
}
@PluginMethod
fun selectApps(call: PluginCall) {
call.resolve(
JSObject().apply {
put("apps", JSArray())
put("cancelled", true)
},
)
}
@PluginMethod
fun blockApps(call: PluginCall) {
if (!hasUsageAccess() || !canDrawOverlays()) {
call.resolve(
JSObject().apply {
put("success", false)
put("endsAt", null as String?)
put("error", missingPermissionReason())
put("blockedCount", 0)
},
)
return
}
val explicitPackageNames = call.data.optJSONArray("packageNames")
val normalizedPackageNames = buildList {
if (explicitPackageNames == null) return@buildList
for (index in 0 until explicitPackageNames.length()) {
val value = explicitPackageNames.optString(index).trim()
if (value.isNotEmpty()) {
add(value)
}
}
}
.distinct()
.sorted()
if (normalizedPackageNames.isEmpty()) {
call.resolve(
JSObject().apply {
put("success", false)
put("endsAt", null as String?)
put("error", "Select at least one Android app to block.")
put("blockedCount", 0)
},
)
return
}
val durationMinutes = parseDurationMinutes(call)
val endsAtEpochMs = durationMinutes?.let { System.currentTimeMillis() + (it * 60_000L) }
AppBlockerStateStore.save(
context = context,
packageNames = normalizedPackageNames,
endsAtEpochMs = endsAtEpochMs,
)
val serviceIntent = Intent(context, AppBlockerForegroundService::class.java).apply {
action = AppBlockerForegroundService.ACTION_START
}
ContextCompat.startForegroundService(context, serviceIntent)
call.resolve(
JSObject().apply {
put("success", true)
put("endsAt", endsAtEpochMs?.let { Instant.ofEpochMilli(it).toString() })
put("blockedCount", normalizedPackageNames.size)
},
)
}
@PluginMethod
fun unblockApps(call: PluginCall) {
AppBlockerStateStore.clear(context)
context.stopService(
Intent(context, AppBlockerForegroundService::class.java).apply {
action = AppBlockerForegroundService.ACTION_STOP
},
)
call.resolve(
JSObject().apply {
put("success", true)
},
)
}
@PluginMethod
fun getStatus(call: PluginCall) {
val saved = AppBlockerStateStore.load(context)
val permission = buildPermissionResult()
val reason = if (saved != null && (!hasUsageAccess() || !canDrawOverlays())) {
missingPermissionReason()
} else {
permission.getString("reason")
}
call.resolve(
JSObject().apply {
put("status", if (saved != null) "active" else "inactive")
put("available", true)
put("active", saved != null)
put("platform", "android")
put("engine", "usage-stats-overlay")
put("capabilities", appBlockerCapabilities())
put("blockedCount", saved?.packageNames?.size ?: 0)
put("blockedPackageNames", JSArray(saved?.packageNames ?: emptyList<String>()))
put("endsAt", saved?.endsAtEpochMs?.let { Instant.ofEpochMilli(it).toString() })
put("permissionStatus", permission.getString("status"))
put("canRequest", permission.getBool("canRequest"))
put("canOpenSettings", permission.getBool("canOpenSettings"))
put("settingsTarget", permission.opt("settingsTarget"))
if (!reason.isNullOrBlank()) {
put("reason", reason)
}
},
)
}
private fun parseDurationMinutes(call: PluginCall): Long? {
val rawValue = call.data.opt("durationMinutes") ?: return null
val duration = when (rawValue) {
is Number -> rawValue.toLong()
is String -> rawValue.toLongOrNull()
else -> null
}
return duration?.takeIf { it > 0 }
}
private fun buildPermissionResult(): JSObject {
val usageAccess = hasUsageAccess()
val overlayAccess = canDrawOverlays()
return JSObject().apply {
put("status", if (usageAccess && overlayAccess) "granted" else "not-determined")
put("canRequest", !usageAccess || !overlayAccess)
put("canOpenSettings", !usageAccess || !overlayAccess)
put("settingsTarget", settingsTarget(usageAccess, overlayAccess))
put("engine", "usage-stats-overlay")
put("capabilities", appBlockerCapabilities())
missingPermissionReason()?.let { put("reason", it) }
}
}
private fun appBlockerCapabilities(): JSObject {
return JSObject().apply {
put("canSelectApps", true)
put("canBlockApps", true)
put("canScheduleTimedBlocks", true)
put("canUnblockEarly", true)
put("requiresFamilyControls", false)
put("requiresUsageAccess", true)
put("requiresOverlay", true)
}
}
private fun settingsTarget(usageAccess: Boolean, overlayAccess: Boolean): String? {
return when {
!usageAccess && !overlayAccess -> "deviceSettings"
!usageAccess -> "usageAccess"
!overlayAccess -> "overlay"
else -> null
}
}
private fun missingPermissionReason(): String? {
val missingUsageAccess = !hasUsageAccess()
val missingOverlayAccess = !canDrawOverlays()
return when {
missingUsageAccess && missingOverlayAccess ->
"Android needs Usage Access and Draw Over Other Apps before Eliza can block apps on this phone."
missingUsageAccess ->
"Android needs Usage Access before Eliza can detect and block foreground apps."
missingOverlayAccess ->
"Android needs Draw Over Other Apps before Eliza can show the blocking shield."
else -> null
}
}
private fun hasUsageAccess(): Boolean {
val appOps = context.getSystemService("appops") as? AppOpsManager ?: return false
val mode = if (Build.VERSION.SDK_INT >= 29) {
appOps.unsafeCheckOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS,
Process.myUid(),
context.packageName,
)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow(
AppOpsManager.OPSTR_GET_USAGE_STATS,
Process.myUid(),
context.packageName,
)
}
return mode == AppOpsManager.MODE_ALLOWED
}
private fun canDrawOverlays(): Boolean {
return Build.VERSION.SDK_INT < 23 || Settings.canDrawOverlays(context)
}
private fun openSettings(action: String, uri: Uri?) {
val intent = Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (uri != null) {
intent.data = uri
}
context.startActivity(intent)
}
}
@@ -0,0 +1,65 @@
package ai.eliza.plugins.appblocker
import android.content.Context
data class SavedAppBlock(
val packageNames: List<String>,
val endsAtEpochMs: Long?,
)
object AppBlockerStateStore {
private const val PREFS_NAME = "eliza_app_blocker"
private const val KEY_PACKAGE_NAMES = "blocked_package_names"
private const val KEY_ENDS_AT = "ends_at_epoch_ms"
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun load(context: Context): SavedAppBlock? {
val preferences = prefs(context)
val packageNames = preferences.getStringSet(KEY_PACKAGE_NAMES, null)
?.toList()
?.sorted()
?: return null
if (packageNames.isEmpty()) {
return null
}
val endsAtEpochMs = if (preferences.contains(KEY_ENDS_AT)) {
preferences.getLong(KEY_ENDS_AT, 0L)
} else {
null
}
if (endsAtEpochMs != null && endsAtEpochMs <= System.currentTimeMillis()) {
clear(context)
return null
}
return SavedAppBlock(
packageNames = packageNames,
endsAtEpochMs = endsAtEpochMs,
)
}
fun save(context: Context, packageNames: List<String>, endsAtEpochMs: Long?) {
prefs(context).edit().apply {
putStringSet(KEY_PACKAGE_NAMES, packageNames.toSet())
if (endsAtEpochMs != null) {
putLong(KEY_ENDS_AT, endsAtEpochMs)
} else {
remove(KEY_ENDS_AT)
}
apply()
}
}
fun clear(context: Context) {
prefs(context).edit().clear().apply()
}
fun isBlocked(context: Context, packageName: String): Boolean {
val saved = load(context) ?: return false
return saved.packageNames.contains(packageName)
}
}
@@ -0,0 +1,57 @@
package ai.eliza.plugins.appblocker
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
/**
* [Context]-backed reader for the launchable-app enumeration that
* [AppBlockerPlugin.getInstalledApps] exposes — the
* `PackageManager.queryIntentActivities(ACTION_MAIN/CATEGORY_LAUNCHER)` read.
*
* Extracted from the Capacitor plugin so the real [PackageManager] query can be
* exercised by an instrumented `androidTest` without a Capacitor `Bridge`/WebView
* (issue #9967). The plugin delegates to it and marshals each record into the
* unchanged `{ packageName, displayName }` JS shape. Permission-free.
*/
class InstalledAppsReader(context: Context) {
private val appContext: Context = context.applicationContext
data class LaunchableApp(val packageName: String, val displayName: String)
/**
* Home-screen-launchable apps, excluding this app, de-duplicated by package
* and sorted case-insensitively by display name.
*/
fun listLaunchableApps(): List<LaunchableApp> {
val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
val pm = appContext.packageManager
val matches = if (Build.VERSION.SDK_INT >= 33) {
pm.queryIntentActivities(launcherIntent, PackageManager.ResolveInfoFlags.of(0))
} else {
@Suppress("DEPRECATION")
pm.queryIntentActivities(launcherIntent, 0)
}
val ownPackageName = appContext.packageName
return matches
.asSequence()
.mapNotNull { resolveInfo ->
val packageName = resolveInfo.activityInfo?.packageName?.trim().orEmpty()
if (packageName.isEmpty() || packageName == ownPackageName) {
return@mapNotNull null
}
val displayName = resolveInfo.loadLabel(pm)
?.toString()
?.trim()
.takeUnless { it.isNullOrEmpty() }
?: packageName
LaunchableApp(packageName, displayName)
}
.distinctBy { it.packageName }
.sortedBy { it.displayName.lowercase() }
.toList()
}
}