@elizaos/capacitor-mobile-signals 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,857 @@
1
+ package ai.eliza.plugins.mobilesignals
2
+
3
+ import android.app.AppOpsManager
4
+ import android.app.usage.UsageStatsManager
5
+ import android.app.KeyguardManager
6
+ import android.content.BroadcastReceiver
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.IntentFilter
10
+ import android.content.pm.PackageManager
11
+ import android.net.Uri
12
+ import android.os.BatteryManager
13
+ import android.os.Build
14
+ import android.os.PowerManager
15
+ import android.os.Process
16
+ import android.provider.Settings
17
+ import android.util.Log
18
+ import androidx.activity.result.ActivityResult
19
+ import androidx.health.connect.client.HealthConnectClient
20
+ import androidx.health.connect.client.PermissionController
21
+ import androidx.health.connect.client.permission.HealthPermission
22
+ import androidx.health.connect.client.records.HeartRateRecord
23
+ import androidx.health.connect.client.records.HeartRateVariabilityRmssdRecord
24
+ import androidx.health.connect.client.records.SleepSessionRecord
25
+ import androidx.health.connect.client.request.ReadRecordsRequest
26
+ import androidx.health.connect.client.time.TimeRangeFilter
27
+ import com.getcapacitor.JSArray
28
+ import com.getcapacitor.JSObject
29
+ import com.getcapacitor.Plugin
30
+ import com.getcapacitor.PluginCall
31
+ import com.getcapacitor.PluginMethod
32
+ import com.getcapacitor.annotation.ActivityCallback
33
+ import com.getcapacitor.annotation.CapacitorPlugin
34
+ import kotlinx.coroutines.CoroutineScope
35
+ import kotlinx.coroutines.Dispatchers
36
+ import kotlinx.coroutines.SupervisorJob
37
+ import kotlinx.coroutines.launch
38
+ import java.time.Duration
39
+ import java.time.Instant
40
+ import org.json.JSONObject
41
+
42
+ private const val HEALTH_CONNECT_PACKAGE = "com.google.android.apps.healthdata"
43
+ private const val FAMILY_CONTROLS_ENTITLEMENT = "com.apple.developer.family-controls"
44
+ private const val PACKAGE_USAGE_STATS_PERMISSION = "android.permission.PACKAGE_USAGE_STATS"
45
+
46
+ @CapacitorPlugin(name = "MobileSignals")
47
+ class MobileSignalsPlugin : Plugin() {
48
+ private val tag = "MobileSignalsPlugin"
49
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
50
+ private val permissionRequest = PermissionController.createRequestPermissionResultContract()
51
+ private var monitoring = false
52
+ private var receiver: BroadcastReceiver? = null
53
+
54
+ @PluginMethod
55
+ fun startMonitoring(call: PluginCall) {
56
+ if (monitoring) {
57
+ call.resolve(buildStartResult())
58
+ return
59
+ }
60
+
61
+ receiver = object : BroadcastReceiver() {
62
+ override fun onReceive(context: Context, intent: Intent) {
63
+ val action = intent.action ?: return
64
+ if (!monitoring) return
65
+ emitSignal("broadcast:$action")
66
+ if (
67
+ action == Intent.ACTION_SCREEN_ON ||
68
+ action == Intent.ACTION_SCREEN_OFF ||
69
+ action == Intent.ACTION_USER_PRESENT
70
+ ) {
71
+ emitHealthSignal(action)
72
+ }
73
+ }
74
+ }
75
+
76
+ val filter = IntentFilter().apply {
77
+ addAction(Intent.ACTION_SCREEN_ON)
78
+ addAction(Intent.ACTION_SCREEN_OFF)
79
+ addAction(Intent.ACTION_USER_PRESENT)
80
+ addAction(Intent.ACTION_BATTERY_CHANGED)
81
+ addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
82
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
83
+ addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
84
+ }
85
+ }
86
+
87
+ try {
88
+ context.registerReceiver(receiver, filter)
89
+ monitoring = true
90
+ call.resolve(buildStartResult())
91
+ if (call.getBoolean("emitInitial") ?: true) {
92
+ emitSignal("start")
93
+ emitHealthSignal("start")
94
+ }
95
+ } catch (error: Throwable) {
96
+ Log.e(tag, "Failed to start monitoring", error)
97
+ call.reject("Failed to start monitoring: ${error.message}")
98
+ }
99
+ }
100
+
101
+ @PluginMethod
102
+ fun stopMonitoring(call: PluginCall) {
103
+ stopInternal()
104
+ call.resolve(JSObject().apply {
105
+ put("stopped", true)
106
+ })
107
+ }
108
+
109
+ @PluginMethod
110
+ override fun checkPermissions(call: PluginCall) {
111
+ scope.launch {
112
+ call.resolve(resolvePermissionResult())
113
+ }
114
+ }
115
+
116
+ @PluginMethod
117
+ override fun requestPermissions(call: PluginCall) {
118
+ val sdkStatus = HealthConnectClient.getSdkStatus(context, HEALTH_CONNECT_PACKAGE)
119
+ if (sdkStatus != HealthConnectClient.SDK_AVAILABLE) {
120
+ call.resolve(buildPermissionResult(sdkStatus))
121
+ return
122
+ }
123
+
124
+ val activity = activity
125
+ if (activity == null) {
126
+ call.resolve(buildPermissionResult(
127
+ sdkStatus,
128
+ reason = "Health Connect permissions require an active Android activity."
129
+ ))
130
+ return
131
+ }
132
+
133
+ val intent = permissionRequest.createIntent(context, requiredPermissions())
134
+ startActivityForResult(call, intent, "handleHealthConnectPermissionResult")
135
+ }
136
+
137
+ @PluginMethod
138
+ fun openSettings(call: PluginCall) {
139
+ val requestedTarget = call.getString("target") ?: "app"
140
+ val (actualTarget, intent) = settingsIntentFor(requestedTarget)
141
+ try {
142
+ val starter = activity
143
+ if (starter != null) {
144
+ starter.startActivity(intent)
145
+ } else {
146
+ context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
147
+ }
148
+ call.resolve(JSObject().apply {
149
+ put("opened", true)
150
+ put("target", requestedTarget)
151
+ put("actualTarget", actualTarget)
152
+ put("reason", JSONObject.NULL)
153
+ })
154
+ } catch (error: Throwable) {
155
+ Log.e(tag, "Failed to open settings", error)
156
+ call.resolve(JSObject().apply {
157
+ put("opened", false)
158
+ put("target", requestedTarget)
159
+ put("actualTarget", actualTarget)
160
+ put("reason", "Failed to open Android settings: ${error.message}")
161
+ })
162
+ }
163
+ }
164
+
165
+ @PluginMethod
166
+ fun getSnapshot(call: PluginCall) {
167
+ val device = buildSnapshot("snapshot")
168
+ scope.launch {
169
+ val health = buildHealthSnapshot("snapshot")
170
+ call.resolve(JSObject().apply {
171
+ put("supported", true)
172
+ put("snapshot", device)
173
+ put("healthSnapshot", health)
174
+ })
175
+ }
176
+ }
177
+
178
+ @PluginMethod
179
+ fun scheduleBackgroundRefresh(call: PluginCall) {
180
+ call.resolve(JSObject().apply {
181
+ put("scheduled", false)
182
+ put("reason", "Android mobile signals use foreground monitoring and system broadcasts instead of BGTaskScheduler.")
183
+ })
184
+ }
185
+
186
+ @PluginMethod
187
+ fun cancelBackgroundRefresh(call: PluginCall) {
188
+ call.resolve(JSObject().apply {
189
+ put("cancelled", false)
190
+ put("reason", "Android mobile signals do not register a BGTaskScheduler background refresh task.")
191
+ })
192
+ }
193
+
194
+ private fun stopInternal() {
195
+ if (receiver != null) {
196
+ try {
197
+ context.unregisterReceiver(receiver)
198
+ } catch (_: Throwable) {
199
+ // best-effort cleanup
200
+ }
201
+ }
202
+ receiver = null
203
+ monitoring = false
204
+ }
205
+
206
+ private fun buildStartResult(): JSObject {
207
+ val snapshot = buildSnapshot("start")
208
+ return JSObject().apply {
209
+ put("enabled", monitoring)
210
+ put("supported", true)
211
+ put("platform", "android")
212
+ put("snapshot", snapshot)
213
+ put("healthSnapshot", JSONObject.NULL)
214
+ }
215
+ }
216
+
217
+ private fun requiredPermissions(): Set<String> {
218
+ return setOf(
219
+ HealthPermission.getReadPermission(SleepSessionRecord::class),
220
+ HealthPermission.getReadPermission(HeartRateRecord::class),
221
+ HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class),
222
+ )
223
+ }
224
+
225
+ private suspend fun resolvePermissionResult(
226
+ reason: String? = null,
227
+ ): JSObject {
228
+ val sdkStatus = HealthConnectClient.getSdkStatus(context, HEALTH_CONNECT_PACKAGE)
229
+ return if (sdkStatus != HealthConnectClient.SDK_AVAILABLE) {
230
+ buildPermissionResult(sdkStatus, reason = reason)
231
+ } else {
232
+ val client = HealthConnectClient.getOrCreate(context)
233
+ val granted = client.permissionController.getGrantedPermissions()
234
+ buildPermissionResult(sdkStatus, granted, reason)
235
+ }
236
+ }
237
+
238
+ private fun buildPermissionResult(
239
+ sdkStatus: Int,
240
+ grantedPermissions: Set<String>? = null,
241
+ reason: String? = null,
242
+ ): JSObject {
243
+ val requestedPermissions = requiredPermissions()
244
+ val sleepPermission = HealthPermission.getReadPermission(SleepSessionRecord::class)
245
+ val biometricPermissions = setOf(
246
+ HealthPermission.getReadPermission(HeartRateRecord::class),
247
+ HealthPermission.getReadPermission(HeartRateVariabilityRmssdRecord::class),
248
+ )
249
+ val granted = grantedPermissions ?: emptySet()
250
+ val sleepGranted = granted.contains(sleepPermission)
251
+ val biometricsGranted = granted.intersect(biometricPermissions).isNotEmpty()
252
+ val allGranted = requestedPermissions.all { granted.contains(it) }
253
+ val (status, canRequest, statusReason) = when (sdkStatus) {
254
+ HealthConnectClient.SDK_AVAILABLE -> {
255
+ if (allGranted) {
256
+ Triple("granted", false, reason)
257
+ } else {
258
+ Triple("not-determined", true, reason)
259
+ }
260
+ }
261
+ HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> Triple(
262
+ "not-applicable",
263
+ false,
264
+ reason ?: "Health Connect is installed but needs an update before Eliza can read health data.",
265
+ )
266
+ else -> Triple(
267
+ "not-applicable",
268
+ false,
269
+ reason ?: "Health Connect is not available on this device.",
270
+ )
271
+ }
272
+
273
+ return JSObject().apply {
274
+ put("status", status)
275
+ put("canRequest", canRequest)
276
+ if (statusReason != null) {
277
+ put("reason", statusReason)
278
+ }
279
+ put("screenTime", buildScreenTimeStatus())
280
+ put("setupActions", buildSetupActions(status, canRequest, sdkStatus))
281
+ put("permissions", JSObject().apply {
282
+ put("sleep", sleepGranted)
283
+ put("biometrics", biometricsGranted)
284
+ })
285
+ }
286
+ }
287
+
288
+ private fun computeIdleTimeSeconds(locked: Boolean, interactive: Boolean): Long? {
289
+ if (!hasUsageStatsAccess()) {
290
+ return null
291
+ }
292
+ val lastInteractionMs = try {
293
+ val usageStatsManager =
294
+ context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
295
+ val nowMs = System.currentTimeMillis()
296
+ val stats = usageStatsManager.queryUsageStats(
297
+ UsageStatsManager.INTERVAL_DAILY,
298
+ nowMs - Duration.ofDays(1).toMillis(),
299
+ nowMs,
300
+ )
301
+ stats.maxOfOrNull { it.lastTimeUsed } ?: 0L
302
+ } catch (_: Throwable) {
303
+ return null
304
+ }
305
+ if (lastInteractionMs <= 0) {
306
+ return null
307
+ }
308
+ val nowMs = System.currentTimeMillis()
309
+ val elapsedSeconds = ((nowMs - lastInteractionMs) / 1_000L).coerceAtLeast(0L)
310
+ // When the device is locked or non-interactive, treat the idle time as
311
+ // the elapsed time since last foreground use; otherwise rely on it as
312
+ // the best available proxy for input idle.
313
+ return if (locked || !interactive) {
314
+ elapsedSeconds
315
+ } else {
316
+ elapsedSeconds
317
+ }
318
+ }
319
+
320
+ private fun buildSnapshot(reason: String): JSObject {
321
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager
322
+ val keyguardManager = context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
323
+ val battery = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
324
+
325
+ val interactive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
326
+ powerManager.isInteractive
327
+ } else {
328
+ @Suppress("DEPRECATION")
329
+ powerManager.isScreenOn
330
+ }
331
+ val locked = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
332
+ keyguardManager.isDeviceLocked
333
+ } else {
334
+ @Suppress("DEPRECATION")
335
+ keyguardManager.isKeyguardLocked
336
+ }
337
+ val powerSaveMode = powerManager.isPowerSaveMode
338
+ val deviceIdle = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
339
+ powerManager.isDeviceIdleMode
340
+ } else {
341
+ false
342
+ }
343
+ val state = when {
344
+ locked -> "locked"
345
+ !interactive -> "background"
346
+ powerSaveMode || deviceIdle -> "idle"
347
+ else -> "active"
348
+ }
349
+ val idleState = when {
350
+ locked -> "locked"
351
+ !interactive || powerSaveMode || deviceIdle -> "idle"
352
+ else -> "active"
353
+ }
354
+ val batteryLevel = battery?.let {
355
+ val level = it.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
356
+ val scale = it.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
357
+ if (level >= 0 && scale > 0) {
358
+ level.toDouble() / scale.toDouble()
359
+ } else {
360
+ null
361
+ }
362
+ }
363
+ val plugged = battery?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0
364
+ val isCharging = battery?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) in setOf(
365
+ BatteryManager.BATTERY_STATUS_CHARGING,
366
+ BatteryManager.BATTERY_STATUS_FULL,
367
+ )
368
+ val idleTimeSeconds = computeIdleTimeSeconds(locked, interactive)
369
+
370
+ return JSObject().apply {
371
+ put("source", "mobile_device")
372
+ put("platform", "android")
373
+ put("state", state)
374
+ put("observedAt", System.currentTimeMillis())
375
+ put("idleState", idleState)
376
+ if (idleTimeSeconds != null) {
377
+ put("idleTimeSeconds", idleTimeSeconds)
378
+ } else {
379
+ put("idleTimeSeconds", JSONObject.NULL)
380
+ }
381
+ put("onBattery", plugged == 0)
382
+ put("metadata", JSObject().apply {
383
+ put("reason", reason)
384
+ put("isInteractive", interactive)
385
+ put("isDeviceLocked", locked)
386
+ put("isPowerSaveMode", powerSaveMode)
387
+ put("isDeviceIdleMode", deviceIdle)
388
+ put("isCharging", isCharging)
389
+ put("batteryLevel", batteryLevel)
390
+ put("screenTime", buildUsageStatsSummary())
391
+ })
392
+ }
393
+ }
394
+
395
+ private fun emitSignal(reason: String) {
396
+ if (!monitoring) return
397
+ notifyListeners("signal", buildSnapshot(reason))
398
+ }
399
+
400
+ private fun emitHealthSignal(reason: String) {
401
+ if (!monitoring) return
402
+ scope.launch {
403
+ val healthSnapshot = buildHealthSnapshot(reason)
404
+ if (monitoring) {
405
+ notifyListeners("signal", healthSnapshot)
406
+ }
407
+ }
408
+ }
409
+
410
+ private suspend fun buildHealthSnapshot(reason: String): JSObject {
411
+ val now = Instant.now()
412
+ val sdkStatus = HealthConnectClient.getSdkStatus(context, HEALTH_CONNECT_PACKAGE)
413
+ if (sdkStatus != HealthConnectClient.SDK_AVAILABLE) {
414
+ return makeHealthSnapshot(
415
+ reason = reason,
416
+ source = "health_connect",
417
+ permissions = permissions(false, false),
418
+ sleep = sleepSnapshot(false, false, null, null, null, null),
419
+ biometrics = biometricsSnapshot(null, null, null, null, null, null),
420
+ warnings = listOf("Health Connect provider unavailable or requires update"),
421
+ )
422
+ }
423
+
424
+ val client = HealthConnectClient.getOrCreate(context)
425
+ val start = now.minus(Duration.ofDays(7))
426
+ val range = TimeRangeFilter.between(start, now)
427
+ val warnings = mutableListOf<String>()
428
+
429
+ val sleepSessions = runCatching {
430
+ client.readRecords(
431
+ ReadRecordsRequest<SleepSessionRecord>(
432
+ timeRangeFilter = range,
433
+ )
434
+ ).records
435
+ }.getOrElse {
436
+ warnings.add("Sleep Connect query failed")
437
+ emptyList()
438
+ }
439
+
440
+ val latestSleep = sleepSessions.maxByOrNull { it.startTime }
441
+ val sleepIsAvailable = latestSleep != null
442
+ // Treat a sleep session as still in progress only when it ends in the
443
+ // future or very recently. Older sessions describe a completed sleep
444
+ // that has already been woken up from, and must not be reported as
445
+ // "sleeping now". Matches the iOS freshness window.
446
+ val sleepFreshnessWindow = Duration.ofMinutes(15)
447
+ val sleepIsSleeping = latestSleep != null &&
448
+ latestSleep.endTime.isAfter(now.minus(sleepFreshnessWindow))
449
+ val sleepAsleepAt = latestSleep?.startTime?.toEpochMilli()
450
+ val sleepAwakeAt = if (sleepIsSleeping) null else latestSleep?.endTime?.toEpochMilli()
451
+ val sleepDurationMinutes = latestSleep?.let {
452
+ val end = if (sleepIsSleeping) now else it.endTime
453
+ Duration.between(it.startTime, end).toMinutes()
454
+ }
455
+ val sleepStage = if (sleepIsSleeping) "sleeping" else "awake"
456
+
457
+ val heartRateSamples = runCatching {
458
+ client.readRecords(
459
+ ReadRecordsRequest(
460
+ recordType = HeartRateRecord::class,
461
+ timeRangeFilter = range,
462
+ )
463
+ ).records.flatMap { it.samples }
464
+ }.getOrElse {
465
+ warnings.add("Heart rate Connect query failed")
466
+ emptyList()
467
+ }
468
+
469
+ val hrvRecords = runCatching {
470
+ client.readRecords(
471
+ ReadRecordsRequest<HeartRateVariabilityRmssdRecord>(
472
+ timeRangeFilter = range,
473
+ )
474
+ ).records
475
+ }.getOrElse {
476
+ warnings.add("HRV Connect query failed")
477
+ emptyList()
478
+ }
479
+
480
+ val latestHeartRate = heartRateSamples.maxByOrNull { it.time }
481
+ val latestHrv = hrvRecords.maxByOrNull { it.time }
482
+ val sampleAt = listOfNotNull(
483
+ latestHeartRate?.time,
484
+ latestHrv?.time,
485
+ ).maxOrNull()?.toEpochMilli()
486
+
487
+ return makeHealthSnapshot(
488
+ reason = reason,
489
+ source = "health_connect",
490
+ permissions = permissions(
491
+ sleep = sleepIsAvailable,
492
+ biometrics = latestHeartRate != null || latestHrv != null,
493
+ ),
494
+ sleep = sleepSnapshot(
495
+ available = sleepIsAvailable,
496
+ isSleeping = sleepIsSleeping,
497
+ asleepAt = sleepAsleepAt,
498
+ awakeAt = sleepAwakeAt,
499
+ durationMinutes = sleepDurationMinutes,
500
+ stage = sleepStage,
501
+ ),
502
+ biometrics = biometricsSnapshot(
503
+ sampleAt = sampleAt,
504
+ heartRateBpm = latestHeartRate?.beatsPerMinute?.toDouble(),
505
+ restingHeartRateBpm = null,
506
+ heartRateVariabilityMs = latestHrv?.heartRateVariabilityMillis,
507
+ respiratoryRate = null,
508
+ bloodOxygenPercent = null,
509
+ ),
510
+ warnings = warnings,
511
+ )
512
+ }
513
+
514
+ private fun permissions(sleep: Boolean, biometrics: Boolean): JSObject {
515
+ return JSObject().apply {
516
+ put("sleep", sleep)
517
+ put("biometrics", biometrics)
518
+ }
519
+ }
520
+
521
+ private fun sleepSnapshot(
522
+ available: Boolean,
523
+ isSleeping: Boolean,
524
+ asleepAt: Long?,
525
+ awakeAt: Long?,
526
+ durationMinutes: Long?,
527
+ stage: String?,
528
+ ): JSObject {
529
+ return JSObject().apply {
530
+ put("available", available)
531
+ put("isSleeping", isSleeping)
532
+ put("asleepAt", asleepAt)
533
+ put("awakeAt", awakeAt)
534
+ put("durationMinutes", durationMinutes)
535
+ put("stage", stage)
536
+ }
537
+ }
538
+
539
+ private fun biometricsSnapshot(
540
+ sampleAt: Long?,
541
+ heartRateBpm: Double?,
542
+ restingHeartRateBpm: Double?,
543
+ heartRateVariabilityMs: Double?,
544
+ respiratoryRate: Double?,
545
+ bloodOxygenPercent: Double?,
546
+ ): JSObject {
547
+ return JSObject().apply {
548
+ put("sampleAt", sampleAt)
549
+ put("heartRateBpm", heartRateBpm)
550
+ put("restingHeartRateBpm", restingHeartRateBpm)
551
+ put("heartRateVariabilityMs", heartRateVariabilityMs)
552
+ put("respiratoryRate", respiratoryRate)
553
+ put("bloodOxygenPercent", bloodOxygenPercent)
554
+ }
555
+ }
556
+
557
+ private fun makeHealthSnapshot(
558
+ reason: String,
559
+ source: String,
560
+ permissions: JSObject,
561
+ sleep: JSObject,
562
+ biometrics: JSObject,
563
+ warnings: List<String>,
564
+ ): JSObject {
565
+ val battery = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
566
+ val plugged = battery?.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) ?: 0
567
+ return JSObject().apply {
568
+ put("source", "mobile_health")
569
+ put("platform", "android")
570
+ put("state", if (sleep.getBool("isSleeping") == true) "sleeping" else "idle")
571
+ put("observedAt", System.currentTimeMillis())
572
+ put("idleState", JSONObject.NULL)
573
+ put("idleTimeSeconds", JSONObject.NULL)
574
+ put("onBattery", plugged == 0)
575
+ put("healthSource", source)
576
+ put("screenTime", buildScreenTimeStatus())
577
+ put("permissions", permissions)
578
+ put("sleep", sleep)
579
+ put("biometrics", biometrics)
580
+ put("warnings", warnings)
581
+ put("metadata", JSObject().apply {
582
+ put("reason", reason)
583
+ put("healthSource", source)
584
+ })
585
+ }
586
+ }
587
+
588
+ private fun buildScreenTimeStatus(
589
+ reason: String = "Android Usage Access is required for app foreground-time summaries.",
590
+ ): JSObject {
591
+ val usageGranted = hasUsageStatsAccess()
592
+ val totalTimeForegroundMs = if (usageGranted) {
593
+ collectUsageStatsSummary().totalTimeForegroundMs
594
+ } else {
595
+ null
596
+ }
597
+ val status = if (usageGranted) "approved" else "not-determined"
598
+ val resolvedReason = if (usageGranted) {
599
+ null
600
+ } else {
601
+ reason
602
+ }
603
+ return JSObject().apply {
604
+ put("supported", true)
605
+ put("requirements", JSObject().apply {
606
+ put("entitlements", JSObject().apply {
607
+ put("familyControls", FAMILY_CONTROLS_ENTITLEMENT)
608
+ })
609
+ put("frameworks", listOf("FamilyControls", "DeviceActivity"))
610
+ put("deviceActivityReportExtension", false)
611
+ put("deviceActivityMonitorExtension", false)
612
+ put("android", JSObject().apply {
613
+ put("usageStatsPermission", PACKAGE_USAGE_STATS_PERMISSION)
614
+ put("usageAccessSettingsAction", Settings.ACTION_USAGE_ACCESS_SETTINGS)
615
+ })
616
+ })
617
+ put("entitlements", JSObject().apply {
618
+ put("familyControls", false)
619
+ })
620
+ put("provisioning", JSObject().apply {
621
+ put("satisfied", usageGranted)
622
+ put("inspected", "not-inspectable")
623
+ put("reason", resolvedReason ?: JSONObject.NULL)
624
+ })
625
+ put("authorization", JSObject().apply {
626
+ put("status", status)
627
+ put("canRequest", false)
628
+ })
629
+ put("reportAvailable", usageGranted)
630
+ put("coarseSummaryAvailable", usageGranted)
631
+ put("thresholdEventsAvailable", false)
632
+ put("rawUsageExportAvailable", false)
633
+ put("android", JSObject().apply {
634
+ put("usageAccessGranted", usageGranted)
635
+ put("packageUsageStatsPermissionDeclared", isUsageStatsPermissionDeclared())
636
+ put("canOpenUsageAccessSettings", true)
637
+ put("foregroundEventsAvailable", usageGranted)
638
+ put("totalTimeForegroundMs", totalTimeForegroundMs ?: JSONObject.NULL)
639
+ })
640
+ put("reason", resolvedReason ?: JSONObject.NULL)
641
+ }
642
+ }
643
+
644
+ private fun buildSetupActions(
645
+ healthStatus: String,
646
+ healthCanRequest: Boolean,
647
+ sdkStatus: Int,
648
+ ): JSArray {
649
+ val actions = mutableListOf<JSObject>()
650
+ actions.add(JSObject().apply {
651
+ put("id", "health_permissions")
652
+ put("label", "Health Connect")
653
+ put(
654
+ "status",
655
+ when {
656
+ sdkStatus != HealthConnectClient.SDK_AVAILABLE -> "unavailable"
657
+ healthStatus == "granted" -> "ready"
658
+ else -> "needs-action"
659
+ },
660
+ )
661
+ put("canRequest", healthCanRequest)
662
+ put("canOpenSettings", true)
663
+ put(
664
+ "settingsTarget",
665
+ if (sdkStatus == HealthConnectClient.SDK_AVAILABLE) "healthConnect" else "deviceSettings",
666
+ )
667
+ put(
668
+ "reason",
669
+ when {
670
+ sdkStatus != HealthConnectClient.SDK_AVAILABLE -> "Install or update Health Connect to sync sleep and biometric signals."
671
+ healthStatus == "granted" -> JSONObject.NULL
672
+ else -> "Grant Health Connect read access for sleep, heart rate, and HRV."
673
+ },
674
+ )
675
+ })
676
+ val usageGranted = hasUsageStatsAccess()
677
+ actions.add(JSObject().apply {
678
+ put("id", "android_usage_access")
679
+ put("label", "Usage Access")
680
+ put("status", if (usageGranted) "ready" else "needs-action")
681
+ put("canRequest", false)
682
+ put("canOpenSettings", true)
683
+ put("settingsTarget", "usageAccess")
684
+ put(
685
+ "reason",
686
+ if (usageGranted) {
687
+ JSONObject.NULL
688
+ } else {
689
+ "Enable Usage Access so LifeOps can summarize foreground app usage for wake and bed inference."
690
+ },
691
+ )
692
+ })
693
+ actions.add(JSObject().apply {
694
+ put("id", "notification_settings")
695
+ put("label", "Notifications")
696
+ put("status", "needs-action")
697
+ put("canRequest", false)
698
+ put("canOpenSettings", true)
699
+ put("settingsTarget", "notification")
700
+ put("reason", "Open notification settings if reminders or telemetry prompts are muted.")
701
+ })
702
+ actions.add(JSObject().apply {
703
+ put("id", "battery_optimization")
704
+ put("label", "Battery optimization")
705
+ put("status", "needs-action")
706
+ put("canRequest", false)
707
+ put("canOpenSettings", true)
708
+ put("settingsTarget", "batteryOptimization")
709
+ put("reason", "Disable aggressive battery optimization if background sync stops.")
710
+ })
711
+ return JSArray(actions)
712
+ }
713
+
714
+ private fun settingsIntentFor(target: String): Pair<String, Intent> {
715
+ val appDetailsIntent = Intent(
716
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
717
+ Uri.parse("package:${context.packageName}"),
718
+ )
719
+ val intent = when (target) {
720
+ "usageAccess", "screenTime" -> Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
721
+ "health", "healthConnect" -> Intent(
722
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
723
+ Uri.parse("package:$HEALTH_CONNECT_PACKAGE"),
724
+ )
725
+ "notification" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
726
+ Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(
727
+ Settings.EXTRA_APP_PACKAGE,
728
+ context.packageName,
729
+ )
730
+ } else {
731
+ appDetailsIntent
732
+ }
733
+ "batteryOptimization" -> Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
734
+ "deviceSettings" -> Intent(Settings.ACTION_SETTINGS)
735
+ else -> appDetailsIntent
736
+ }
737
+ val actualTarget = when (target) {
738
+ "usageAccess", "screenTime" -> "usageAccess"
739
+ "health", "healthConnect" -> "healthConnect"
740
+ "notification" -> "notification"
741
+ "batteryOptimization" -> "batteryOptimization"
742
+ "deviceSettings" -> "deviceSettings"
743
+ else -> "app"
744
+ }
745
+ return Pair(actualTarget, intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
746
+ }
747
+
748
+ private fun hasUsageStatsAccess(): Boolean {
749
+ val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
750
+ val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
751
+ appOps.unsafeCheckOpNoThrow(
752
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
753
+ Process.myUid(),
754
+ context.packageName,
755
+ )
756
+ } else {
757
+ @Suppress("DEPRECATION")
758
+ appOps.checkOpNoThrow(
759
+ AppOpsManager.OPSTR_GET_USAGE_STATS,
760
+ Process.myUid(),
761
+ context.packageName,
762
+ )
763
+ }
764
+ return mode == AppOpsManager.MODE_ALLOWED
765
+ }
766
+
767
+ private fun isUsageStatsPermissionDeclared(): Boolean {
768
+ return try {
769
+ val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
770
+ context.packageManager.getPackageInfo(
771
+ context.packageName,
772
+ PackageManager.PackageInfoFlags.of(PackageManager.GET_PERMISSIONS.toLong()),
773
+ )
774
+ } else {
775
+ @Suppress("DEPRECATION")
776
+ context.packageManager.getPackageInfo(
777
+ context.packageName,
778
+ PackageManager.GET_PERMISSIONS,
779
+ )
780
+ }
781
+ packageInfo.requestedPermissions?.contains(PACKAGE_USAGE_STATS_PERMISSION) == true
782
+ } catch (_: PackageManager.NameNotFoundException) {
783
+ false
784
+ }
785
+ }
786
+
787
+ private data class UsageStatsSummary(
788
+ val totalTimeForegroundMs: Long,
789
+ val topApps: List<JSObject>,
790
+ )
791
+
792
+ private fun collectUsageStatsSummary(): UsageStatsSummary {
793
+ if (!hasUsageStatsAccess()) {
794
+ return UsageStatsSummary(0, emptyList())
795
+ }
796
+ val usageStatsManager =
797
+ context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
798
+ val nowMs = System.currentTimeMillis()
799
+ val stats = usageStatsManager.queryUsageStats(
800
+ UsageStatsManager.INTERVAL_DAILY,
801
+ nowMs - Duration.ofDays(1).toMillis(),
802
+ nowMs,
803
+ )
804
+ val topApps = stats
805
+ .asSequence()
806
+ .filter { it.totalTimeInForeground > 0 }
807
+ .sortedByDescending { it.totalTimeInForeground }
808
+ .take(10)
809
+ .map { usage ->
810
+ JSObject().apply {
811
+ put("packageName", usage.packageName)
812
+ put("totalTimeForegroundMs", usage.totalTimeInForeground)
813
+ put("lastTimeUsed", usage.lastTimeUsed)
814
+ }
815
+ }
816
+ .toList()
817
+ val totalTimeForegroundMs = stats.sumOf { it.totalTimeInForeground }
818
+ return UsageStatsSummary(totalTimeForegroundMs, topApps)
819
+ }
820
+
821
+ private fun buildUsageStatsSummary(): JSObject {
822
+ val granted = hasUsageStatsAccess()
823
+ val summary = if (granted) {
824
+ collectUsageStatsSummary()
825
+ } else {
826
+ UsageStatsSummary(0, emptyList())
827
+ }
828
+ return JSObject().apply {
829
+ put("granted", granted)
830
+ put("permissionDeclared", isUsageStatsPermissionDeclared())
831
+ put("windowHours", 24)
832
+ put("totalTimeForegroundMs", if (granted) summary.totalTimeForegroundMs else JSONObject.NULL)
833
+ put("topApps", JSArray(summary.topApps))
834
+ }
835
+ }
836
+
837
+ private fun handleOnDestroyInternal() {
838
+ stopInternal()
839
+ super.handleOnDestroy()
840
+ }
841
+
842
+ @ActivityCallback
843
+ private fun handleHealthConnectPermissionResult(call: PluginCall, result: ActivityResult) {
844
+ scope.launch {
845
+ val reason = if (result.resultCode != android.app.Activity.RESULT_OK) {
846
+ "Health Connect permissions were not granted."
847
+ } else {
848
+ null
849
+ }
850
+ call.resolve(resolvePermissionResult(reason))
851
+ }
852
+ }
853
+
854
+ override fun handleOnDestroy() {
855
+ handleOnDestroyInternal()
856
+ }
857
+ }