tauri-plugin-healthkit 0.1.3

A Tauri plugin for accessing HealthKit (iOS) and Health Connect (Android)
package life.thephage.healthkit

import android.app.Activity
import android.content.Intent
import androidx.core.net.toUri
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.records.*
import androidx.health.connect.client.request.AggregateGroupByDurationRequest
import androidx.health.connect.client.request.ReadRecordsRequest
import androidx.health.connect.client.time.TimeRangeFilter
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.annotation.ActivityCallback
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter

// ============================================================================
// Args Classes
// ============================================================================

@InvokeArg
class PermissionArgs {
    var permissions: List<String> = emptyList()
}

@InvokeArg
class StepArgs {
    var sinceDate: String = ""
    var untilDate: String = ""
}

@InvokeArg
class SleepDataArgs {
    var sinceDate: String = ""
    var untilDate: String = ""
}

@InvokeArg
class WeightDataArgs {
    var sinceDate: String = ""
    var untilDate: String = ""
}

private const val HEALTH_CONNECT = "HEALTH_CONNECT"
private const val HEALTH_CONNECT_PACKAGE_NAME = "com.google.android.apps.healthdata"
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

@TauriPlugin
class HealthConnectPlugin(private val activity: Activity) : Plugin(activity) {
    private val appContext = activity.applicationContext
    private var client: HealthConnectClient? = null

    private fun getClientStatus(): Int {
        return HealthConnectClient.getSdkStatus(appContext, HEALTH_CONNECT_PACKAGE_NAME)
    }

    private fun isAvailable(): Boolean {
        return getClientStatus() == HealthConnectClient.SDK_AVAILABLE
    }

    private fun getClient(): HealthConnectClient {
        if (client == null) {
            if (!isAvailable()) {
                throw IllegalStateException("Health Connect is not available.")
            }
            client = HealthConnectClient.getOrCreate(appContext)
        }
        return client!!
    }

    // ========================================================================
    // Status & Permissions
    // ========================================================================

    @Command
    fun getHealthStatus(invoke: Invoke) {
        val ret = JSObject()

        val status = getClientStatus()
        val statusStr = when (status) {
            HealthConnectClient.SDK_AVAILABLE -> "AVAILABLE"
            HealthConnectClient.SDK_UNAVAILABLE -> "UNAVAILABLE"
            HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> "UPDATE_REQUIRED"
            else -> "UNKNOWN"
        }
        val isAvailable = status == HealthConnectClient.SDK_AVAILABLE

        ret.put("platform", HEALTH_CONNECT)
        ret.put("status", statusStr)
        ret.put("isAvailable", isAvailable)
        invoke.resolve(ret)
    }

    /**
     * 全スコープの現在の権限ステータスを返す。
     * 各スコープに対して "granted" または "denied" を返す。
     */
    @Command
    override fun checkPermissions(invoke: Invoke) {
        getPermissions(invoke)
    }

    /**
     * 指定されたスコープの権限をリクエストするダイアログを表示する。
     * ダイアログが閉じた後、全スコープの最新の権限ステータスを返す。
     */
    @Command
    override fun requestPermissions(invoke: Invoke) {
        val args = invoke.parseArgs(PermissionArgs::class.java)
        val permissions = HealthPermissionUtil.toFullPermissions(args.permissions)

        val intent = Intent(activity, HealthConnectActivity::class.java)
        intent.putExtra(
            HealthConnectActivity.PERMISSIONS_EXTRA,
            permissions.toTypedArray()
        )
        startActivityForResult(invoke, intent, "requestPermissionsResult")
    }

    /**
     * 権限リクエストダイアログが閉じた後に呼ばれるコールバック。
     * 最新の権限ステータスを取得してinvokeに返す。
     */
    @ActivityCallback
    private fun requestPermissionsResult(invoke: Invoke, result: androidx.activity.result.ActivityResult) {
        if (result.resultCode == Activity.RESULT_OK || result.resultCode == Activity.RESULT_CANCELED) {
            getPermissions(invoke)
        } else {
            invoke.reject("Permission request failed with resultCode: ${result.resultCode}")
        }
    }

