Add local storage of heart rate values.

- Create HeartRate entity, DAO, and InsertOrUpdateTransaction
- Add DB migration to version 24
- Add support to AppRepository
This commit is contained in:
Robert Buessow 2023-04-17 22:19:03 +02:00
parent 39dd25af07
commit 4dd5bf2d03
17 changed files with 4365 additions and 20 deletions

View file

@ -7,6 +7,7 @@ plugins {
} }
apply from: "${project.rootDir}/core/main/android_dependencies.gradle" apply from: "${project.rootDir}/core/main/android_dependencies.gradle"
apply from: "${project.rootDir}/core/main/test_dependencies.gradle"
android { android {
@ -30,4 +31,4 @@ dependencies {
allOpen { allOpen {
// allows mocking for classes w/o directly opening them for release builds // allows mocking for classes w/o directly opening them for release builds
annotation 'info.nightscout.database.annotations.DbOpenForTesting' annotation 'info.nightscout.database.annotations.DbOpenForTesting'
} }

View file

@ -0,0 +1,44 @@
package info.nightscout.database.entities
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import info.nightscout.database.entities.embedments.InterfaceIDs
import info.nightscout.database.entities.interfaces.DBEntryWithTimeAndDuration
import info.nightscout.database.entities.interfaces.TraceableDBEntry
import java.util.*
/** Heart rate values measured by a user smart watch or the like. */
@Entity(
tableName = TABLE_HEART_RATE,
indices = [Index("id"), Index("timestamp")]
)
data class HeartRate(
@PrimaryKey(autoGenerate = true)
override var id: Long = 0,
/** Duration milliseconds */
override var duration: Long,
/** Milliseconds since the epoch. End of the sampling period, i.e. the value is
* sampled from timestamp-duration to timestamp. */
override var timestamp: Long,
var beatsPerMinute: Double,
/** Source device that measured the heart rate. */
var device: String,
override var utcOffset: Long = TimeZone.getDefault().getOffset(timestamp).toLong(),
override var version: Int = 0,
override var dateCreated: Long = -1,
override var isValid: Boolean = true,
override var referenceId: Long? = null,
@Embedded
override var interfaceIDs_backing: InterfaceIDs? = null
) : TraceableDBEntry, DBEntryWithTimeAndDuration {
fun contentEqualsTo(other: HeartRate): Boolean {
return this === other || (
duration == other.duration &&
timestamp == other.timestamp &&
beatsPerMinute == other.beatsPerMinute &&
isValid == other.isValid)
}
}

View file

@ -8,6 +8,7 @@ const val TABLE_CARBS = "carbs"
const val TABLE_DEVICE_STATUS = "deviceStatus" const val TABLE_DEVICE_STATUS = "deviceStatus"
const val TABLE_EFFECTIVE_PROFILE_SWITCHES = "effectiveProfileSwitches" const val TABLE_EFFECTIVE_PROFILE_SWITCHES = "effectiveProfileSwitches"
const val TABLE_EXTENDED_BOLUSES = "extendedBoluses" const val TABLE_EXTENDED_BOLUSES = "extendedBoluses"
const val TABLE_HEART_RATE = "heartRate"
const val TABLE_GLUCOSE_VALUES = "glucoseValues" const val TABLE_GLUCOSE_VALUES = "glucoseValues"
const val TABLE_FOODS = "foods" const val TABLE_FOODS = "foods"
const val TABLE_MULTIWAVE_BOLUS_LINKS = "multiwaveBolusLinks" const val TABLE_MULTIWAVE_BOLUS_LINKS = "multiwaveBolusLinks"

View file

@ -8,6 +8,7 @@ import info.nightscout.database.entities.Carbs
import info.nightscout.database.entities.EffectiveProfileSwitch import info.nightscout.database.entities.EffectiveProfileSwitch
import info.nightscout.database.entities.ExtendedBolus import info.nightscout.database.entities.ExtendedBolus
import info.nightscout.database.entities.GlucoseValue import info.nightscout.database.entities.GlucoseValue
import info.nightscout.database.entities.HeartRate
import info.nightscout.database.entities.MultiwaveBolusLink import info.nightscout.database.entities.MultiwaveBolusLink
import info.nightscout.database.entities.OfflineEvent import info.nightscout.database.entities.OfflineEvent
import info.nightscout.database.entities.PreferenceChange import info.nightscout.database.entities.PreferenceChange
@ -35,5 +36,6 @@ data class NewEntries(
val temporaryTarget: List<TemporaryTarget>, val temporaryTarget: List<TemporaryTarget>,
val therapyEvents: List<TherapyEvent>, val therapyEvents: List<TherapyEvent>,
val totalDailyDoses: List<TotalDailyDose>, val totalDailyDoses: List<TotalDailyDose>,
val versionChanges: List<VersionChange> val versionChanges: List<VersionChange>,
) val heartRates: List<HeartRate>,
)

View file

@ -0,0 +1,36 @@
package info.nightscout.database.entities
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.jupiter.api.Test
class HeartRateTest {
@Test
fun contentEqualsTo_equals() {
val hr1 = createHeartRate()
assertTrue(hr1.contentEqualsTo(hr1))
assertTrue(hr1.contentEqualsTo(hr1.copy()))
assertTrue(hr1.contentEqualsTo(hr1.copy (id = 2, version = 2, dateCreated = 1L, referenceId = 4L)))
}
@Test
fun contentEqualsTo_notEquals() {
val hr1 = createHeartRate()
assertFalse(hr1.contentEqualsTo(hr1.copy(duration = 60_001L)))
assertFalse(hr1.contentEqualsTo(hr1.copy(timestamp = 2L)))
assertFalse(hr1.contentEqualsTo(hr1.copy(duration = 60_001L)))
assertFalse(hr1.contentEqualsTo(hr1.copy(beatsPerMinute = 100.0)))
assertFalse(hr1.contentEqualsTo(hr1.copy(isValid = false)))
}
companion object {
fun createHeartRate(timestamp: Long? = null, beatsPerMinute: Double = 80.0) =
HeartRate(
timestamp = timestamp ?: System.currentTimeMillis(),
duration = 60_0000L,
beatsPerMinute = beatsPerMinute,
device = "T",
)
}
}

View file

@ -8,6 +8,8 @@ plugins {
apply from: "${project.rootDir}/core/main/android_dependencies.gradle" apply from: "${project.rootDir}/core/main/android_dependencies.gradle"
apply from: "${project.rootDir}/core/main/android_module_dependencies.gradle" apply from: "${project.rootDir}/core/main/android_module_dependencies.gradle"
apply from: "${project.rootDir}/core/main/test_dependencies.gradle"
apply from: "${project.rootDir}/core/main/jacoco_global.gradle"
android { android {
@ -20,6 +22,9 @@ android {
} }
} }
} }
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas")
}
} }
dependencies { dependencies {
@ -44,9 +49,11 @@ dependencies {
api "com.google.dagger:dagger-android:$dagger_version" api "com.google.dagger:dagger-android:$dagger_version"
api "com.google.dagger:dagger-android-support:$dagger_version" api "com.google.dagger:dagger-android-support:$dagger_version"
androidTestImplementation "androidx.room:room-testing:$room_version"
} }
allOpen { allOpen {
// allows mocking for classes w/o directly opening them for release builds // allows mocking for classes w/o directly opening them for release builds
annotation 'info.nightscout.database.annotations.DbOpenForTesting' annotation 'info.nightscout.database.annotations.DbOpenForTesting'
} }

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 23, "version": 23,
"identityHash": "173734db5f4f35f6295ed953d8124794", "identityHash": "a3ee37800b6cda170d0ea64799ed7876",
"entities": [ "entities": [
{ {
"tableName": "apsResults", "tableName": "apsResults",
@ -3689,12 +3689,153 @@
] ]
} }
] ]
},
{
"tableName": "heartRate",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `duration` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `beatsPerMinute` REAL NOT NULL, `device` TEXT NOT NULL, `utcOffset` INTEGER NOT NULL, `version` INTEGER NOT NULL, `dateCreated` INTEGER NOT NULL, `isValid` INTEGER NOT NULL, `referenceId` INTEGER, `nightscoutSystemId` TEXT, `nightscoutId` TEXT, `pumpType` TEXT, `pumpSerial` TEXT, `temporaryId` INTEGER, `pumpId` INTEGER, `startId` INTEGER, `endId` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "beatsPerMinute",
"columnName": "beatsPerMinute",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "device",
"columnName": "device",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "utcOffset",
"columnName": "utcOffset",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "version",
"columnName": "version",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateCreated",
"columnName": "dateCreated",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isValid",
"columnName": "isValid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "referenceId",
"columnName": "referenceId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "interfaceIDs_backing.nightscoutSystemId",
"columnName": "nightscoutSystemId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "interfaceIDs_backing.nightscoutId",
"columnName": "nightscoutId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "interfaceIDs_backing.pumpType",
"columnName": "pumpType",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "interfaceIDs_backing.pumpSerial",
"columnName": "pumpSerial",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "interfaceIDs_backing.temporaryId",
"columnName": "temporaryId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "interfaceIDs_backing.pumpId",
"columnName": "pumpId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "interfaceIDs_backing.startId",
"columnName": "startId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "interfaceIDs_backing.endId",
"columnName": "endId",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_heartRate_id",
"unique": false,
"columnNames": [
"id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_heartRate_id` ON `${TABLE_NAME}` (`id`)"
},
{
"name": "index_heartRate_timestamp",
"unique": false,
"columnNames": [
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_heartRate_timestamp` ON `${TABLE_NAME}` (`timestamp`)"
}
],
"foreignKeys": []
} }
], ],
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '173734db5f4f35f6295ed953d8124794')" "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a3ee37800b6cda170d0ea64799ed7876')"
] ]
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,108 @@
package info.nightscout.database.impl
import android.content.Context
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import info.nightscout.database.entities.HeartRate
import info.nightscout.database.entities.TABLE_HEART_RATE
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
internal class HeartRateDaoTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private fun createDatabase() =
Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
private fun getDbObjects(supportDb: SupportSQLiteDatabase, type: String): Set<String> {
val names = mutableSetOf<String>()
supportDb.query("SELECT name FROM sqlite_master WHERE type = '$type'").use { c ->
while (c.moveToNext()) names.add(c.getString(0))
}
return names
}
private fun getTableNames(db: SupportSQLiteDatabase) = getDbObjects(db, "table")
private fun getIndexNames(db: SupportSQLiteDatabase) = getDbObjects(db, "index")
private fun insertAndFind(database: AppDatabase) {
val hr1 = createHeartRate()
val id = database.heartRateDao.insert(hr1)
val hr2 = database.heartRateDao.findById(id)
assertTrue(hr1.contentEqualsTo(hr2!!))
}
@Test
fun new_insertAndFind() {
createDatabase().use { db -> insertAndFind(db) }
}
@Test
fun migrate_createsTableAndIndices() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
val startVersion = 22
val supportDb = helper.createDatabase(TEST_DB_NAME, startVersion)
assertFalse(getTableNames(supportDb).contains(TABLE_HEART_RATE))
DatabaseModule().migrations.filter { m -> m.startVersion >= startVersion }.forEach { m -> m.migrate(supportDb) }
assertTrue(getTableNames(supportDb).contains(TABLE_HEART_RATE))
assertTrue(getIndexNames(supportDb).contains("index_heartRate_id"))
assertTrue(getIndexNames(supportDb).contains("index_heartRate_timestamp"))
}
@Test
fun migrate_insertAndFind() {
val helper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
// Create the database for version 22 (that's missing the heartRate table).
// helper.createDatabase removes the db file if it already exists.
val supportDb = helper.createDatabase(TEST_DB_NAME, 22)
assertFalse(getTableNames(supportDb).contains(TABLE_HEART_RATE))
// Room.databaseBuilder will use the previously created db file that has version 22.
Room.databaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java, TEST_DB_NAME)
.addMigrations(*DatabaseModule().migrations)
.build().use { db -> insertAndFind(db) }
}
@Test
fun getFromTime() {
createDatabase().use { db ->
val dao = db.heartRateDao
val timestamp = System.currentTimeMillis()
val hr1 = createHeartRate(timestamp = timestamp, beatsPerMinute = 80.0)
val hr2 = createHeartRate(timestamp = timestamp + 1, beatsPerMinute = 150.0)
dao.insertNewEntry(hr1)
dao.insertNewEntry(hr2)
assertEquals(listOf(hr1, hr2), dao.getFromTime(timestamp))
assertEquals(listOf(hr2), dao.getFromTime(timestamp + 1))
assertTrue(dao.getFromTime(timestamp + 2).isEmpty())
}
}
companion object {
private const val TEST_DB_NAME = "testDatabase"
fun createHeartRate(timestamp: Long? = null, beatsPerMinute: Double = 80.0) =
HeartRate(
timestamp = timestamp ?: System.currentTimeMillis(),
duration = 60_0000L,
beatsPerMinute = beatsPerMinute,
device = "T",
)
}
}

View file

@ -0,0 +1,57 @@
package info.nightscout.database.impl.transactions
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import info.nightscout.database.impl.AppDatabase
import info.nightscout.database.impl.AppRepository
import info.nightscout.database.impl.HeartRateDaoTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InsertOrUpdateHeartRateTransactionTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: AppDatabase
private lateinit var repo: AppRepository
@Before
fun setupUp() {
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
repo = AppRepository(db)
}
@After
fun shutdown() {
db.close()
}
@Test
fun createNewEntry() {
val hr1 = HeartRateDaoTest.createHeartRate()
val result = repo.runTransactionForResult(InsertOrUpdateHeartRateTransaction(hr1)).blockingGet()
assertEquals(listOf(hr1), result.inserted)
assertTrue(result.updated.isEmpty())
}
@Test
fun updateEntry() {
val hr1 = HeartRateDaoTest.createHeartRate()
val id = db.heartRateDao.insertNewEntry(hr1)
assertNotEquals(0, id)
val hr2 = hr1.copy(id = id, beatsPerMinute = 181.0)
val result = repo.runTransactionForResult(InsertOrUpdateHeartRateTransaction(hr2)).blockingGet()
assertEquals(listOf(hr2), result.updated)
assertTrue(result.inserted.isEmpty())
val hr3 = db.heartRateDao.findById(id)!!
assertTrue(hr2.contentEqualsTo(hr3))
}
}

View file

@ -33,6 +33,7 @@ import info.nightscout.database.entities.EffectiveProfileSwitch
import info.nightscout.database.entities.ExtendedBolus import info.nightscout.database.entities.ExtendedBolus
import info.nightscout.database.entities.Food import info.nightscout.database.entities.Food
import info.nightscout.database.entities.GlucoseValue import info.nightscout.database.entities.GlucoseValue
import info.nightscout.database.entities.HeartRate
import info.nightscout.database.entities.MultiwaveBolusLink import info.nightscout.database.entities.MultiwaveBolusLink
import info.nightscout.database.entities.OfflineEvent import info.nightscout.database.entities.OfflineEvent
import info.nightscout.database.entities.PreferenceChange import info.nightscout.database.entities.PreferenceChange
@ -43,18 +44,20 @@ import info.nightscout.database.entities.TherapyEvent
import info.nightscout.database.entities.TotalDailyDose import info.nightscout.database.entities.TotalDailyDose
import info.nightscout.database.entities.UserEntry import info.nightscout.database.entities.UserEntry
import info.nightscout.database.entities.VersionChange import info.nightscout.database.entities.VersionChange
import info.nightscout.database.impl.daos.HeartRateDao
import java.io.Closeable
const val DATABASE_VERSION = 23 const val DATABASE_VERSION = 24
@Database(version = DATABASE_VERSION, @Database(version = DATABASE_VERSION,
entities = [APSResult::class, Bolus::class, BolusCalculatorResult::class, Carbs::class, entities = [APSResult::class, Bolus::class, BolusCalculatorResult::class, Carbs::class,
EffectiveProfileSwitch::class, ExtendedBolus::class, GlucoseValue::class, ProfileSwitch::class, EffectiveProfileSwitch::class, ExtendedBolus::class, GlucoseValue::class, ProfileSwitch::class,
TemporaryBasal::class, TemporaryTarget::class, TherapyEvent::class, TotalDailyDose::class, APSResultLink::class, TemporaryBasal::class, TemporaryTarget::class, TherapyEvent::class, TotalDailyDose::class, APSResultLink::class,
MultiwaveBolusLink::class, PreferenceChange::class, VersionChange::class, UserEntry::class, MultiwaveBolusLink::class, PreferenceChange::class, VersionChange::class, UserEntry::class,
Food::class, DeviceStatus::class, OfflineEvent::class], Food::class, DeviceStatus::class, OfflineEvent::class, HeartRate::class],
exportSchema = true) exportSchema = true)
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
internal abstract class AppDatabase : RoomDatabase() { internal abstract class AppDatabase : Closeable, RoomDatabase() {
abstract val glucoseValueDao: GlucoseValueDao abstract val glucoseValueDao: GlucoseValueDao
@ -96,4 +99,5 @@ internal abstract class AppDatabase : RoomDatabase() {
abstract val offlineEventDao: OfflineEventDao abstract val offlineEventDao: OfflineEventDao
} abstract val heartRateDao: HeartRateDao
}

View file

@ -101,6 +101,7 @@ import kotlin.math.roundToInt
//database.foodDao.deleteOlderThan(than) //database.foodDao.deleteOlderThan(than)
removed.add(Pair("DeviceStatus", database.deviceStatusDao.deleteOlderThan(than))) removed.add(Pair("DeviceStatus", database.deviceStatusDao.deleteOlderThan(than)))
removed.add(Pair("OfflineEvent", database.offlineEventDao.deleteOlderThan(than))) removed.add(Pair("OfflineEvent", database.offlineEventDao.deleteOlderThan(than)))
removed.add(Pair("HeartRate", database.heartRateDao.deleteOlderThan(than)))
if (deleteTrackedChanges) { if (deleteTrackedChanges) {
removed.add(Pair("GlucoseValue", database.glucoseValueDao.deleteTrackedChanges())) removed.add(Pair("GlucoseValue", database.glucoseValueDao.deleteTrackedChanges()))
@ -119,6 +120,7 @@ import kotlin.math.roundToInt
removed.add(Pair("ApsResult", database.apsResultDao.deleteTrackedChanges())) removed.add(Pair("ApsResult", database.apsResultDao.deleteTrackedChanges()))
//database.foodDao.deleteHistory() //database.foodDao.deleteHistory()
removed.add(Pair("OfflineEvent", database.offlineEventDao.deleteTrackedChanges())) removed.add(Pair("OfflineEvent", database.offlineEventDao.deleteTrackedChanges()))
removed.add(Pair("HeartRate", database.heartRateDao.deleteTrackedChanges()))
} }
val ret = StringBuilder() val ret = StringBuilder()
removed removed
@ -930,6 +932,8 @@ import kotlin.math.roundToInt
fun getLastOfflineEventId(): Long? = fun getLastOfflineEventId(): Long? =
database.offlineEventDao.getLastId() database.offlineEventDao.getLastId()
fun getHeartRatesFromTime(timeMillis: Long) = database.heartRateDao.getFromTime(timeMillis)
suspend fun collectNewEntriesSince(since: Long, until: Long, limit: Int, offset: Int) = NewEntries( suspend fun collectNewEntriesSince(since: Long, until: Long, limit: Int, offset: Int) = NewEntries(
apsResults = database.apsResultDao.getNewEntriesSince(since, until, limit, offset), apsResults = database.apsResultDao.getNewEntriesSince(since, until, limit, offset),
apsResultLinks = database.apsResultLinkDao.getNewEntriesSince(since, until, limit, offset), apsResultLinks = database.apsResultLinkDao.getNewEntriesSince(since, until, limit, offset),
@ -948,6 +952,7 @@ import kotlin.math.roundToInt
therapyEvents = database.therapyEventDao.getNewEntriesSince(since, until, limit, offset), therapyEvents = database.therapyEventDao.getNewEntriesSince(since, until, limit, offset),
totalDailyDoses = database.totalDailyDoseDao.getNewEntriesSince(since, until, limit, offset), totalDailyDoses = database.totalDailyDoseDao.getNewEntriesSince(since, until, limit, offset),
versionChanges = database.versionChangeDao.getNewEntriesSince(since, until, limit, offset), versionChanges = database.versionChangeDao.getNewEntriesSince(since, until, limit, offset),
heartRates = database.heartRateDao.getNewEntriesSince(since, until, limit, offset),
) )
} }
@ -956,4 +961,3 @@ inline fun <reified T : Any> Maybe<T>.toWrappedSingle(): Single<ValueWrapper<T>>
this.map { ValueWrapper.Existing(it) as ValueWrapper<T> } this.map { ValueWrapper.Existing(it) as ValueWrapper<T> }
.switchIfEmpty(Maybe.just(ValueWrapper.Absent())) .switchIfEmpty(Maybe.just(ValueWrapper.Absent()))
.toSingle() .toSingle()

View file

@ -1,12 +1,14 @@
package info.nightscout.database.impl package info.nightscout.database.impl
import android.content.Context import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase.Callback import androidx.room.RoomDatabase.Callback
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import info.nightscout.database.entities.TABLE_HEART_RATE
import javax.inject.Qualifier import javax.inject.Qualifier
import javax.inject.Singleton import javax.inject.Singleton
@ -22,13 +24,7 @@ open class DatabaseModule {
internal fun provideAppDatabase(context: Context, @DbFileName fileName: String) = internal fun provideAppDatabase(context: Context, @DbFileName fileName: String) =
Room Room
.databaseBuilder(context, AppDatabase::class.java, fileName) .databaseBuilder(context, AppDatabase::class.java, fileName)
// .addMigrations(migration5to6) .addMigrations(*migrations)
// .addMigrations(migration6to7)
// .addMigrations(migration7to8)
// .addMigrations(migration11to12)
.addMigrations(migration20to21)
.addMigrations(migration21to22)
.addMigrations(migration22to23)
.addCallback(object : Callback() { .addCallback(object : Callback() {
override fun onOpen(db: SupportSQLiteDatabase) { override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db) super.onOpen(db)
@ -89,4 +85,36 @@ open class DatabaseModule {
} }
} }
} private val migration23to24 = object : Migration(23, 24) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""CREATE TABLE IF NOT EXISTS `$TABLE_HEART_RATE` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`duration` INTEGER NOT NULL,
`timestamp` INTEGER NOT NULL,
`beatsPerMinute` REAL NOT NULL,
`device` TEXT NOT NULL,
`utcOffset` INTEGER NOT NULL,
`version` INTEGER NOT NULL,
`dateCreated` INTEGER NOT NULL,
`isValid` INTEGER NOT NULL,
`referenceId` INTEGER,
`nightscoutSystemId` TEXT,
`nightscoutId` TEXT,
`pumpType` TEXT,
`pumpSerial` TEXT,
`temporaryId` INTEGER,
`pumpId` INTEGER, `startId` INTEGER,
`endId` INTEGER)""".trimIndent()
)
database.execSQL("""CREATE INDEX IF NOT EXISTS `index_heartRate_id` ON `$TABLE_HEART_RATE` (`id`)""")
database.execSQL("""CREATE INDEX IF NOT EXISTS `index_heartRate_timestamp` ON `$TABLE_HEART_RATE` (`timestamp`)""")
// Custom indexes must be dropped on migration to pass room schema checking after upgrade
dropCustomIndexes(database)
}
}
/** List of all migrations for easy reply in tests. */
@VisibleForTesting
internal val migrations = arrayOf(migration20to21, migration21to22, migration22to23, migration23to24)
}

View file

@ -41,6 +41,8 @@ import info.nightscout.database.impl.daos.delegated.DelegatedTotalDailyDoseDao
import info.nightscout.database.impl.daos.delegated.DelegatedUserEntryDao import info.nightscout.database.impl.daos.delegated.DelegatedUserEntryDao
import info.nightscout.database.impl.daos.delegated.DelegatedVersionChangeDao import info.nightscout.database.impl.daos.delegated.DelegatedVersionChangeDao
import info.nightscout.database.entities.interfaces.DBEntry import info.nightscout.database.entities.interfaces.DBEntry
import info.nightscout.database.impl.daos.HeartRateDao
import info.nightscout.database.impl.daos.delegated.DelegatedHeartRateDao
internal class DelegatedAppDatabase(val changes: MutableList<DBEntry>, val database: AppDatabase) { internal class DelegatedAppDatabase(val changes: MutableList<DBEntry>, val database: AppDatabase) {
@ -64,5 +66,6 @@ internal class DelegatedAppDatabase(val changes: MutableList<DBEntry>, val datab
val foodDao: FoodDao = DelegatedFoodDao(changes, database.foodDao) val foodDao: FoodDao = DelegatedFoodDao(changes, database.foodDao)
val deviceStatusDao: DeviceStatusDao = DelegatedDeviceStatusDao(changes, database.deviceStatusDao) val deviceStatusDao: DeviceStatusDao = DelegatedDeviceStatusDao(changes, database.deviceStatusDao)
val offlineEventDao: OfflineEventDao = DelegatedOfflineEventDao(changes, database.offlineEventDao) val offlineEventDao: OfflineEventDao = DelegatedOfflineEventDao(changes, database.offlineEventDao)
val heartRateDao: HeartRateDao = DelegatedHeartRateDao(changes, database.heartRateDao)
fun clearAllTables() = database.clearAllTables() fun clearAllTables() = database.clearAllTables()
} }

View file

@ -0,0 +1,28 @@
package info.nightscout.database.impl.daos
import androidx.room.Dao
import androidx.room.Query
import info.nightscout.database.entities.HeartRate
import info.nightscout.database.entities.TABLE_HEART_RATE
@Dao
internal interface HeartRateDao : TraceableDao<HeartRate> {
@Query("SELECT * FROM $TABLE_HEART_RATE WHERE id = :id")
override fun findById(id: Long): HeartRate?
@Query("DELETE FROM $TABLE_HEART_RATE")
override fun deleteAllEntries()
@Query("DELETE FROM $TABLE_HEART_RATE WHERE timestamp < :than")
override fun deleteOlderThan(than: Long): Int
@Query("DELETE FROM $TABLE_HEART_RATE WHERE referenceId IS NOT NULL")
override fun deleteTrackedChanges(): Int
@Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp >= :timestamp ORDER BY timestamp")
fun getFromTime(timestamp: Long): List<HeartRate>
@Query("SELECT * FROM $TABLE_HEART_RATE WHERE timestamp > :since AND timestamp <= :until LIMIT :limit OFFSET :offset")
fun getNewEntriesSince(since: Long, until: Long, limit: Int, offset: Int): List<HeartRate>
}

View file

@ -0,0 +1,20 @@
package info.nightscout.database.impl.daos.delegated
import info.nightscout.database.entities.HeartRate
import info.nightscout.database.entities.interfaces.DBEntry
import info.nightscout.database.impl.daos.HeartRateDao
internal class DelegatedHeartRateDao(
changes: MutableList<DBEntry>,
private val dao:HeartRateDao): DelegatedDao(changes), HeartRateDao by dao {
override fun insertNewEntry(entry: HeartRate): Long {
changes.add(entry)
return dao.insertNewEntry(entry)
}
override fun updateExistingEntry(entry: HeartRate): Long {
changes.add(entry)
return dao.updateExistingEntry(entry)
}
}

View file

@ -0,0 +1,20 @@
package info.nightscout.database.impl.transactions
import info.nightscout.database.entities.HeartRate
class InsertOrUpdateHeartRateTransaction(private val heartRate: HeartRate):
Transaction<InsertOrUpdateHeartRateTransaction.TransactionResult>() {
override fun run(): TransactionResult {
val existing = if (heartRate.id == 0L) null else database.heartRateDao.findById(heartRate.id)
return if (existing == null) {
database.heartRateDao.insertNewEntry(heartRate).let { id ->
TransactionResult(listOf(heartRate), emptyList()) }
} else {
database.heartRateDao.updateExistingEntry(heartRate)
TransactionResult(emptyList(), listOf(heartRate))
}
}
data class TransactionResult(val inserted: List<HeartRate>, val updated: List<HeartRate>)
}