@elizaos/capacitor-appblocker 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ElizaosCapacitorAppblocker'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.license = package['license'] || { :type => 'MIT' }
10
+ s.homepage = 'https://elizaos.ai'
11
+ s.authors = { 'elizaOS' => 'dev@elizaos.ai' }
12
+ s.source = { :git => 'https://github.com/elizaOS/eliza.git', :tag => s.version.to_s }
13
+ s.source_files = 'ios/Sources/**/*.{swift,h,m,c,cc,mm,cpp}'
14
+ s.ios.deployment_target = '15.0'
15
+ s.frameworks = 'FamilyControls', 'ManagedSettings'
16
+ s.dependency 'Capacitor'
17
+ s.swift_version = '5.1'
18
+ end
@@ -0,0 +1,51 @@
1
+ ext {
2
+ junitVersion = project.hasProperty('junitVersion') ? rootProject.ext.junitVersion : '4.13.2'
3
+ androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.6.1'
4
+ androidxCoreVersion = project.hasProperty('androidxCoreVersion') ? rootProject.ext.androidxCoreVersion : '1.12.0'
5
+ androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.1.5'
6
+ androidxEspressoCoreVersion = project.hasProperty('androidxEspressoCoreVersion') ? rootProject.ext.androidxEspressoCoreVersion : '3.5.1'
7
+ }
8
+
9
+ apply plugin: 'com.android.library'
10
+ android {
11
+ namespace = "ai.eliza.plugins.appblocker"
12
+ compileSdk project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 34
13
+
14
+ defaultConfig {
15
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 26
16
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 34
17
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
18
+ }
19
+
20
+ buildTypes {
21
+ release {
22
+ minifyEnabled false
23
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
24
+ }
25
+ }
26
+
27
+ compileOptions {
28
+ sourceCompatibility JavaVersion.VERSION_17
29
+ targetCompatibility JavaVersion.VERSION_17
30
+ }
31
+
32
+ }
33
+
34
+ repositories {
35
+ google()
36
+ maven {
37
+ url = uri(rootProject.ext.mavenCentralMirrorUrl)
38
+ }
39
+ mavenCentral()
40
+ }
41
+
42
+ dependencies {
43
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
44
+ implementation project(':capacitor-android')
45
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
46
+ implementation "androidx.core:core-ktx:$androidxCoreVersion"
47
+
48
+ testImplementation "junit:junit:$junitVersion"
49
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
50
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
51
+ }
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
+ xmlns:tools="http://schemas.android.com/tools">
4
+
5
+ <uses-permission
6
+ android:name="android.permission.PACKAGE_USAGE_STATS"
7
+ tools:ignore="ProtectedPermissions" />
8
+ <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
9
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
10
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
11
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
12
+
13
+ <application>
14
+ <service
15
+ android:name=".AppBlockerForegroundService"
16
+ android:enabled="true"
17
+ android:exported="false"
18
+ android:foregroundServiceType="specialUse">
19
+ <property
20
+ android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
21
+ android:value="app_blocking_monitor" />
22
+ </service>
23
+ </application>
24
+ </manifest>
@@ -0,0 +1,345 @@
1
+ package ai.eliza.plugins.appblocker
2
+
3
+ import android.app.Notification
4
+ import android.app.NotificationChannel
5
+ import android.app.NotificationManager
6
+ import android.app.Service
7
+ import android.app.usage.UsageEvents
8
+ import android.app.usage.UsageStatsManager
9
+ import android.content.ActivityNotFoundException
10
+ import android.content.Context
11
+ import android.content.Intent
12
+ import android.graphics.Color
13
+ import android.graphics.PixelFormat
14
+ import android.os.Build
15
+ import android.os.Handler
16
+ import android.os.IBinder
17
+ import android.os.Looper
18
+ import android.provider.Settings
19
+ import android.view.Gravity
20
+ import android.view.View
21
+ import android.view.WindowManager
22
+ import android.widget.Button
23
+ import android.widget.FrameLayout
24
+ import android.widget.LinearLayout
25
+ import android.widget.TextView
26
+ import androidx.core.app.NotificationCompat
27
+ import java.text.SimpleDateFormat
28
+ import java.util.Date
29
+ import java.util.Locale
30
+
31
+ class AppBlockerForegroundService : Service() {
32
+ private val handler = Handler(Looper.getMainLooper())
33
+ private var polling = false
34
+ private var ownPackageName = ""
35
+ private var overlayView: View? = null
36
+ private var windowManager: WindowManager? = null
37
+
38
+ private val pollRunnable = object : Runnable {
39
+ override fun run() {
40
+ if (!polling) {
41
+ return
42
+ }
43
+ checkForegroundApp()
44
+ if (polling) {
45
+ handler.postDelayed(this, POLL_INTERVAL_MS)
46
+ }
47
+ }
48
+ }
49
+
50
+ override fun onBind(intent: Intent?): IBinder? = null
51
+
52
+ override fun onCreate() {
53
+ super.onCreate()
54
+ ownPackageName = packageName
55
+ windowManager = getSystemService(WINDOW_SERVICE) as? WindowManager
56
+ createNotificationChannel()
57
+ }
58
+
59
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
60
+ when (intent?.action) {
61
+ ACTION_STOP -> {
62
+ stopPolling()
63
+ hideBlockingOverlay()
64
+ stopForegroundCompat()
65
+ stopSelf()
66
+ return START_NOT_STICKY
67
+ }
68
+ ACTION_START, null -> Unit
69
+ else -> return START_NOT_STICKY
70
+ }
71
+
72
+ val saved = AppBlockerStateStore.load(this)
73
+ if (saved == null || saved.packageNames.isEmpty()) {
74
+ stopSelf()
75
+ return START_NOT_STICKY
76
+ }
77
+
78
+ startForeground(NOTIFICATION_ID, buildNotification(saved))
79
+ startPolling()
80
+
81
+ val endsAtEpochMs = saved.endsAtEpochMs
82
+ if (endsAtEpochMs != null) {
83
+ val delayMs = endsAtEpochMs - System.currentTimeMillis()
84
+ if (delayMs > 0) {
85
+ handler.postDelayed({
86
+ AppBlockerStateStore.clear(this)
87
+ stopPolling()
88
+ hideBlockingOverlay()
89
+ stopForegroundCompat()
90
+ stopSelf()
91
+ }, delayMs)
92
+ } else {
93
+ AppBlockerStateStore.clear(this)
94
+ stopSelf()
95
+ return START_NOT_STICKY
96
+ }
97
+ }
98
+
99
+ return START_STICKY
100
+ }
101
+
102
+ override fun onDestroy() {
103
+ stopPolling()
104
+ hideBlockingOverlay()
105
+ super.onDestroy()
106
+ }
107
+
108
+ private fun startPolling() {
109
+ if (polling) {
110
+ return
111
+ }
112
+ polling = true
113
+ handler.post(pollRunnable)
114
+ }
115
+
116
+ private fun stopPolling() {
117
+ polling = false
118
+ handler.removeCallbacks(pollRunnable)
119
+ }
120
+
121
+ private fun checkForegroundApp() {
122
+ val saved = AppBlockerStateStore.load(this)
123
+ if (saved == null || saved.packageNames.isEmpty()) {
124
+ hideBlockingOverlay()
125
+ stopPolling()
126
+ stopForegroundCompat()
127
+ stopSelf()
128
+ return
129
+ }
130
+
131
+ if (!Settings.canDrawOverlays(this)) {
132
+ hideBlockingOverlay()
133
+ return
134
+ }
135
+
136
+ val foregroundPackage = getForegroundPackage() ?: return
137
+ val shouldBlock = foregroundPackage != ownPackageName &&
138
+ foregroundPackage != "com.android.launcher" &&
139
+ !foregroundPackage.contains("launcher", ignoreCase = true) &&
140
+ AppBlockerStateStore.isBlocked(this, foregroundPackage)
141
+
142
+ if (shouldBlock) {
143
+ showBlockingOverlay(saved)
144
+ } else {
145
+ hideBlockingOverlay()
146
+ }
147
+ }
148
+
149
+ private fun getForegroundPackage(): String? {
150
+ val usageStatsManager = getSystemService("usagestats") as? UsageStatsManager ?: return null
151
+ val now = System.currentTimeMillis()
152
+ val usageEvents = usageStatsManager.queryEvents(now - 2_000, now)
153
+ val event = UsageEvents.Event()
154
+ var packageName: String? = null
155
+ while (usageEvents.hasNextEvent()) {
156
+ usageEvents.getNextEvent(event)
157
+ if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
158
+ packageName = event.packageName
159
+ }
160
+ }
161
+ return packageName
162
+ }
163
+
164
+ private fun showBlockingOverlay(saved: SavedAppBlock) {
165
+ if (overlayView != null) {
166
+ updateOverlayMessage(saved)
167
+ return
168
+ }
169
+
170
+ val overlayCard = LinearLayout(this).apply {
171
+ orientation = LinearLayout.VERTICAL
172
+ gravity = Gravity.CENTER
173
+ setPadding(64, 64, 64, 64)
174
+ setBackgroundColor(Color.parseColor("#E8EFE2"))
175
+ }
176
+
177
+ val titleView = TextView(this).apply {
178
+ id = View.generateViewId()
179
+ text = "App Blocked"
180
+ textSize = 28f
181
+ setTextColor(Color.parseColor("#132011"))
182
+ gravity = Gravity.CENTER
183
+ }
184
+
185
+ val messageView = TextView(this).apply {
186
+ id = View.generateViewId()
187
+ tag = "message"
188
+ textSize = 16f
189
+ setTextColor(Color.parseColor("#2D3C2B"))
190
+ gravity = Gravity.CENTER
191
+ setPadding(0, 24, 0, 32)
192
+ }
193
+
194
+ val homeButton = Button(this).apply {
195
+ text = "Go Home"
196
+ setOnClickListener { goHome() }
197
+ }
198
+
199
+ overlayCard.addView(
200
+ titleView,
201
+ LinearLayout.LayoutParams(
202
+ LinearLayout.LayoutParams.MATCH_PARENT,
203
+ LinearLayout.LayoutParams.WRAP_CONTENT,
204
+ ),
205
+ )
206
+ overlayCard.addView(
207
+ messageView,
208
+ LinearLayout.LayoutParams(
209
+ LinearLayout.LayoutParams.MATCH_PARENT,
210
+ LinearLayout.LayoutParams.WRAP_CONTENT,
211
+ ),
212
+ )
213
+ overlayCard.addView(
214
+ homeButton,
215
+ LinearLayout.LayoutParams(
216
+ LinearLayout.LayoutParams.WRAP_CONTENT,
217
+ LinearLayout.LayoutParams.WRAP_CONTENT,
218
+ ),
219
+ )
220
+
221
+ val overlayRoot = FrameLayout(this).apply {
222
+ setBackgroundColor(Color.parseColor("#CC132011"))
223
+ addView(
224
+ overlayCard,
225
+ FrameLayout.LayoutParams(
226
+ FrameLayout.LayoutParams.MATCH_PARENT,
227
+ FrameLayout.LayoutParams.WRAP_CONTENT,
228
+ Gravity.CENTER,
229
+ ).apply {
230
+ marginStart = 48
231
+ marginEnd = 48
232
+ },
233
+ )
234
+ }
235
+
236
+ val windowType = if (Build.VERSION.SDK_INT >= 26) {
237
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
238
+ } else {
239
+ @Suppress("DEPRECATION")
240
+ WindowManager.LayoutParams.TYPE_PHONE
241
+ }
242
+
243
+ val layoutParams = WindowManager.LayoutParams(
244
+ WindowManager.LayoutParams.MATCH_PARENT,
245
+ WindowManager.LayoutParams.MATCH_PARENT,
246
+ windowType,
247
+ 768,
248
+ PixelFormat.TRANSLUCENT,
249
+ ).apply {
250
+ gravity = Gravity.CENTER
251
+ }
252
+
253
+ updateOverlayMessage(saved, messageView)
254
+
255
+ try {
256
+ windowManager?.addView(overlayRoot, layoutParams)
257
+ overlayView = overlayRoot
258
+ } catch (_: Exception) {
259
+ overlayView = null
260
+ }
261
+ }
262
+
263
+ private fun updateOverlayMessage(saved: SavedAppBlock, messageView: TextView? = findOverlayMessageView()) {
264
+ val message = saved.endsAtEpochMs?.let { endsAtEpochMs ->
265
+ val formatter = SimpleDateFormat("h:mm a", Locale.getDefault())
266
+ "This app is blocked by Eliza until ${formatter.format(Date(endsAtEpochMs))}."
267
+ } ?: "This app is blocked by Eliza until you unblock it."
268
+ messageView?.text = message
269
+ }
270
+
271
+ private fun findOverlayMessageView(): TextView? {
272
+ val root = overlayView as? FrameLayout ?: return null
273
+ return root.findViewWithTag("message") as? TextView
274
+ }
275
+
276
+ private fun hideBlockingOverlay() {
277
+ val currentOverlay = overlayView ?: return
278
+ try {
279
+ windowManager?.removeView(currentOverlay)
280
+ overlayView = null
281
+ } catch (_: Exception) {
282
+ overlayView = null
283
+ }
284
+ }
285
+
286
+ private fun goHome() {
287
+ val intent = Intent(Intent.ACTION_MAIN).apply {
288
+ addCategory(Intent.CATEGORY_HOME)
289
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
290
+ }
291
+ try {
292
+ startActivity(intent)
293
+ } catch (_: ActivityNotFoundException) {
294
+ }
295
+ }
296
+
297
+ private fun createNotificationChannel() {
298
+ val channel = NotificationChannel(
299
+ CHANNEL_ID,
300
+ "App Blocker",
301
+ NotificationManager.IMPORTANCE_LOW,
302
+ ).apply {
303
+ description = "Eliza is monitoring and blocking selected apps."
304
+ }
305
+ val notificationManager = getSystemService(NotificationManager::class.java)
306
+ notificationManager?.createNotificationChannel(channel)
307
+ }
308
+
309
+ private fun buildNotification(saved: SavedAppBlock): Notification {
310
+ val count = saved.packageNames.size
311
+ val countSuffix = if (count == 1) "" else "s"
312
+ val contentText = if (saved.endsAtEpochMs != null) {
313
+ val formatter = SimpleDateFormat("h:mm a", Locale.getDefault())
314
+ val endsAt = formatter.format(Date(saved.endsAtEpochMs))
315
+ "Blocking $count app$countSuffix until $endsAt."
316
+ } else {
317
+ val pronoun = if (count == 1) "it" else "them"
318
+ "Blocking $count app$countSuffix until you unblock $pronoun."
319
+ }
320
+ return NotificationCompat.Builder(this, CHANNEL_ID)
321
+ .setContentTitle("App Blocker Active")
322
+ .setContentText(contentText)
323
+ .setSmallIcon(android.R.drawable.ic_lock_lock)
324
+ .setOngoing(true)
325
+ .build()
326
+ }
327
+
328
+ private fun stopForegroundCompat() {
329
+ if (Build.VERSION.SDK_INT >= 24) {
330
+ stopForeground(STOP_FOREGROUND_REMOVE)
331
+ } else {
332
+ @Suppress("DEPRECATION")
333
+ stopForeground(true)
334
+ }
335
+ }
336
+
337
+ companion object {
338
+ const val ACTION_START = "ai.eliza.plugins.appblocker.ACTION_START"
339
+ const val ACTION_STOP = "ai.eliza.plugins.appblocker.ACTION_STOP"
340
+
341
+ private const val CHANNEL_ID = "eliza_app_blocker"
342
+ private const val NOTIFICATION_ID = 9201
343
+ private const val POLL_INTERVAL_MS = 500L
344
+ }
345
+ }
@@ -0,0 +1,260 @@
1
+ package ai.eliza.plugins.appblocker
2
+
3
+ import android.app.AppOpsManager
4
+ import android.content.Intent
5
+ import android.content.pm.PackageManager
6
+ import android.net.Uri
7
+ import android.os.Build
8
+ import android.os.Process
9
+ import android.provider.Settings
10
+ import androidx.core.content.ContextCompat
11
+ import com.getcapacitor.JSArray
12
+ import com.getcapacitor.JSObject
13
+ import com.getcapacitor.Plugin
14
+ import com.getcapacitor.PluginCall
15
+ import com.getcapacitor.PluginMethod
16
+ import com.getcapacitor.annotation.CapacitorPlugin
17
+ import java.time.Instant
18
+
19
+ @CapacitorPlugin(name = "ElizaAppBlocker")
20
+ class AppBlockerPlugin : Plugin() {
21
+ @PluginMethod
22
+ override fun checkPermissions(call: PluginCall) {
23
+ call.resolve(buildPermissionResult())
24
+ }
25
+
26
+ @PluginMethod
27
+ override fun requestPermissions(call: PluginCall) {
28
+ if (!hasUsageAccess()) {
29
+ openSettings(Settings.ACTION_USAGE_ACCESS_SETTINGS, null)
30
+ } else if (!canDrawOverlays()) {
31
+ openSettings(
32
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
33
+ Uri.parse("package:${context.packageName}"),
34
+ )
35
+ }
36
+ call.resolve(buildPermissionResult())
37
+ }
38
+
39
+ @PluginMethod
40
+ fun getInstalledApps(call: PluginCall) {
41
+ val launcherIntent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
42
+ val matches = if (Build.VERSION.SDK_INT >= 33) {
43
+ context.packageManager.queryIntentActivities(
44
+ launcherIntent,
45
+ PackageManager.ResolveInfoFlags.of(0),
46
+ )
47
+ } else {
48
+ @Suppress("DEPRECATION")
49
+ context.packageManager.queryIntentActivities(launcherIntent, 0)
50
+ }
51
+
52
+ val ownPackageName = context.packageName
53
+ val apps = matches
54
+ .asSequence()
55
+ .mapNotNull { resolveInfo ->
56
+ val packageName = resolveInfo.activityInfo?.packageName?.trim().orEmpty()
57
+ if (packageName.isEmpty() || packageName == ownPackageName) {
58
+ return@mapNotNull null
59
+ }
60
+ val displayName = resolveInfo.loadLabel(context.packageManager)
61
+ ?.toString()
62
+ ?.trim()
63
+ .takeUnless { it.isNullOrEmpty() }
64
+ ?: packageName
65
+ JSObject().apply {
66
+ put("packageName", packageName)
67
+ put("displayName", displayName)
68
+ }
69
+ }
70
+ .distinctBy { it.getString("packageName") }
71
+ .sortedBy { it.getString("displayName")?.lowercase() ?: "" }
72
+ .toList()
73
+
74
+ call.resolve(
75
+ JSObject().apply {
76
+ put("apps", JSArray(apps))
77
+ },
78
+ )
79
+ }
80
+
81
+ @PluginMethod
82
+ fun selectApps(call: PluginCall) {
83
+ call.resolve(
84
+ JSObject().apply {
85
+ put("apps", JSArray())
86
+ put("cancelled", true)
87
+ },
88
+ )
89
+ }
90
+
91
+ @PluginMethod
92
+ fun blockApps(call: PluginCall) {
93
+ if (!hasUsageAccess() || !canDrawOverlays()) {
94
+ call.resolve(
95
+ JSObject().apply {
96
+ put("success", false)
97
+ put("endsAt", null as String?)
98
+ put("error", missingPermissionReason())
99
+ put("blockedCount", 0)
100
+ },
101
+ )
102
+ return
103
+ }
104
+
105
+ val explicitPackageNames = call.data.optJSONArray("packageNames")
106
+ val normalizedPackageNames = buildList {
107
+ if (explicitPackageNames == null) return@buildList
108
+ for (index in 0 until explicitPackageNames.length()) {
109
+ val value = explicitPackageNames.optString(index).trim()
110
+ if (value.isNotEmpty()) {
111
+ add(value)
112
+ }
113
+ }
114
+ }
115
+ .distinct()
116
+ .sorted()
117
+
118
+ if (normalizedPackageNames.isEmpty()) {
119
+ call.resolve(
120
+ JSObject().apply {
121
+ put("success", false)
122
+ put("endsAt", null as String?)
123
+ put("error", "Select at least one Android app to block.")
124
+ put("blockedCount", 0)
125
+ },
126
+ )
127
+ return
128
+ }
129
+
130
+ val durationMinutes = parseDurationMinutes(call)
131
+ val endsAtEpochMs = durationMinutes?.let { System.currentTimeMillis() + (it * 60_000L) }
132
+
133
+ AppBlockerStateStore.save(
134
+ context = context,
135
+ packageNames = normalizedPackageNames,
136
+ endsAtEpochMs = endsAtEpochMs,
137
+ )
138
+
139
+ val serviceIntent = Intent(context, AppBlockerForegroundService::class.java).apply {
140
+ action = AppBlockerForegroundService.ACTION_START
141
+ }
142
+ ContextCompat.startForegroundService(context, serviceIntent)
143
+
144
+ call.resolve(
145
+ JSObject().apply {
146
+ put("success", true)
147
+ put("endsAt", endsAtEpochMs?.let { Instant.ofEpochMilli(it).toString() })
148
+ put("blockedCount", normalizedPackageNames.size)
149
+ },
150
+ )
151
+ }
152
+
153
+ @PluginMethod
154
+ fun unblockApps(call: PluginCall) {
155
+ AppBlockerStateStore.clear(context)
156
+ context.stopService(
157
+ Intent(context, AppBlockerForegroundService::class.java).apply {
158
+ action = AppBlockerForegroundService.ACTION_STOP
159
+ },
160
+ )
161
+
162
+ call.resolve(
163
+ JSObject().apply {
164
+ put("success", true)
165
+ },
166
+ )
167
+ }
168
+
169
+ @PluginMethod
170
+ fun getStatus(call: PluginCall) {
171
+ val saved = AppBlockerStateStore.load(context)
172
+ val permission = buildPermissionResult()
173
+ val reason = if (saved != null && (!hasUsageAccess() || !canDrawOverlays())) {
174
+ missingPermissionReason()
175
+ } else {
176
+ permission.getString("reason")
177
+ }
178
+
179
+ call.resolve(
180
+ JSObject().apply {
181
+ put("available", true)
182
+ put("active", saved != null)
183
+ put("platform", "android")
184
+ put("engine", "usage-stats-overlay")
185
+ put("blockedCount", saved?.packageNames?.size ?: 0)
186
+ put("blockedPackageNames", JSArray(saved?.packageNames ?: emptyList<String>()))
187
+ put("endsAt", saved?.endsAtEpochMs?.let { Instant.ofEpochMilli(it).toString() })
188
+ put("permissionStatus", permission.getString("status"))
189
+ if (!reason.isNullOrBlank()) {
190
+ put("reason", reason)
191
+ }
192
+ },
193
+ )
194
+ }
195
+
196
+ private fun parseDurationMinutes(call: PluginCall): Long? {
197
+ val rawValue = call.data.opt("durationMinutes") ?: return null
198
+ val duration = when (rawValue) {
199
+ is Number -> rawValue.toLong()
200
+ is String -> rawValue.toLongOrNull()
201
+ else -> null
202
+ }
203
+ return duration?.takeIf { it > 0 }
204
+ }
205
+
206
+ private fun buildPermissionResult(): JSObject {
207
+ val usageAccess = hasUsageAccess()
208
+ val overlayAccess = canDrawOverlays()
209
+ return JSObject().apply {
210
+ put("status", if (usageAccess && overlayAccess) "granted" else "not-determined")
211
+ put("canRequest", !usageAccess || !overlayAccess)
212
+ missingPermissionReason()?.let { put("reason", it) }
213
+ }
214
+ }
215
+
216
+ private fun missingPermissionReason(): String? {
217
+ val missingUsageAccess = !hasUsageAccess()
218
+ val missingOverlayAccess = !canDrawOverlays()
219
+ return when {
220
+ missingUsageAccess && missingOverlayAccess ->
221
+ "Android needs Usage Access and Draw Over Other Apps before Eliza can block apps on this phone."
222
+ missingUsageAccess ->
223
+ "Android needs Usage Access before Eliza can detect and block foreground apps."
224
+ missingOverlayAccess ->
225
+ "Android needs Draw Over Other Apps before Eliza can show the blocking shield."
226
+ else -> null
227
+ }
228
+ }
229
+
230
+ private fun hasUsageAccess(): Boolean {
231
+ val appOps = context.getSystemService("appops") as? AppOpsManager ?: return false
232
+ val mode = if (Build.VERSION.SDK_INT >= 29) {
233
+ appOps.unsafeCheckOpNoThrow(
234
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
235
+ Process.myUid(),
236
+ context.packageName,
237
+ )
238
+ } else {
239
+ @Suppress("DEPRECATION")
240
+ appOps.checkOpNoThrow(
241
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
242
+ Process.myUid(),
243
+ context.packageName,
244
+ )
245
+ }
246
+ return mode == AppOpsManager.MODE_ALLOWED
247
+ }
248
+
249
+ private fun canDrawOverlays(): Boolean {
250
+ return Build.VERSION.SDK_INT < 23 || Settings.canDrawOverlays(context)
251
+ }
252
+
253
+ private fun openSettings(action: String, uri: Uri?) {
254
+ val intent = Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
255
+ if (uri != null) {
256
+ intent.data = uri
257
+ }
258
+ context.startActivity(intent)
259
+ }
260
+ }