    /**
     * 現在の権限ステータスを取得してinvokeに返す共通処理。
     * Health Connectから許可済み権限を取得し、全スコープのステータスをレスポンスとして返す。
     */
    private fun getPermissions(invoke: Invoke) {
        scope.launch {
            try {
                val grantedPermissions = withContext(Dispatchers.IO) {
                    getClient().permissionController.getGrantedPermissions()
                }
                invoke.resolve(formatPermissionsResponse(grantedPermissions))
            } catch (e: Exception) {
                invoke.reject("Failed to check permissions: ${e.message}")
            }
        }
    }

    /**
     * 許可済み権限のセットから、全スコープのステータスをJSON形式で組み立てる。
     * 各スコープに対して "granted"(許可済み)または "denied"(未許可)を設定する。
     */
    private fun formatPermissionsResponse(grantedPermissions: Set<String>): JSObject {
        val permissionsArray = JSONArray()
        HealthPermissionUtil.allScopes.forEach { scope ->
            val fullPermission = HealthPermissionUtil.toFullPermission(scope)
            val state = if (fullPermission != null && grantedPermissions.contains(fullPermission)) {
                "granted"
            } else {
                "denied"
            }
            val item = JSONObject()
            item.put("scope", scope)
            item.put("state", state)
            permissionsArray.put(item)
        }
        val ret = JSObject()
        ret.put("permissions", permissionsArray)
        return ret
    }

    // ========================================================================
    // Settings & Store
    // ========================================================================

