@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.
- package/ElizaosCapacitorMobileSignals.podspec +18 -0
- package/android/build.gradle +53 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/ai/eliza/plugins/mobilesignals/MobileSignalsPlugin.kt +857 -0
- package/dist/esm/definitions.d.ts +162 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/web.d.ts +29 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +272 -0
- package/dist/esm/web.test.d.ts +2 -0
- package/dist/esm/web.test.d.ts.map +1 -0
- package/dist/esm/web.test.js +75 -0
- package/dist/plugin.cjs.js +285 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +288 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/MobileSignalsPlugin/MobileSignalsPlugin.swift +968 -0
- package/ios/Sources/MobileSignalsPlugin/ScreenTimeSupport.swift +188 -0
- package/package.json +84 -0
- package/scripts/validate-ios-screen-time.mjs +320 -0
|
@@ -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
|
+
}
|