I've been building a period tracking app for myself with the help of Health Connect. Last week, I published the first blog post where I explained how to set up Health Connect and asked the user to give permission to use their data.
I initially thought I'd write one blog post about building this app - now, it's turning into (at least) three. But I have to say that I've enjoyed this process a lot.
This blog post will look into reading and writing data with Health Connect. There will be one more blog post about updating and deleting data before we get to the point where the app looks like in this video:
Reading and Writing Data
Before going into code, I want to mention one handy app: Health Connect Toolbox. With its help, you can write and read data from Health Connect, which helps debug issues like if the data was saved at all. Trust me, I know.
Health Connect Toolbox also helps with the first task we're looking into: Reading records. If you don't have any other app writing that particular type of data to Health Connect, you can use the Health Connect Toolbox to write data and then see that data in your own app.
Now, we have the permissions to read and write data from/to Health Connect, so we can add the implementation for those actions.
Read Records
The first action we're implementing is reading data. Let's start from the closest point to Health Connect: HealthConnectManager
, which we defined in the previous blog post. We need a function to read data from Health Connect:
// HealthConnectManager.kt
suspend fun readMenstruationRecords():
List<MenstruationPeriodRecord> {
val request = ReadRecordsRequest(
recordType = MenstruationPeriodRecord::class,
timeRangeFilter =
TimeRangeFilter.after(
LocalDateTime.now().minusMonths(12)
),
)
val response = healthConnectClient.readRecords(request)
return response.records
}
First, we define the request, which is ReadRecordsRequest
. The type is MenstruationPeriodRecord::class
, as we want to read menstruation period data from Health Connect. We also add a time range filter going back 12 months from today. This end date will probably be changed while developing this application, but for now, let's stick with data from the past 12 months.
Next, we call readRecords
from the Health Connect Client. It returns a response with records as one of its values, and we want to return those from this function.
In the view model, we need to make a few more adjustments. Basically, we want to add a wrapper that checks if the app has all permissions to try to read (or write) data. We also need to add the actual data reading function, as well as store the read data to a variable in the view model.
Let's start with the wrapper and add the function:
// PeriodViewModel.kt
private suspend fun tryWithPermissionsCheck(
block: suspend () -> Unit
) {
permissionsGranted =
healthConnectManager.hasAllPermissions()
try {
if (permissionsGranted) {
block()
}
} catch (remoteException: RemoteException) {
Log.e("Error getting records:", "${remoteException.message}")
} catch (securityException: SecurityException) {
Log.e("Error getting records:", "${securityException.message}")
} catch (ioException: IOException) {
Log.e("Error getting records:", "${ioException.message}")
} catch (illegalStateException: IllegalStateException) {
Log.e("Error getting records:", "${illegalStateException.message}")
} catch (e: Exception) {
Log.e("Error getting records:", "${e.message}")
}
}
It's a suspend function that takes in a block that will be executed if the app has permissions. First, we check the permission situation from the Health Connect Manager and store the value to the permissionsGranted
variable we defined in the previous blog post.
Then, if the permissions are granted, we try to execute the block we're passing in. There is also a somewhat granular exception handling - for this app, for now, it's just logging the errors, but this would be the place to handle errors in other ways, too.
Now that we have permission checks in place, we can read the Health Connect records and store them in the view model.
// PeriodViewModel.kt
var periods by mutableStateOf<List<MenstruationPeriodRecord>>(emptyList())
fun getInitialRecords() {
viewModelScope.launch {
tryWithPermissionsCheck {
getPeriodRecords()
}
}
}
private fun getPeriodRecords() {
viewModelScope.launch {
periods = healthConnectManager.readMenstruationRecords()
}
}
In this block of code, we first define the periods
-variable as a mutable state and list of MenstruationPeriodRecord
. Then we define the getInitialRecords
-function, which uses tryWithPermissionsCheck
on calling getPeriodRecords
, which calls readMenstruationRecords
from the Health Connect Manager. Finally, it sets the records returned by this function to the periods
-variable.
The final piece in the puzzle is the UI layer. The first bigger change is to change the type of data we're passing around: from the Period
-data class we defined for the initial UI to MenstruationPeriodRecord
. You can see all the changes required from the final commit, linked at the end of the blog post.
In the previous post, we left a couple of blocks with TODO-comments. Let's replace them with the actual data reading functions:
// MainScreen.kt
val permissionsLauncher =
rememberLauncherForActivityResult(
viewModel.permissionsLauncher
) {
viewModel.getInitialRecords()
}
LaunchedEffect(Unit) {
if (viewModel.permissionsGranted) {
viewModel.getInitialRecords()
} else {
permissionsLauncher.launch(PERMISSIONS)
}
}
We call the getInitialRecords
from the view model in these two places. What happens here is that when the user opens this activity, the LaunchedEffect
is run. The app fetches the data from Health Connect if permissions are granted. If they're not, it calls the launch
-method from the permissionsLauncher
we defined to ask for permissions from the user. When the permissions are granted, it calls the getInitialRecords
.
We also refactor the code to use periods
from the view model instead of the hard-coded periods
.
With these changes, we actually get data from the Health Connect! If you don't have any other apps writing this type of data, adding some records with the Health Connect Toolbox is a way to test that everything works as intended.
This is enough if you want to just read data from other apps. But if you want to add and modify data, then keep reading.
Write Records
For writing data to Health Connect, we'll take similar steps as when reading data: First, the Health Connect Manager, then the view model, and finally, the UI layer.
Let's start with the Health Connect Manager and add a function to write records:
// HealthConnectManager.kt
suspend fun writeMenstruationRecords(
menstruationPeriodRecord: MenstruationPeriodRecord
) {
val records = listOf(menstruationPeriodRecord)
try {
healthConnectClient.insertRecords(records)
Toast.makeText(context, "Successfully insert records", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(context, e.message.toString(), Toast.LENGTH_SHORT).show()
Log.e("Error", "Message: ${e.message}")
}
}
writeMenstruationRecords
-function takes in the record to write. As Health Connect Client's insertRecords
takes in a list of records, we wrap our menstruationPeriodRecord
in a list.
Within a try - catch
block, we call the insertRecords
-method, and if everything is successful, we'll show a toast message to the user. If there is an error, we show a toast message with the error and then log the error.
In the view model, we use the tryWithPermissionsCheck
-function to check the permissions before writing data:
// PeriodViewModel.kt
fun writeMenstruationRecord(
menstruationPeriodRecord: MenstruationPeriodRecord
) {
viewModelScope.launch {
tryWithPermissionsCheck {
healthConnectManager.writeMenstruationRecords(
menstruationPeriodRecord
)
getPeriodRecords()
}
}
}
This function takes in the MenstruationPeriodRecord
and writes it to Health Connect with the Health Connect Manager's writeMenstruationRecords
. After that, it re-fetches the records from Health Connect to update the UI. I haven't found a way to observe the data yet, so it needs to be updated manually.
The next thing is to modify the UI. The date range picker dialog already takes in a function that is called when the user presses "Save" in the dialog. Let's edit it to allow writing data:
// DateRangePickerDialog.kt
TextButton(onClick = {
val startTime =
if (dateRangePickerState.selectedStartDateMillis != null) {
Instant.ofEpochMilli(
dateRangePickerState.selectedStartDateMillis!!
)
} else
Instant.now()
val endTime =
if (dateRangePickerState.selectedEndDateMillis != null) {
Instant.ofEpochMilli(
dateRangePickerState.selectedEndDateMillis!!
)
} else
startTime.plusSeconds(60) // This needs to be different from start time, but within the same day to work as intended on my code.
...
}) { ... }
Let's start unpacking the code. First, we need the start date and end date, and as Health Connect records take in Instant
, we need to convert the milliseconds dateRangePickerState
uses to Instant
. We also need to check if the values are null - and if they are, then we set the start date to Instant.now()
and the end date to be 60 seconds from the start date. This 60-second difference is because, for Health Connect menstruation period records, the end date can't be null, but it can't be equal to the start time either.
The app allows the user to not set the end date because if the menstruation is ongoing, there's no way to know when it ends. So, I've modified the code to check if the end time is the same date as the start time, and if so, then it's treated as null elsewhere in the code - like in the visualization.
Now, we have enough data to actually save the new period. The DateRangePickerDialog
takes in a selectedPeriod
, which is either an empty record with default values, or a record we're updating. We define this updated
record with the start and end times inside the onClick
-handler and call the onConfirm
-function we pass to DateRangePickerDialog
:
// DateRangePickerDialog.kt
TextButton(onClick = {
...
val updated = MenstruationPeriodRecord(
startTime = startTime,
endTime = endTime,
startZoneOffset = selectedPeriod.startZoneOffset,
endZoneOffset = selectedPeriod.endZoneOffset,
metadata = selectedPeriod.metadata,
)
onConfirm(updated)
}) { ... }
Then, in MainScreen
, we add a new function, saveMenstruationPeriod
that calls the view model's function by the same name. We also modify the onConfirm
-function a bit:
// MainScreen.kt
fun saveMenstruationPeriod(
menstruationPeriodRecord: MenstruationPeriodRecord
) {
viewModel.writeMenstruationRecord(
menstruationPeriodRecord
)
}
....
DateRangePickerDialog( ... ) { updatedSelectedPeriod ->
showDatePickerDialog = false
selectedPeriod = updatedSelectedPeriod
saveMenstruationPeriod(updatedSelectedPeriod)
}
Okay, now we've allowed the user to add new records. But what if the user inserts incorrect data and wants to edit the dates? They need to be able to edit the records, which we'll look at in the next blog post.
Wrapping Up
In this blog post, we've looked at how to read and write data with Health Connect. You can find all the changes made to the code in the context of this blog post in this commit: Read and write data from/to Health Connect.
In next week's blog post, we'll look into how to update and delete Health Connect data. So stay tuned for that!