    /**
     * Health Connect の設定画面を開く。
     * ユーザーがアプリごとの権限を確認・変更できる画面に遷移する。
     */
    @Command
    fun openSettings(invoke: Invoke) {
        val intent = Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS)
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        appContext.startActivity(intent)
        invoke.resolve()
    }

    /**
     * Google Play ストアの Health Connect ページを開く。
     * Health Connect がインストールされていない、またはアップデートが必要な場合に使用する。
     */
    @Command
    fun openPlayStore(invoke: Invoke) {
        val intent = Intent(Intent.ACTION_VIEW).apply {
            data = "market://details?id=${HEALTH_CONNECT_PACKAGE_NAME}".toUri()
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        appContext.startActivity(intent)
        invoke.resolve()
    }

    // ========================================================================
    // Step Count Data
    // ========================================================================

    /**
     * Get raw step data with 5-minute intervals
     */
    @Command
    fun getStep(invoke: Invoke) {
        val args = invoke.parseArgs(StepArgs::class.java)

        if (args.sinceDate.isEmpty() || args.untilDate.isEmpty()) {
            invoke.reject("Invalid date parameters")
            return
        }

        scope.launch {
            try {
                val startInstant = Instant.parse(args.sinceDate)
                val endInstant = Instant.parse(args.untilDate)

                val response = withContext(Dispatchers.IO) {
                    getClient().aggregateGroupByDuration(
                        AggregateGroupByDurationRequest(
                            metrics = setOf(StepsRecord.COUNT_TOTAL),
                            timeRangeFilter = TimeRangeFilter.between(startInstant, endInstant),
                            timeRangeSlicer = Duration.ofMinutes(5)
                        )
                    )
                }

                val recordsArray = JSONArray()
                response.sortedByDescending { it.startTime }.forEach { bucket ->
                    val steps = bucket.result[StepsRecord.COUNT_TOTAL] ?: 0L
                    val offsetDateTime = bucket.startTime
                        .atZone(ZoneId.systemDefault())
                        .toOffsetDateTime()

                    val recordObj = JSONObject()
                    recordObj.put("recordedAt", offsetDateTime.toString())
                    recordObj.put("value", steps)
                    recordsArray.put(recordObj)
                }

                val ret = JSObject()
                ret.put("records", recordsArray)
                invoke.resolve(ret)
            } catch (e: Exception) {
                invoke.reject("Failed to get raw step data: ${e.message}")
            }
        }
    }

    // ========================================================================
    // Sleep Data
    // ========================================================================

    /**
     * Get sleep data within time range
     */
    @Command
    fun getSleep(invoke: Invoke) {
        val args = invoke.parseArgs(SleepDataArgs::class.java)
        scope.launch {
            try {
                val startTime = Instant.parse(args.sinceDate)
                val endTime = Instant.parse(args.untilDate)

                val response = withContext(Dispatchers.IO) {
                    getClient().readRecords(
                        ReadRecordsRequest(
                            recordType = SleepSessionRecord::class,
                            timeRangeFilter = TimeRangeFilter.between(startTime, endTime)
                        )
                    )
                }

                val recordsArray = JSONArray()

                // Flatten sleep stages into individual records
                response.records.forEach { session ->
                    if (session.stages.isEmpty()) {
                        // If no stages, create a single record with "asleep" type
                        val recordObj = JSONObject()
                        recordObj.put("startTime", formatInstant(session.startTime))
                        recordObj.put("endTime", formatInstant(session.endTime))
                        recordObj.put("sleepType", "asleep")
                        recordsArray.put(recordObj)
                    } else {
                        // Each stage becomes a separate record
                        session.stages.forEach { stage ->
                            val recordObj = JSONObject()
                            recordObj.put("startTime", formatInstant(stage.startTime))
                            recordObj.put("endTime", formatInstant(stage.endTime))
                            recordObj.put("sleepType", mapSleepType(stage.stage))
                            recordsArray.put(recordObj)
                        }
                    }
                }

                val ret = JSObject()
                ret.put("records", recordsArray)
                invoke.resolve(ret)
            } catch (e: Exception) {
                invoke.reject("Failed to get sleep data: ${e.message}")
            }
        }
    }

    private fun mapSleepType(stage: Int): String {
        return when (stage) {
            SleepSessionRecord.STAGE_TYPE_AWAKE -> "awake"
            SleepSessionRecord.STAGE_TYPE_SLEEPING -> "asleep"
            SleepSessionRecord.STAGE_TYPE_OUT_OF_BED -> "wake"
            SleepSessionRecord.STAGE_TYPE_LIGHT -> "light"
            SleepSessionRecord.STAGE_TYPE_DEEP -> "deep"
            SleepSessionRecord.STAGE_TYPE_REM -> "rem"
            else -> "unknown"
        }
    }

    // ========================================================================
    // Weight Data
    // ========================================================================

    /**
     * Get weight data within time range
     */
    @Command
    fun getWeight(invoke: Invoke) {
        val args = invoke.parseArgs(WeightDataArgs::class.java)
        scope.launch {
            try {
                val startTime = Instant.parse(args.sinceDate)
                val endTime = Instant.parse(args.untilDate)

                val response = withContext(Dispatchers.IO) {
                    getClient().readRecords(
                        ReadRecordsRequest(
                            recordType = WeightRecord::class,
                            timeRangeFilter = TimeRangeFilter.between(startTime, endTime)
                        )
                    )
                }

                val recordsArray = JSONArray()
                response.records.forEach { record ->
                    val recordObj = JSONObject()
                    recordObj.put("recordedAt", formatInstant(record.time))
                    recordObj.put("weight", record.weight.inKilograms)
                    recordsArray.put(recordObj)
                }

                val ret = JSObject()
                ret.put("records", recordsArray)
                invoke.resolve(ret)
            } catch (e: Exception) {
                invoke.reject("Failed to get weight data: ${e.message}")
            }
        }
    }

    // ========================================================================
    // Utilities
    // ========================================================================

    private fun formatInstant(instant: Instant): String {
        return DateTimeFormatter.ISO_INSTANT.format(instant)
    }
}