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:
parent
39dd25af07
commit
4dd5bf2d03
17 changed files with 4365 additions and 20 deletions
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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>,
|
||||||
|
)
|
||||||
|
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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'
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
@ -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",
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>)
|
||||||
|
}
|
Loading…
Reference in a new issue