Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ credentials.json
!.env.*.example

# Build output
modules/breathly-health-connect/android/build/
*.ipa
*.apk
*.aab
3 changes: 2 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
"expo-build-properties",
{
"android": {
"enableProguardInReleaseBuilds": true
"enableProguardInReleaseBuilds": true,
"minSdkVersion": 26
}
}
],
Expand Down
19 changes: 19 additions & 0 deletions modules/breathly-health-connect/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}

group = 'com.mmazzarolo.breathly.healthconnect'
version = '1.0.0'

android {
namespace 'com.mmazzarolo.breathly.healthconnect'
defaultConfig {
versionCode 1
versionName '1.0.0'
}
}

dependencies {
implementation 'androidx.health.connect:connect-client:1.1.0'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.health.WRITE_MINDFULNESS" />

<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>

<application>
<activity
android:name=".HealthConnectPermissionRationaleActivity"
android:exported="true"
android:excludeFromRecents="true"
>
<intent-filter>
<action
android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE"
/>
</intent-filter>
</activity>

<activity-alias
android:name="ViewHealthPermissionsUsageActivity"
android:exported="true"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE"
android:targetActivity=".HealthConnectPermissionRationaleActivity"
>
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.mmazzarolo.breathly.healthconnect

import android.os.Build
import androidx.health.connect.client.HealthConnectClient
import androidx.health.connect.client.HealthConnectFeatures
import androidx.health.connect.client.feature.ExperimentalMindfulnessSessionApi
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.MindfulnessSessionRecord
import androidx.health.connect.client.records.metadata.Device
import androidx.health.connect.client.records.metadata.Metadata
import expo.modules.kotlin.activityresult.AppContextActivityResultLauncher
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.functions.Coroutine
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.time.Instant
import java.time.ZoneId

@OptIn(ExperimentalMindfulnessSessionApi::class)
class BreathlyHealthConnectModule : Module() {
private val context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()

private val writePermission =
HealthPermission.getWritePermission(MindfulnessSessionRecord::class)

override fun definition() = ModuleDefinition {
Name("BreathlyHealthConnect")

lateinit var permissionLauncher:
AppContextActivityResultLauncher<HealthPermissionRequestInput, Set<String>>

RegisterActivityContracts {
permissionLauncher = registerForActivityResult(HealthPermissionRequestContract())
}

AsyncFunction("getStatusAsync") Coroutine { ->
getStatus()
}

AsyncFunction("requestPermissionAsync") Coroutine { ->
if (getStatus() != STATUS_PERMISSION_REQUIRED) return@Coroutine false

val grantedPermissions = permissionLauncher.launch(
HealthPermissionRequestInput(arrayListOf(writePermission))
)
grantedPermissions.contains(writePermission)
}

AsyncFunction("writeBreathingSessionAsync") Coroutine { startTimeMs: Double, endTimeMs: Double, clientRecordId: String ->
val status = getStatus()
if (status != STATUS_AUTHORIZED) return@Coroutine status
if (
!startTimeMs.isFinite() ||
!endTimeMs.isFinite() ||
startTimeMs < 0 ||
endTimeMs <= startTimeMs ||
clientRecordId.isBlank()
) {
return@Coroutine STATUS_ERROR
}

val startTime = Instant.ofEpochMilli(startTimeMs.toLong())
val endTime = Instant.ofEpochMilli(endTimeMs.toLong())
val zoneRules = ZoneId.systemDefault().rules
val record = MindfulnessSessionRecord(
startTime = startTime,
startZoneOffset = zoneRules.getOffset(startTime),
endTime = endTime,
endZoneOffset = zoneRules.getOffset(endTime),
mindfulnessSessionType = MindfulnessSessionRecord.MINDFULNESS_SESSION_TYPE_BREATHING,
title = context.getString(R.string.health_connect_session_title),
metadata = Metadata.activelyRecorded(
device = Device(
type = Device.TYPE_PHONE,
manufacturer = Build.MANUFACTURER,
model = Build.MODEL,
),
clientRecordId = clientRecordId,
clientRecordVersion = 1L,
),
)
try {
healthConnectClient().insertRecords(listOf(record))
STATUS_SAVED
} catch (_: SecurityException) {
STATUS_PERMISSION_REQUIRED
}
}
}

private suspend fun getStatus(): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return STATUS_UNAVAILABLE

when (HealthConnectClient.getSdkStatus(context)) {
HealthConnectClient.SDK_UNAVAILABLE -> return STATUS_UNAVAILABLE
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED -> return STATUS_UPDATE_REQUIRED
}

val client = healthConnectClient()
if (
client.features.getFeatureStatus(HealthConnectFeatures.FEATURE_MINDFULNESS_SESSION) !=
HealthConnectFeatures.FEATURE_STATUS_AVAILABLE
) {
return STATUS_UNSUPPORTED
}

return if (client.permissionController.getGrantedPermissions().contains(writePermission)) {
STATUS_AUTHORIZED
} else {
STATUS_PERMISSION_REQUIRED
}
}

private fun healthConnectClient() = HealthConnectClient.getOrCreate(context)

private companion object {
const val STATUS_UNAVAILABLE = "unavailable"
const val STATUS_UPDATE_REQUIRED = "updateRequired"
const val STATUS_UNSUPPORTED = "unsupported"
const val STATUS_PERMISSION_REQUIRED = "permissionRequired"
const val STATUS_AUTHORIZED = "authorized"
const val STATUS_SAVED = "saved"
const val STATUS_ERROR = "error"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.mmazzarolo.breathly.healthconnect

import android.app.Activity
import android.os.Bundle
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView

class HealthConnectPermissionRationaleActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val density = resources.displayMetrics.density
val padding = (24 * density).toInt()
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(padding, padding, padding, padding)
}

content.addView(
TextView(this).apply {
text = getString(R.string.health_connect_rationale_title)
textSize = 24f
},
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
content.addView(
TextView(this).apply {
text = getString(R.string.health_connect_rationale_message)
textSize = 16f
setPadding(0, (16 * density).toInt(), 0, (24 * density).toInt())
},
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
content.addView(
TextView(this).apply {
text = getString(R.string.health_connect_privacy_policy_title)
textSize = 20f
},
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
content.addView(
TextView(this).apply {
text = getString(R.string.health_connect_privacy_policy)
textSize = 16f
setPadding(0, (16 * density).toInt(), 0, (24 * density).toInt())
},
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
content.addView(
Button(this).apply {
text = getString(R.string.health_connect_rationale_close)
setOnClickListener { finish() }
},
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)

setContentView(
ScrollView(this).apply { addView(content) },
ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.mmazzarolo.breathly.healthconnect

import android.content.Context
import android.content.Intent
import androidx.health.connect.client.PermissionController
import expo.modules.kotlin.activityresult.AppContextActivityResultContract
import java.io.Serializable

internal data class HealthPermissionRequestInput(
val permissions: ArrayList<String>
) : Serializable

internal class HealthPermissionRequestContract :
AppContextActivityResultContract<HealthPermissionRequestInput, Set<String>> {
private val contract = PermissionController.createRequestPermissionResultContract()

override fun createIntent(context: Context, input: HealthPermissionRequestInput): Intent =
contract.createIntent(context, input.permissions.toSet())

override fun parseResult(
input: HealthPermissionRequestInput,
resultCode: Int,
intent: Intent?
): Set<String> = contract.parseResult(resultCode, intent)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<resources>
<string name="health_connect_session_title">Breathing exercise</string>
<string name="health_connect_rationale_title">Breathly and Health Connect</string>
<string name="health_connect_rationale_message">When you enable Health Connect, Breathly writes the start time, end time, and breathing type of completed exercises. Breathly does not read health data. You can revoke access at any time in Health Connect.</string>
<string name="health_connect_privacy_policy_title">Privacy policy</string>
<string name="health_connect_privacy_policy">Breathly writes completed breathing-session times and the breathing activity type to Health Connect only when you enable this option. Breathly does not read Health Connect data and does not send this data to a Breathly server. Health Connect controls access to this data. You can revoke Breathly’s access or delete its records at any time in Health Connect.</string>
<string name="health_connect_rationale_close">Close</string>
</resources>
6 changes: 6 additions & 0 deletions modules/breathly-health-connect/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["com.mmazzarolo.breathly.healthconnect.BreathlyHealthConnectModule"]
}
}
Loading