diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 78d38df3..7b8b8793 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -171,3 +171,5 @@ compass-autocomplete = { module = "dev.jordond.compass:autocomplete", version.re compass-autocomplete-mobile = { module = "dev.jordond.compass:autocomplete-mobile", version.ref = "compass" } coredevices-krispPrivate = { group = "coredevices.krispkmp", name = "library", version.ref = "coredevices-krisp" } health-kmp = { module = "com.viktormykhailiv:health-kmp", version.ref = "health-kmp" } +# health-kmp 1.4.0 doesn't model OxygenSaturation, so we depend on connect-client directly. +health-connect = { module = "androidx.health.connect:connect-client", version = "1.1.0" } diff --git a/libpebble3/schema/io.rebble.libpebblecommon.database.Database/40.json b/libpebble3/schema/io.rebble.libpebblecommon.database.Database/40.json new file mode 100644 index 00000000..74ea59fa --- /dev/null +++ b/libpebble3/schema/io.rebble.libpebblecommon.database.Database/40.json @@ -0,0 +1,1858 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "e44a80014384141f5b3e24ddb6697cb6", + "entities": [ + { + "tableName": "KnownWatchItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transportIdentifier` TEXT NOT NULL, `transportType` TEXT NOT NULL, `name` TEXT NOT NULL, `runningFwVersion` TEXT NOT NULL, `serial` TEXT NOT NULL, `connectGoal` INTEGER NOT NULL, `lastConnected` INTEGER, `watchType` TEXT, `color` INTEGER, `nickname` TEXT, `btClassicMacAddress` TEXT, `capabilities` TEXT, PRIMARY KEY(`transportIdentifier`))", + "fields": [ + { + "fieldPath": "transportIdentifier", + "columnName": "transportIdentifier", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transportType", + "columnName": "transportType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "runningFwVersion", + "columnName": "runningFwVersion", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serial", + "columnName": "serial", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connectGoal", + "columnName": "connectGoal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastConnected", + "columnName": "lastConnected", + "affinity": "INTEGER" + }, + { + "fieldPath": "watchType", + "columnName": "watchType", + "affinity": "TEXT" + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER" + }, + { + "fieldPath": "nickname", + "columnName": "nickname", + "affinity": "TEXT" + }, + { + "fieldPath": "btClassicMacAddress", + "columnName": "btClassicMacAddress", + "affinity": "TEXT" + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transportIdentifier" + ] + } + }, + { + "tableName": "LockerEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `id` TEXT NOT NULL, `version` TEXT NOT NULL, `title` TEXT NOT NULL, `type` TEXT NOT NULL, `developerName` TEXT NOT NULL, `configurable` INTEGER NOT NULL, `pbwVersionCode` TEXT NOT NULL, `category` TEXT, `sideloaded` INTEGER NOT NULL, `sideloadeTimestamp` INTEGER, `platforms` TEXT NOT NULL, `iosCompanion` TEXT, `androidCompanion` TEXT, `orderIndex` INTEGER NOT NULL DEFAULT 0, `systemApp` INTEGER NOT NULL DEFAULT 0, `active` INTEGER NOT NULL DEFAULT 0, `capabilities` TEXT DEFAULT NULL, `grantedPermissions` TEXT DEFAULT NULL, `hearts` INTEGER, `developerId` TEXT, `timelineEnabled` INTEGER, `removeLink` TEXT, `shareLink` TEXT, `pbwLink` TEXT, `userToken` TEXT, `sourceLink` TEXT, `storeId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.developerName", + "columnName": "developerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.configurable", + "columnName": "configurable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.pbwVersionCode", + "columnName": "pbwVersionCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.category", + "columnName": "category", + "affinity": "TEXT" + }, + { + "fieldPath": "record.sideloaded", + "columnName": "sideloaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.sideloadeTimestamp", + "columnName": "sideloadeTimestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "record.platforms", + "columnName": "platforms", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.iosCompanion", + "columnName": "iosCompanion", + "affinity": "TEXT" + }, + { + "fieldPath": "record.androidCompanion", + "columnName": "androidCompanion", + "affinity": "TEXT" + }, + { + "fieldPath": "record.orderIndex", + "columnName": "orderIndex", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "record.systemApp", + "columnName": "systemApp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "record.active", + "columnName": "active", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "record.capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "record.grantedPermissions", + "columnName": "grantedPermissions", + "affinity": "TEXT", + "defaultValue": "NULL" + }, + { + "fieldPath": "record.appstoreData.hearts", + "columnName": "hearts", + "affinity": "INTEGER" + }, + { + "fieldPath": "record.appstoreData.developerId", + "columnName": "developerId", + "affinity": "TEXT" + }, + { + "fieldPath": "record.appstoreData.timelineEnabled", + "columnName": "timelineEnabled", + "affinity": "INTEGER" + }, + { + "fieldPath": "record.appstoreData.removeLink", + "columnName": "removeLink", + "affinity": "TEXT" + }, + { + "fieldPath": "record.appstoreData.shareLink", + "columnName": "shareLink", + "affinity": "TEXT" + }, + { + "fieldPath": "record.appstoreData.pbwLink", + "columnName": "pbwLink", + "affinity": "TEXT" + }, + { + "fieldPath": "record.appstoreData.userToken", + "columnName": "userToken", + "affinity": "TEXT" + }, + { + "fieldPath": "record.appstoreData.sourceLink", + "columnName": "sourceLink", + "affinity": "TEXT" + }, + { + "fieldPath": "record.appstoreData.storeId", + "columnName": "storeId", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "LockerEntrySyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `LockerEntryEntity`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "LockerEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TimelineNotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `itemId` TEXT NOT NULL, `parentId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `flags` TEXT NOT NULL, `layout` TEXT NOT NULL, `attributes` TEXT NOT NULL, `actions` TEXT NOT NULL, PRIMARY KEY(`itemId`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.content.duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.content.flags", + "columnName": "flags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.layout", + "columnName": "layout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.attributes", + "columnName": "attributes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.actions", + "columnName": "actions", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "itemId" + ] + } + }, + { + "tableName": "TimelineNotificationSyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `TimelineNotificationEntity`(`itemId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "TimelineNotificationEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "itemId" + ] + } + ] + }, + { + "tableName": "TimelinePinEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `itemId` TEXT NOT NULL, `backingId` TEXT, `parentId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `flags` TEXT NOT NULL, `layout` TEXT NOT NULL, `attributes` TEXT NOT NULL, `actions` TEXT NOT NULL, PRIMARY KEY(`itemId`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.backingId", + "columnName": "backingId", + "affinity": "TEXT" + }, + { + "fieldPath": "record.content.parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.content.duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.content.flags", + "columnName": "flags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.layout", + "columnName": "layout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.attributes", + "columnName": "attributes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.actions", + "columnName": "actions", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "itemId" + ] + } + }, + { + "tableName": "TimelinePinSyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `TimelinePinEntity`(`itemId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "TimelinePinEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "itemId" + ] + } + ] + }, + { + "tableName": "TimelineReminderEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `itemId` TEXT NOT NULL, `parentId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `flags` TEXT NOT NULL, `layout` TEXT NOT NULL, `attributes` TEXT NOT NULL, `actions` TEXT NOT NULL, PRIMARY KEY(`itemId`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.parentId", + "columnName": "parentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.content.duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.content.flags", + "columnName": "flags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.layout", + "columnName": "layout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.attributes", + "columnName": "attributes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.content.actions", + "columnName": "actions", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "itemId" + ] + } + }, + { + "tableName": "TimelineReminderSyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `TimelineReminderEntity`(`itemId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "TimelineReminderEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "itemId" + ] + } + ] + }, + { + "tableName": "NotificationAppItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `packageName` TEXT NOT NULL, `name` TEXT NOT NULL, `muteState` TEXT NOT NULL, `channelGroups` TEXT NOT NULL, `stateUpdated` INTEGER NOT NULL, `lastNotified` INTEGER NOT NULL, `muteExpiration` INTEGER DEFAULT null, `vibePatternName` TEXT DEFAULT null, `colorName` TEXT DEFAULT null, `iconCode` TEXT DEFAULT null, `allowDuplicates` INTEGER NOT NULL DEFAULT 0, `isSystemApp` INTEGER NOT NULL DEFAULT 0, `autoAdded` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.muteState", + "columnName": "muteState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.channelGroups", + "columnName": "channelGroups", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.stateUpdated", + "columnName": "stateUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.lastNotified", + "columnName": "lastNotified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.muteExpiration", + "columnName": "muteExpiration", + "affinity": "INTEGER", + "defaultValue": "null" + }, + { + "fieldPath": "record.vibePatternName", + "columnName": "vibePatternName", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "record.colorName", + "columnName": "colorName", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "record.iconCode", + "columnName": "iconCode", + "affinity": "TEXT", + "defaultValue": "null" + }, + { + "fieldPath": "record.allowDuplicates", + "columnName": "allowDuplicates", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "record.isSystemApp", + "columnName": "isSystemApp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "record.autoAdded", + "columnName": "autoAdded", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + } + }, + { + "tableName": "NotificationAppItemSyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `NotificationAppItemEntity`(`packageName`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "NotificationAppItemEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "packageName" + ] + } + ] + }, + { + "tableName": "CalendarEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `platformId` TEXT NOT NULL, `name` TEXT NOT NULL, `ownerName` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `color` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `syncEvents` INTEGER NOT NULL DEFAULT 1, `visible` INTEGER NOT NULL DEFAULT 1)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "platformId", + "columnName": "platformId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerName", + "columnName": "ownerName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncEvents", + "columnName": "syncEvents", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "visible", + "columnName": "visible", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_CalendarEntity_platformId", + "unique": true, + "columnNames": [ + "platformId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_CalendarEntity_platformId` ON `${TABLE_NAME}` (`platformId`)" + } + ] + }, + { + "tableName": "HealthSettingsEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `id` TEXT NOT NULL, `value` TEXT NOT NULL, `timestamp` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "HealthSettingsEntrySyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `HealthSettingsEntryEntity`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "HealthSettingsEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "LockerAppPermission", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appUuid` TEXT NOT NULL, `permission` TEXT NOT NULL, `granted` INTEGER NOT NULL, PRIMARY KEY(`appUuid`, `permission`))", + "fields": [ + { + "fieldPath": "appUuid", + "columnName": "appUuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permission", + "columnName": "permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "granted", + "columnName": "granted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "appUuid", + "permission" + ] + } + }, + { + "tableName": "NotificationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pkg` TEXT NOT NULL, `key` TEXT NOT NULL, `groupKey` TEXT, `channelId` TEXT, `timestamp` INTEGER NOT NULL, `title` TEXT, `body` TEXT, `decision` TEXT NOT NULL, `people` TEXT DEFAULT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pkg", + "columnName": "pkg", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "groupKey", + "columnName": "groupKey", + "affinity": "TEXT" + }, + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT" + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT" + }, + { + "fieldPath": "decision", + "columnName": "decision", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "people", + "columnName": "people", + "affinity": "TEXT", + "defaultValue": "NULL" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NotificationEntity_pkg_channelId", + "unique": false, + "columnNames": [ + "pkg", + "channelId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_pkg_channelId` ON `${TABLE_NAME}` (`pkg`, `channelId`)" + } + ] + }, + { + "tableName": "ContactEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`lookupKey` TEXT NOT NULL, `name` TEXT NOT NULL, `muteState` TEXT NOT NULL, `vibePatternName` TEXT DEFAULT null, PRIMARY KEY(`lookupKey`))", + "fields": [ + { + "fieldPath": "lookupKey", + "columnName": "lookupKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteState", + "columnName": "muteState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vibePatternName", + "columnName": "vibePatternName", + "affinity": "TEXT", + "defaultValue": "null" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "lookupKey" + ] + } + }, + { + "tableName": "VibePatternEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `bundled` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bundled", + "columnName": "bundled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "health_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `orientation` INTEGER NOT NULL, `intensity` INTEGER NOT NULL, `lightIntensity` INTEGER NOT NULL, `activeMinutes` INTEGER NOT NULL, `restingGramCalories` INTEGER NOT NULL, `activeGramCalories` INTEGER NOT NULL, `distanceCm` INTEGER NOT NULL, `heartRate` INTEGER NOT NULL, `heartRateZone` INTEGER NOT NULL, `heartRateWeight` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "orientation", + "columnName": "orientation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intensity", + "columnName": "intensity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lightIntensity", + "columnName": "lightIntensity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeMinutes", + "columnName": "activeMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restingGramCalories", + "columnName": "restingGramCalories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeGramCalories", + "columnName": "activeGramCalories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distanceCm", + "columnName": "distanceCm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "heartRate", + "columnName": "heartRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "heartRateZone", + "columnName": "heartRateZone", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "heartRateWeight", + "columnName": "heartRateWeight", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "overlay_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`startTime` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `type` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `restingKiloCalories` INTEGER NOT NULL, `activeKiloCalories` INTEGER NOT NULL, `distanceCm` INTEGER NOT NULL, `offsetUTC` INTEGER NOT NULL, PRIMARY KEY(`startTime`, `type`))", + "fields": [ + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "restingKiloCalories", + "columnName": "restingKiloCalories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "activeKiloCalories", + "columnName": "activeKiloCalories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distanceCm", + "columnName": "distanceCm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "offsetUTC", + "columnName": "offsetUTC", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "startTime", + "type" + ] + } + }, + { + "tableName": "spo2_readings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `spo2Percent` INTEGER NOT NULL, `quality` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spo2Percent", + "columnName": "spo2Percent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "HealthStatEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `key` TEXT NOT NULL, `payload` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.payload", + "columnName": "payload", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "record.lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "HealthStatSyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `HealthStatEntity`(`key`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "HealthStatEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "key" + ] + } + ] + }, + { + "tableName": "WatchPrefItemEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `id` TEXT NOT NULL, `value` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "WatchPrefItemSyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `WatchPrefItemEntity`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "WatchPrefItemEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "WeatherAppEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `key` TEXT NOT NULL, `currentTemp` INTEGER NOT NULL, `currentWeatherType` INTEGER NOT NULL, `todayHighTemp` INTEGER NOT NULL, `todayLowTemp` INTEGER NOT NULL, `tomorrowWeatherType` INTEGER NOT NULL, `tomorrowHighTemp` INTEGER NOT NULL, `tomorrowLowTemp` INTEGER NOT NULL, `lastUpdateTimeUtcSecs` INTEGER NOT NULL, `isCurrentLocation` INTEGER NOT NULL, `locationName` TEXT NOT NULL, `forecastShort` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.currentTemp", + "columnName": "currentTemp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.currentWeatherType", + "columnName": "currentWeatherType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.todayHighTemp", + "columnName": "todayHighTemp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.todayLowTemp", + "columnName": "todayLowTemp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.tomorrowWeatherType", + "columnName": "tomorrowWeatherType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.tomorrowHighTemp", + "columnName": "tomorrowHighTemp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.tomorrowLowTemp", + "columnName": "tomorrowLowTemp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.lastUpdateTimeUtcSecs", + "columnName": "lastUpdateTimeUtcSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.isCurrentLocation", + "columnName": "isCurrentLocation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "record.locationName", + "columnName": "locationName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.forecastShort", + "columnName": "forecastShort", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "WeatherAppEntrySyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `WeatherAppEntryEntity`(`key`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "WeatherAppEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "key" + ] + } + ] + }, + { + "tableName": "AppPrefsEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordHashcode` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `sync` INTEGER NOT NULL DEFAULT 1, `id` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "recordHashcode", + "columnName": "recordHashcode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sync", + "columnName": "sync", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "record.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "record.value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "AppPrefsEntrySyncEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`recordId` TEXT NOT NULL, `transport` TEXT NOT NULL, `watchSynchHashcode` INTEGER NOT NULL, PRIMARY KEY(`recordId`, `transport`), FOREIGN KEY(`recordId`) REFERENCES `AppPrefsEntryEntity`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "recordId", + "columnName": "recordId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transport", + "columnName": "transport", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watchSynchHashcode", + "columnName": "watchSynchHashcode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "recordId", + "transport" + ] + }, + "foreignKeys": [ + { + "table": "AppPrefsEntryEntity", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "recordId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NotificationRuleEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `targetType` TEXT NOT NULL, `target` TEXT, `matchType` TEXT NOT NULL, `matchField` TEXT NOT NULL, `pattern` TEXT NOT NULL, `caseSensitive` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetType", + "columnName": "targetType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "target", + "columnName": "target", + "affinity": "TEXT" + }, + { + "fieldPath": "matchType", + "columnName": "matchType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "matchField", + "columnName": "matchField", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseSensitive", + "columnName": "caseSensitive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NotificationRuleEntity_targetType_target", + "unique": false, + "columnNames": [ + "targetType", + "target" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationRuleEntity_targetType_target` ON `${TABLE_NAME}` (`targetType`, `target`)" + } + ] + } + ], + "setupQueries": [ + "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, 'e44a80014384141f5b3e24ddb6697cb6')" + ] + } +} \ No newline at end of file diff --git a/libpebble3/src/androidInstrumentedTest/kotlin/io/rebble/libpebblecommon/database/dao/Spo2DaoTest.kt b/libpebble3/src/androidInstrumentedTest/kotlin/io/rebble/libpebblecommon/database/dao/Spo2DaoTest.kt new file mode 100644 index 00000000..af6c1cf3 --- /dev/null +++ b/libpebble3/src/androidInstrumentedTest/kotlin/io/rebble/libpebblecommon/database/dao/Spo2DaoTest.kt @@ -0,0 +1,47 @@ +package io.rebble.libpebblecommon.database.dao + +import androidx.room.Room +import androidx.test.platform.app.InstrumentationRegistry +import io.rebble.libpebblecommon.database.Database +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class Spo2DaoTest { + private lateinit var db: Database + private lateinit var spo2Dao: Spo2Dao + + @Before + fun createDb() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + db = Room.inMemoryDatabaseBuilder(context, Database::class.java) + .allowMainThreadQueries() + .build() + spo2Dao = db.spo2Dao() + } + + @After + fun closeDb() { + db.close() + } + + @Test + fun insertAndRead() = runBlocking { + val reading = Spo2ReadingEntity( + timestamp = 1600000000L, + spo2Percent = 97, + quality = 6, + ) + spo2Dao.insertSpo2Readings(listOf(reading)) + + val latest = spo2Dao.getLatestSpo2Reading() + assertNotNull(latest) + assertEquals(97, latest.spo2Percent) + assertEquals(6, latest.quality) + assertEquals(1600000000L, latest.timestamp) + } +} diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt index e44566c2..be040136 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt @@ -26,6 +26,7 @@ import io.rebble.libpebblecommon.database.dao.HealthAggregates import io.rebble.libpebblecommon.database.entity.HealthDataEntity import io.rebble.libpebblecommon.services.DailySleep import io.rebble.libpebblecommon.connection.LatestHeartRate +import io.rebble.libpebblecommon.connection.LatestSpo2 import io.rebble.libpebblecommon.database.entity.HRMonitoringInterval import io.rebble.libpebblecommon.database.entity.HealthGender import io.rebble.libpebblecommon.database.entity.MuteState @@ -33,6 +34,7 @@ import io.rebble.libpebblecommon.database.entity.NotificationAppItem import io.rebble.libpebblecommon.database.entity.NotificationEntity import io.rebble.libpebblecommon.database.entity.NotificationRuleEntity import io.rebble.libpebblecommon.database.entity.OverlayDataEntity +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity import io.rebble.libpebblecommon.database.entity.TimelineNotification import io.rebble.libpebblecommon.database.entity.TimelinePin import io.rebble.libpebblecommon.database.entity.WatchPref @@ -370,6 +372,9 @@ class FakeLibPebble : LibPebble { hrZone1Threshold = 130, hrZone2Threshold = 154, hrZone3Threshold = 172, + bloodOxygenEnabled = false, + bloodOxygenActivityEnabled = false, + spo2MeasurementInterval = HRMonitoringInterval.TenMin, )) } override fun updateHealthSettings(healthSettings: HealthSettings) {} @@ -480,6 +485,10 @@ class FakeLibPebble : LibPebble { override suspend fun getLatestHeartRateReading(): LatestHeartRate? = null override suspend fun getRestingHeartRate(dayStartEpochSec: Long): Int? = null override suspend fun getHRZoneMinutes(start: Long, end: Long) = emptyMap() + override suspend fun getSpo2Readings(start: Long, end: Long) = emptyList() + override suspend fun getSpo2ReadingsAfter(afterTimestamp: Long) = emptyList() + override suspend fun getAverageSpo2(start: Long, end: Long): Double? = null + override suspend fun getLatestSpo2Reading(): LatestSpo2? = null override suspend fun getActivitySessions(start: Long, end: Long) = emptyList() override suspend fun getTypicalSteps(dayOfWeek: Int) = emptyList() override suspend fun getTypicalSleepSeconds() = 0L diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt index afa2f202..79c3fed9 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt @@ -33,6 +33,7 @@ import io.rebble.libpebblecommon.database.entity.MuteState import io.rebble.libpebblecommon.database.entity.NotificationEntity import io.rebble.libpebblecommon.database.entity.NotificationRuleEntity import io.rebble.libpebblecommon.database.entity.OverlayDataEntity +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity import io.rebble.libpebblecommon.database.entity.TimelineNotification import io.rebble.libpebblecommon.services.DailySleep import io.rebble.libpebblecommon.database.entity.TimelinePin @@ -145,6 +146,15 @@ data class LatestHeartRate( val timestampEpochSec: Long, ) +/** + * Represents the most recent SpO2 (blood oxygen saturation) reading from the watch. + */ +data class LatestSpo2( + val spo2Percent: Int, + val quality: Int, + val timestampEpochSec: Long, +) + interface HealthDataApi { suspend fun getLatestTimestamp(): Long? suspend fun getHealthDataAfter(afterTimestamp: Long): List @@ -183,6 +193,18 @@ interface HealthDataApi { /** Returns minutes spent in each heart rate zone (keys: 0=rest, 1=light, 2=cardio, 3=high). */ suspend fun getHRZoneMinutes(start: Long, end: Long): Map + /** Returns SpO2 readings (percent > 0) in the epoch-second range. */ + suspend fun getSpo2Readings(start: Long, end: Long): List + + /** Returns SpO2 readings after the given timestamp, in ascending order. */ + suspend fun getSpo2ReadingsAfter(afterTimestamp: Long): List + + /** Returns the average SpO2 percent for readings in the range, or null if none. */ + suspend fun getAverageSpo2(start: Long, end: Long): Double? + + /** Returns the most recent SpO2 reading, or null if none exists. */ + suspend fun getLatestSpo2Reading(): LatestSpo2? + /** Returns activity overlay entries (Walk, Run, OpenWorkout types) in the range. */ suspend fun getActivitySessions(start: Long, end: Long): List diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/endpointmanager/blobdb/BlobDB.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/endpointmanager/blobdb/BlobDB.kt index 3c1a611a..ab59e2be 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/endpointmanager/blobdb/BlobDB.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/endpointmanager/blobdb/BlobDB.kt @@ -300,8 +300,9 @@ class BlobDB( } // The watch firmware sends health settings (activityPreferences, unitsDistance, - // hrmPreferences, heartRatePreferences) via the WatchPrefs BlobDB, but the phone stores - // them in HealthParams. Route those keys to the health settings DAO. + // hrmPreferences, heartRatePreferences, bloodOxygenPreferences, bloodOxygenActivityPreferences, + // spo2Preferences) via the WatchPrefs BlobDB, but the phone stores them in HealthParams. Route + // those keys to the health settings DAO. private fun effectiveDatabaseFor(message: DbWrite): BlobDatabase { if (message.database != BlobDatabase.WatchPrefs) return message.database val key = message.key.toByteArray().decodeToString().trimEnd(NUL_CHAR) @@ -309,7 +310,10 @@ class BlobDB( "activityPreferences", "unitsDistance", "hrmPreferences", - "heartRatePreferences" -> BlobDatabase.HealthParams + "heartRatePreferences", + "bloodOxygenPreferences", + "bloodOxygenActivityPreferences", + "spo2Preferences" -> BlobDatabase.HealthParams else -> message.database } } diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt index 4d90fcf6..060329c7 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt @@ -19,6 +19,7 @@ import io.rebble.libpebblecommon.database.dao.LockerEntryRealDao import io.rebble.libpebblecommon.database.dao.NotificationAppRealDao import io.rebble.libpebblecommon.database.dao.NotificationDao import io.rebble.libpebblecommon.database.dao.NotificationRuleDao +import io.rebble.libpebblecommon.database.dao.Spo2Dao import io.rebble.libpebblecommon.database.dao.TimelineNotificationRealDao import io.rebble.libpebblecommon.database.dao.TimelinePinRealDao import io.rebble.libpebblecommon.database.dao.TimelineReminderRealDao @@ -45,6 +46,7 @@ import io.rebble.libpebblecommon.database.entity.NotificationAppItemSyncEntity import io.rebble.libpebblecommon.database.entity.NotificationEntity import io.rebble.libpebblecommon.database.entity.NotificationRuleEntity import io.rebble.libpebblecommon.database.entity.OverlayDataEntity +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity import io.rebble.libpebblecommon.database.entity.TimelineNotificationEntity import io.rebble.libpebblecommon.database.entity.TimelineNotificationSyncEntity import io.rebble.libpebblecommon.database.entity.TimelinePinEntity @@ -83,6 +85,7 @@ internal const val DATABASE_FILENAME = "libpebble3.db" VibePatternEntity::class, HealthDataEntity::class, OverlayDataEntity::class, + Spo2ReadingEntity::class, HealthStatEntity::class, HealthStatSyncEntity::class, WatchPrefItemEntity::class, @@ -93,7 +96,7 @@ internal const val DATABASE_FILENAME = "libpebble3.db" AppPrefsEntrySyncEntity::class, NotificationRuleEntity::class, ], - version = 39, + version = 40, autoMigrations = [ AutoMigration(from = 10, to = 11), AutoMigration(from = 11, to = 12), @@ -124,6 +127,7 @@ internal const val DATABASE_FILENAME = "libpebble3.db" AutoMigration(from = 36, to = 37), AutoMigration(from = 37, to = 38), AutoMigration(from = 38, to = 39), + AutoMigration(from = 39, to = 40), ], exportSchema = true, ) @@ -143,6 +147,7 @@ abstract class Database : RoomDatabase() { abstract fun contactDao(): ContactDao abstract fun vibePatternDao(): VibePatternDao abstract fun healthDao(): HealthDao + abstract fun spo2Dao(): Spo2Dao abstract fun healthStatDao(): HealthStatDao abstract fun watchPrefDao(): WatchPrefRealDao abstract fun weatherAppDao(): WeatherAppRealDao diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/dao/HealthSettingsRealDao.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/dao/HealthSettingsRealDao.kt index dd6b4066..27b68cc5 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/dao/HealthSettingsRealDao.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/dao/HealthSettingsRealDao.kt @@ -7,6 +7,12 @@ import io.rebble.libpebblecommon.database.asMillisecond import io.rebble.libpebblecommon.database.entity.ActivityPrefsBlobItem import io.rebble.libpebblecommon.database.entity.ActivityPrefsValue import io.rebble.libpebblecommon.database.entity.ActivityPrefsValue.Companion.encodeToString +import io.rebble.libpebblecommon.database.entity.BloodOxygenActivityPreferencesBlobItem +import io.rebble.libpebblecommon.database.entity.BloodOxygenActivityPreferencesValue +import io.rebble.libpebblecommon.database.entity.BloodOxygenActivityPreferencesValue.Companion.encodeToString +import io.rebble.libpebblecommon.database.entity.BloodOxygenPreferencesBlobItem +import io.rebble.libpebblecommon.database.entity.BloodOxygenPreferencesValue +import io.rebble.libpebblecommon.database.entity.BloodOxygenPreferencesValue.Companion.encodeToString import io.rebble.libpebblecommon.database.entity.DistanceUnitsBlobItem import io.rebble.libpebblecommon.database.entity.HRMonitoringInterval import io.rebble.libpebblecommon.database.entity.HealthGender @@ -19,6 +25,9 @@ import io.rebble.libpebblecommon.database.entity.HeartRatePreferencesValue.Compa import io.rebble.libpebblecommon.database.entity.HrmPreferencesBlobItem import io.rebble.libpebblecommon.database.entity.HrmPreferencesValue import io.rebble.libpebblecommon.database.entity.HrmPreferencesValue.Companion.encodeToString +import io.rebble.libpebblecommon.database.entity.Spo2PreferencesBlobItem +import io.rebble.libpebblecommon.database.entity.Spo2PreferencesValue +import io.rebble.libpebblecommon.database.entity.Spo2PreferencesValue.Companion.encodeToString import io.rebble.libpebblecommon.database.entity.UnitsDistanceValue import io.rebble.libpebblecommon.database.entity.UnitsDistanceValue.Companion.encodeToString import io.rebble.libpebblecommon.packets.blobdb.BlobResponse @@ -97,6 +106,31 @@ interface HealthSettingsEntryRealDao : HealthSettingsEntryDao { zone3Threshold = blob.zone3Threshold.get().toShort(), ).encodeToString() } + "bloodOxygenPreferences" -> { + val blob = BloodOxygenPreferencesBlobItem(enabled = false) + blob.fromBytes(value) + BloodOxygenPreferencesValue( + enabled = blob.enabled.get() != 0.toByte(), + ).encodeToString() + } + "bloodOxygenActivityPreferences" -> { + val blob = BloodOxygenActivityPreferencesBlobItem(enabled = false) + blob.fromBytes(value) + BloodOxygenActivityPreferencesValue( + enabled = blob.enabled.get() != 0.toByte(), + ).encodeToString() + } + "spo2Preferences" -> { + val blob = Spo2PreferencesBlobItem(measurementInterval = 0) + blob.fromBytes(value) + // Only 0..3 are valid; the watch clamps anything else to 10 min on read. + val interval = HRMonitoringInterval.entries + .firstOrNull { it.value == blob.measurementInterval.get() } + ?: HRMonitoringInterval.TenMin + Spo2PreferencesValue( + measurementInterval = interval, + ).encodeToString() + } else -> { logger.w { "Unknown health settings key from watch: $key" } return BlobResponse.BlobStatus.Success diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/dao/Spo2Dao.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/dao/Spo2Dao.kt new file mode 100644 index 00000000..5d638940 --- /dev/null +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/dao/Spo2Dao.kt @@ -0,0 +1,31 @@ +package io.rebble.libpebblecommon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity + +@Dao +interface Spo2Dao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSpo2Readings(readings: List) + + @Query("SELECT * FROM spo2_readings WHERE timestamp >= :start AND timestamp < :end ORDER BY timestamp ASC") + suspend fun getSpo2Readings(start: Long, end: Long): List + + @Query("SELECT * FROM spo2_readings WHERE timestamp > :afterTimestamp ORDER BY timestamp ASC") + suspend fun getSpo2ReadingsAfter(afterTimestamp: Long): List + + @Query("SELECT * FROM spo2_readings ORDER BY timestamp DESC LIMIT 1") + suspend fun getLatestSpo2Reading(): Spo2ReadingEntity? + + @Query("SELECT MAX(timestamp) FROM spo2_readings") + suspend fun getLatestTimestamp(): Long? + + @Query("SELECT AVG(spo2Percent) FROM spo2_readings WHERE timestamp >= :start AND timestamp < :end") + suspend fun getAverageSpo2(start: Long, end: Long): Double? + + @Query("DELETE FROM spo2_readings WHERE timestamp < :expirationTimestamp") + suspend fun deleteExpiredSpo2Data(expirationTimestamp: Long): Int +} diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/HealthSettings.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/HealthSettings.kt index 37b59a45..fb0a6b47 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/HealthSettings.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/HealthSettings.kt @@ -9,10 +9,16 @@ import io.rebble.libpebblecommon.database.dao.BlobDbItem import io.rebble.libpebblecommon.database.dao.ValueParams import io.rebble.libpebblecommon.database.entity.ActivityPrefsValue.Companion.asBytes import io.rebble.libpebblecommon.database.entity.ActivityPrefsValue.Companion.encodeToString +import io.rebble.libpebblecommon.database.entity.BloodOxygenActivityPreferencesValue.Companion.asBytes +import io.rebble.libpebblecommon.database.entity.BloodOxygenActivityPreferencesValue.Companion.encodeToString +import io.rebble.libpebblecommon.database.entity.BloodOxygenPreferencesValue.Companion.asBytes +import io.rebble.libpebblecommon.database.entity.BloodOxygenPreferencesValue.Companion.encodeToString import io.rebble.libpebblecommon.database.entity.HeartRatePreferencesValue.Companion.asBytes import io.rebble.libpebblecommon.database.entity.HeartRatePreferencesValue.Companion.encodeToString import io.rebble.libpebblecommon.database.entity.HrmPreferencesValue.Companion.asBytes import io.rebble.libpebblecommon.database.entity.HrmPreferencesValue.Companion.encodeToString +import io.rebble.libpebblecommon.database.entity.Spo2PreferencesValue.Companion.asBytes +import io.rebble.libpebblecommon.database.entity.Spo2PreferencesValue.Companion.encodeToString import io.rebble.libpebblecommon.database.entity.UnitsDistanceValue.Companion.asBytes import io.rebble.libpebblecommon.database.entity.UnitsDistanceValue.Companion.encodeToString import io.rebble.libpebblecommon.health.HealthSettings @@ -20,7 +26,7 @@ import io.rebble.libpebblecommon.packets.ProtocolCapsFlag import io.rebble.libpebblecommon.services.FirmwareVersion import io.rebble.libpebblecommon.structmapper.SBoolean import io.rebble.libpebblecommon.structmapper.SByte -import io.rebble.libpebblecommon.structmapper.SFixedString +import io.rebble.libpebblecommon.structmapper.SNullTerminatedString import io.rebble.libpebblecommon.structmapper.SOptional import io.rebble.libpebblecommon.structmapper.SUByte import io.rebble.libpebblecommon.structmapper.SUShort @@ -55,11 +61,11 @@ data class HealthSettingsEntry( @ColumnInfo(defaultValue = "0") val timestamp: MillisecondInstant = MillisecondInstant(Instant.fromEpochMilliseconds(0)), ) : BlobDbItem { - override fun key(): UByteArray = SFixedString( - mapper = StructMapper(), - initialSize = id.length, - default = id, - ).toBytes() + // Keys are sent NUL-terminated (key_size = strlen+1), matching every other synced setting + // (WatchPrefs) and the watch's strlen+1 backing-store lookup. Sending the key without the + // trailing NUL writes the bytes to flash but the live in-memory value never updates. + override fun key(): UByteArray = + SNullTerminatedString(StructMapper(), id).toBytes() override fun value(params: ValueParams): UByteArray? { if (!params.capabilities.contains(ProtocolCapsFlag.SupportsHealthInsights)) { @@ -70,6 +76,9 @@ data class HealthSettingsEntry( KEY_HRM_PREFERENCES -> HrmPreferencesValue.fromString(value)?.asBytes(params.firmwareVersion) KEY_UNITS_DISTANCE -> UnitsDistanceValue.fromString(value)?.asBytes() KEY_HEART_RATE_PREFERENCES -> HeartRatePreferencesValue.fromString(value)?.asBytes() + KEY_BLOOD_OXYGEN_PREFERENCES -> BloodOxygenPreferencesValue.fromString(value)?.asBytes() + KEY_BLOOD_OXYGEN_ACTIVITY_PREFERENCES -> BloodOxygenActivityPreferencesValue.fromString(value)?.asBytes() + KEY_SPO2_PREFERENCES -> Spo2PreferencesValue.fromString(value)?.asBytes() else -> null } } @@ -83,6 +92,9 @@ private const val KEY_ACTIVITY_PREFERENCES = "activityPreferences" private const val KEY_HRM_PREFERENCES = "hrmPreferences" private const val KEY_UNITS_DISTANCE = "unitsDistance" private const val KEY_HEART_RATE_PREFERENCES = "heartRatePreferences" +private const val KEY_BLOOD_OXYGEN_PREFERENCES = "bloodOxygenPreferences" +private const val KEY_BLOOD_OXYGEN_ACTIVITY_PREFERENCES = "bloodOxygenActivityPreferences" +private const val KEY_SPO2_PREFERENCES = "spo2Preferences" private val json = Json { ignoreUnknownKeys = true } // ActivityHRMSettings struct grew in firmware: @@ -117,12 +129,32 @@ fun HealthSettingsEntryDao.getWatchSettings(): Flow { val heartRatePrefsFlow = getEntryFlow(KEY_HEART_RATE_PREFERENCES).map { HeartRatePreferencesValue.fromString(it?.value) ?: HeartRatePreferencesValue() } + val bloodOxygenPrefsFlow = getEntryFlow(KEY_BLOOD_OXYGEN_PREFERENCES).map { + BloodOxygenPreferencesValue.fromString(it?.value) ?: BloodOxygenPreferencesValue() + } + val bloodOxygenActivityPrefsFlow = getEntryFlow(KEY_BLOOD_OXYGEN_ACTIVITY_PREFERENCES).map { + BloodOxygenActivityPreferencesValue.fromString(it?.value) ?: BloodOxygenActivityPreferencesValue() + } + val spo2PrefsFlow = getEntryFlow(KEY_SPO2_PREFERENCES).map { + Spo2PreferencesValue.fromString(it?.value) ?: Spo2PreferencesValue() + } + // kotlinx.coroutines.flow.combine only has typed overloads up to 5 flows; bundle the three SpO2 + // prefs into a triple so the outer combine stays within that limit. + val bloodOxygenCombined = combine( + bloodOxygenPrefsFlow, + bloodOxygenActivityPrefsFlow, + spo2PrefsFlow, + ) { prefs, activityPrefs, spo2Prefs -> + Triple(prefs, activityPrefs, spo2Prefs) + } return combine( activityPrefsFlow, unitPrefsFlow, hrmPrefsFlow, heartRatePrefsFlow, - ) { activityPrefs, unitPrefs, hrmPrefs, heartRatePrefs -> + bloodOxygenCombined, + ) { activityPrefs, unitPrefs, hrmPrefs, heartRatePrefs, bloodOxygen -> + val (bloodOxygenPrefs, bloodOxygenActivityPrefs, spo2Prefs) = bloodOxygen HealthSettings( heightMm = activityPrefs.heightMm, weightDag = activityPrefs.weightDag, @@ -141,6 +173,9 @@ fun HealthSettingsEntryDao.getWatchSettings(): Flow { hrZone1Threshold = heartRatePrefs.zone1Threshold, hrZone2Threshold = heartRatePrefs.zone2Threshold, hrZone3Threshold = heartRatePrefs.zone3Threshold, + bloodOxygenEnabled = bloodOxygenPrefs.enabled, + bloodOxygenActivityEnabled = bloodOxygenActivityPrefs.enabled, + spo2MeasurementInterval = spo2Prefs.measurementInterval, ) } } @@ -196,6 +231,33 @@ suspend fun HealthSettingsEntryDao.setWatchSettings(healthSettings: HealthSettin timestamp = now, ) ) + insertOrReplace( + HealthSettingsEntry( + id = KEY_BLOOD_OXYGEN_PREFERENCES, + value = BloodOxygenPreferencesValue( + enabled = healthSettings.bloodOxygenEnabled, + ).encodeToString(), + timestamp = now, + ) + ) + insertOrReplace( + HealthSettingsEntry( + id = KEY_BLOOD_OXYGEN_ACTIVITY_PREFERENCES, + value = BloodOxygenActivityPreferencesValue( + enabled = healthSettings.bloodOxygenActivityEnabled, + ).encodeToString(), + timestamp = now, + ) + ) + insertOrReplace( + HealthSettingsEntry( + id = KEY_SPO2_PREFERENCES, + value = Spo2PreferencesValue( + measurementInterval = healthSettings.spo2MeasurementInterval, + ).encodeToString(), + timestamp = now, + ) + ) } @Serializable @@ -285,6 +347,63 @@ data class HeartRatePreferencesValue( } } +/** + * Blood oxygen (SpO2) monitoring preference. Defaults to OFF — the watch gates SpO2 on this + * synced setting (the master on/off), so nothing is produced until it's enabled. Single byte: + * 0x01 enabled, 0x00 disabled. The sampling *rate* is controlled independently by + * [Spo2PreferencesValue]; SpO2 only runs when this is enabled AND the rate != Disabled. + */ +@Serializable +data class BloodOxygenPreferencesValue( + val enabled: Boolean = false, +) { + companion object { + fun BloodOxygenPreferencesValue.encodeToString(): String = json.encodeToString(this) + fun fromString(value: String?): BloodOxygenPreferencesValue? = value?.let { json.decodeFromString(value) } + fun BloodOxygenPreferencesValue.asBytes(): UByteArray = BloodOxygenPreferencesBlobItem( + enabled = enabled, + ).toBytes() + } +} + +/** + * Blood oxygen (SpO2) during-activities preference. When enabled alongside "HR During Activities" + * (hrmPreferences.activity_tracking_enabled), the watch takes ~one SpO2 reading every 5 minutes + * during an auto-detected activity. Defaults to OFF. Same single-byte encoding as + * [BloodOxygenPreferencesValue]: 0x01 enabled, 0x00 disabled. + */ +@Serializable +data class BloodOxygenActivityPreferencesValue( + val enabled: Boolean = false, +) { + companion object { + fun BloodOxygenActivityPreferencesValue.encodeToString(): String = json.encodeToString(this) + fun fromString(value: String?): BloodOxygenActivityPreferencesValue? = value?.let { json.decodeFromString(value) } + fun BloodOxygenActivityPreferencesValue.asBytes(): UByteArray = BloodOxygenActivityPreferencesBlobItem( + enabled = enabled, + ).toBytes() + } +} + +/** + * SpO2 monitoring *rate* (measurement interval). Independent of the [BloodOxygenPreferencesValue] + * master on/off — the watch only samples when that toggle is enabled AND this rate != Disabled. + * Single byte encoding the [HRMonitoringInterval] enum (0..3); values >= 4 are not valid. The + * watch enforces value_len == 1 on read-back, so the blob must be exactly one byte. + */ +@Serializable +data class Spo2PreferencesValue( + val measurementInterval: HRMonitoringInterval = HRMonitoringInterval.TenMin, +) { + companion object { + fun Spo2PreferencesValue.encodeToString(): String = json.encodeToString(this) + fun fromString(value: String?): Spo2PreferencesValue? = value?.let { json.decodeFromString(value) } + fun Spo2PreferencesValue.asBytes(): UByteArray = Spo2PreferencesBlobItem( + measurementInterval = measurementInterval.value, + ).toBytes() + } +} + class ActivityPrefsBlobItem( heightMm: UShort, weightDag: UShort, @@ -337,6 +456,24 @@ class HeartRatePreferencesBlobItem( val zone3Threshold = SUByte(m, zone3Threshold) } +class BloodOxygenPreferencesBlobItem( + enabled: Boolean, +) : StructMappable(endianness = Endian.Little) { + val enabled = SByte(m, if (enabled) 0x01 else 0x00) +} + +class BloodOxygenActivityPreferencesBlobItem( + enabled: Boolean, +) : StructMappable(endianness = Endian.Little) { + val enabled = SByte(m, if (enabled) 0x01 else 0x00) +} + +class Spo2PreferencesBlobItem( + measurementInterval: Byte, +) : StructMappable(endianness = Endian.Little) { + val measurementInterval = SByte(m, measurementInterval) +} + enum class HealthGender( val value: Byte, ) { diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/Spo2ReadingEntity.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/Spo2ReadingEntity.kt new file mode 100644 index 00000000..0cc2dc7b --- /dev/null +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/Spo2ReadingEntity.kt @@ -0,0 +1,21 @@ +package io.rebble.libpebblecommon.database.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Per-minute SpO2 (blood oxygen saturation) reading, parsed from the ActivityMinuteData + * data-logging stream (tag 81) at record version 14+. Only samples where spo2_percent > 0 + * are persisted; the watch produces a real reading only ~once per measurement interval. + * + * Keyed/deduped by the minute timestamp (the sample's UTC) via INSERT OR REPLACE. + */ +@Entity(tableName = "spo2_readings") +data class Spo2ReadingEntity( + @PrimaryKey + val timestamp: Long, + /** Blood oxygen saturation percent (0–100). Never persisted as 0. */ + val spo2Percent: Int, + /** Reading quality, 0–7 (mirrors the firmware HeartRateQuality scale). */ + val quality: Int, +) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/WatchPrefEntity.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/WatchPrefEntity.kt index 2c194987..9f600b73 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/WatchPrefEntity.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/WatchPrefEntity.kt @@ -648,6 +648,9 @@ enum class ColorWatchPref( - alarmsAppOpened - hrmPreferences (see HealthSettingsEntry) - heartRatePreferences (see HealthSettingsEntry) + - bloodOxygenPreferences (see HealthSettingsEntry) + - bloodOxygenActivityPreferences (see HealthSettingsEntry) + - spo2Preferences (see HealthSettingsEntry) - workerId (need UI to figure out which apps are eligible) - dndWeekdaySchedule (need to figure out how to do this) - dndWeekdayScheduleEnabled diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/HealthDataProcessor.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/HealthDataProcessor.kt index 3f358991..0c0772b3 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/HealthDataProcessor.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/HealthDataProcessor.kt @@ -3,6 +3,7 @@ package io.rebble.libpebblecommon.datalogging import co.touchlab.kermit.Logger import io.rebble.libpebblecommon.SystemAppIDs.SYSTEM_APP_UUID import io.rebble.libpebblecommon.database.dao.HealthDao +import io.rebble.libpebblecommon.database.dao.Spo2Dao import io.rebble.libpebblecommon.database.dao.insertHealthDataWithPriority import io.rebble.libpebblecommon.database.dao.insertOverlayDataWithDeduplication import io.rebble.libpebblecommon.database.entity.HealthStatDao @@ -35,6 +36,7 @@ class HealthDataProcessor( private val scope: LibPebbleCoroutineScope, private val healthDao: HealthDao, private val healthStatDao: HealthStatDao, + private val spo2Dao: Spo2Dao, ) { private val logger = Logger.withTag("HealthDataProcessor") @@ -187,9 +189,12 @@ class HealthDataProcessor( } private suspend fun processStepsData(payload: ByteArray, itemSize: UShort): String? { - val records = parseStepsData(payload, itemSize) - logger.d { "HEALTH_DATA: Parsed ${records.size} step records from payload" } - if (records.isEmpty()) { + val parsed = parseStepsData(payload, itemSize) + val records = parsed.healthData + logger.d { + "HEALTH_DATA: Parsed ${records.size} step records and ${parsed.spo2Readings.size} SpO2 readings from payload" + } + if (records.isEmpty() && parsed.spo2Readings.isEmpty()) { return null } @@ -213,11 +218,18 @@ class HealthDataProcessor( "HEALTH_DATA: About to insert ${records.size} records into database (steps=$totalSteps, active=${totalActiveKcal}kcal, resting=${totalRestingKcal}kcal, distance=${totalDistanceKm}km, activeMin=$totalActiveMin, $hrSummary)" } - healthDao.insertHealthDataWithPriority(records) + if (records.isNotEmpty()) { + healthDao.insertHealthDataWithPriority(records) + } + if (parsed.spo2Readings.isNotEmpty()) { + spo2Dao.insertSpo2Readings(parsed.spo2Readings) + logger.d { "HEALTH_DATA: Inserted ${parsed.spo2Readings.size} SpO2 readings" } + } _healthDataUpdated.emit(Unit) logger.d { "HEALTH_DATA: Health update event emitted successfully" } - return "${records.size} records (${totalSteps} steps)" + return "${records.size} records (${totalSteps} steps)" + + if (parsed.spo2Readings.isNotEmpty()) ", ${parsed.spo2Readings.size} SpO2" else "" } private suspend fun processOverlayData(payload: ByteArray, itemSize: UShort): String? { diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt index 3bd95c98..e1571077 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt @@ -354,6 +354,7 @@ fun initKoin( single { get().vibePatternDao() } single { get().notificationRuleDao() } single { get().healthDao() } + single { get().spo2Dao() } single { get().healthStatDao() } singleOf(::HealthDataProcessor) single { get().watchPrefDao() } diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt index 8af9219c..37ea3d8e 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt @@ -5,14 +5,17 @@ import io.rebble.libpebblecommon.connection.ConnectedPebbleDevice import io.rebble.libpebblecommon.connection.HealthApi import io.rebble.libpebblecommon.connection.HealthDataApi import io.rebble.libpebblecommon.connection.LatestHeartRate +import io.rebble.libpebblecommon.connection.LatestSpo2 import io.rebble.libpebblecommon.connection.WatchManager import io.rebble.libpebblecommon.database.dao.HealthDao +import io.rebble.libpebblecommon.database.dao.Spo2Dao import io.rebble.libpebblecommon.database.entity.HealthDataEntity import io.rebble.libpebblecommon.database.entity.HRMonitoringInterval import io.rebble.libpebblecommon.database.entity.HealthGender import io.rebble.libpebblecommon.database.entity.HealthSettingsEntryDao import io.rebble.libpebblecommon.database.entity.HealthStatDao import io.rebble.libpebblecommon.database.entity.OverlayDataEntity +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity import io.rebble.libpebblecommon.database.entity.getWatchSettings import io.rebble.libpebblecommon.database.entity.setWatchSettings import io.rebble.libpebblecommon.datalogging.HealthDataProcessor @@ -44,6 +47,7 @@ class Health( private val libPebbleCoroutineScope: LibPebbleCoroutineScope, private val healthDao: HealthDao, private val healthStatDao: HealthStatDao, + private val spo2Dao: Spo2Dao, private val watchManager: WatchManager, private val healthDataProcessor: HealthDataProcessor, ) : HealthApi, HealthDataApi { @@ -226,6 +230,24 @@ class Health( override suspend fun getHRZoneMinutes(start: Long, end: Long): Map = healthDao.getHeartRateZoneMinutes(start, end).associate { it.heartRateZone to it.minutes } + override suspend fun getSpo2Readings(start: Long, end: Long): List = + spo2Dao.getSpo2Readings(start, end) + + override suspend fun getSpo2ReadingsAfter(afterTimestamp: Long): List = + spo2Dao.getSpo2ReadingsAfter(afterTimestamp) + + override suspend fun getAverageSpo2(start: Long, end: Long): Double? = + spo2Dao.getAverageSpo2(start, end) + + override suspend fun getLatestSpo2Reading(): LatestSpo2? { + val entry = spo2Dao.getLatestSpo2Reading() ?: return null + return LatestSpo2( + spo2Percent = entry.spo2Percent, + quality = entry.quality, + timestampEpochSec = entry.timestamp, + ) + } + override suspend fun getActivitySessions(start: Long, end: Long): List = healthDao.getOverlayEntries(start, end, listOf( OverlayType.Walk.value, OverlayType.Run.value, OverlayType.OpenWorkout.value @@ -450,6 +472,12 @@ data class HealthSettings( val hrZone1Threshold: Short, val hrZone2Threshold: Short, val hrZone3Threshold: Short, + // BloodOxygenPreferences + val bloodOxygenEnabled: Boolean, + // BloodOxygenActivityPreferences (SpO2 during activities; gated by HR During Activities) + val bloodOxygenActivityEnabled: Boolean, + // Spo2Preferences — SpO2 monitoring rate; sampling runs only when bloodOxygenEnabled && rate != Disabled + val spo2MeasurementInterval: HRMonitoringInterval, ) /** Time range for displaying health data */ diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/parsers/HealthDataParser.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/parsers/HealthDataParser.kt index 321fe943..57ce5cdc 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/parsers/HealthDataParser.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/parsers/HealthDataParser.kt @@ -3,12 +3,23 @@ package io.rebble.libpebblecommon.health.parsers import co.touchlab.kermit.Logger import io.rebble.libpebblecommon.database.entity.HealthDataEntity import io.rebble.libpebblecommon.database.entity.OverlayDataEntity +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity import io.rebble.libpebblecommon.health.OverlayType import io.rebble.libpebblecommon.util.DataBuffer import io.rebble.libpebblecommon.util.Endian private val logger = Logger.withTag("HealthDataParser") +/** + * Result of parsing an ActivityMinuteData (tag 81) payload. Step/HR/overlay data goes into + * [healthData]; any per-minute SpO2 readings (version 14+ only, where spo2_percent > 0) go into + * [spo2Readings] keyed by their minute timestamp. + */ +data class ParsedHealthData( + val healthData: List, + val spo2Readings: List, +) + /** * Parses step/movement data from the watch's health payload. * @@ -18,19 +29,21 @@ private val logger = Logger.withTag("HealthDataParser") * - Firmware 4.0 (version 7) - adds heart rate data * - Firmware 4.1 (version 8) - adds heart rate weight * - Firmware 4.3 (version 13) - adds heart rate zone + * - Firmware 4.4 (version 14) - adds SpO2 (spo2_percent + spo2_quality) * * @param payload Raw byte array from the watch * @param itemSize Size of each data item in bytes - * @return List of parsed health data entities ready for database insertion + * @return Parsed health data entities (steps/HR) plus any SpO2 readings, ready for database insertion */ -fun parseStepsData(payload: ByteArray, itemSize: UShort): List { +fun parseStepsData(payload: ByteArray, itemSize: UShort): ParsedHealthData { if (payload.isEmpty() || itemSize.toInt() == 0) { logger.w { "Cannot parse steps data: empty payload or zero item size" } - return emptyList() + return ParsedHealthData(emptyList(), emptyList()) } val buffer = DataBuffer(payload.toUByteArray()) val records = mutableListOf() + val spo2Readings = mutableListOf() if (payload.size % itemSize.toInt() != 0) { logger.w { @@ -85,6 +98,8 @@ fun parseStepsData(payload: ByteArray, itemSize: UShort): List var heartRate = 0 var heartRateWeight = 0 var heartRateZone = 0 + var spo2Percent = 0 + var spo2Quality = 0 if (version >= VERSION_FW_3_11) { restingGramCalories = buffer.getUShort().toInt() @@ -104,6 +119,11 @@ fun parseStepsData(payload: ByteArray, itemSize: UShort): List heartRateZone = buffer.getUByte().toInt() } + if (version >= VERSION_FW_4_4) { + spo2Percent = buffer.getUByte().toInt() + spo2Quality = buffer.getUByte().toInt() + } + records.add( HealthDataEntity( timestamp = currentTimestamp.toLong(), @@ -121,6 +141,17 @@ fun parseStepsData(payload: ByteArray, itemSize: UShort): List ) ) + // Only persist SpO2 samples with an actual reading; most minutes carry 0. + if (spo2Percent > 0) { + spo2Readings.add( + Spo2ReadingEntity( + timestamp = currentTimestamp.toLong(), + spo2Percent = spo2Percent, + quality = spo2Quality, + ) + ) + } + currentTimestamp += 60u } @@ -135,11 +166,11 @@ fun parseStepsData(payload: ByteArray, itemSize: UShort): List break } } else if (consumed > expected) { - logger.w { "Health steps item over-read: consumed=$consumed, expected=$expected" } + logger.w { "Health steps item over-read: consumed=$consumed, expected=$expected" } } } - return records + return ParsedHealthData(records, spo2Readings) } /** @@ -245,10 +276,12 @@ private val VERSION_FW_3_11: UShort = 6u private val VERSION_FW_4_0: UShort = 7u private val VERSION_FW_4_1: UShort = 8u private val VERSION_FW_4_3: UShort = 13u +private val VERSION_FW_4_4: UShort = 14u private val SUPPORTED_STEP_VERSIONS = setOf( VERSION_FW_3_10_AND_BELOW, VERSION_FW_3_11, VERSION_FW_4_0, VERSION_FW_4_1, - VERSION_FW_4_3 + VERSION_FW_4_3, + VERSION_FW_4_4, ) diff --git a/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/health/HealthParsingTest.kt b/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/health/HealthParsingTest.kt index 6f207427..2daf13f2 100644 --- a/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/health/HealthParsingTest.kt +++ b/libpebble3/src/commonTest/kotlin/io/rebble/libpebblecommon/health/HealthParsingTest.kt @@ -1,11 +1,113 @@ package io.rebble.libpebblecommon.health +import io.rebble.libpebblecommon.health.parsers.parseStepsData import io.rebble.libpebblecommon.util.DataBuffer import io.rebble.libpebblecommon.util.Endian import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class HealthParsingTest { + @Test + fun testSpo2V14Parsing() { + // Header (9 bytes): Version(2), Timestamp(4), Unused(1), RecordLength(1), RecordNum(1) + // Sample (18 bytes v14): Steps(1), Orientation(1), VMC(2), Light(1), Flags(1), + // RestingCal(2), ActiveCal(2), Distance(2), HR(1), HRWeight(2), HRZone(1), + // SpO2%(1), SpO2Quality(1) + val baseTime = 1600000000u + val buffer = DataBuffer(UByteArray(45)) + buffer.setEndian(Endian.Little) + + // Header + buffer.putUShort(14u) // Version 14 + buffer.putUInt(baseTime) // Timestamp + buffer.putUByte(0u) // Unused (time_local_offset placeholder) + buffer.putUByte(18u) // RecordLength / sample_size + buffer.putUByte(2u) // 2 samples + + // Sample 1: real SpO2 reading + buffer.putUByte(100u) // Steps + buffer.putUByte(1u) // Orientation + buffer.putUShort(500u) // VMC + buffer.putUByte(10u) // Light + buffer.putUByte(2u) // Flags + buffer.putUShort(10u) // RestingCal + buffer.putUShort(50u) // ActiveCal + buffer.putUShort(7000u) // Distance + buffer.putUByte(60u) // HR + buffer.putUShort(1u) // HRWeight + buffer.putUByte(1u) // HRZone + buffer.putUByte(97u) // SpO2% + buffer.putUByte(6u) // SpO2 quality + + // Sample 2: no SpO2 reading (0 = none) + buffer.putUByte(150u) // Steps + buffer.putUByte(2u) // Orientation + buffer.putUShort(600u) // VMC + buffer.putUByte(20u) // Light + buffer.putUByte(0u) // Flags + buffer.putUShort(12u) // RestingCal + buffer.putUShort(60u) // ActiveCal + buffer.putUShort(8000u) // Distance + buffer.putUByte(65u) // HR + buffer.putUShort(1u) // HRWeight + buffer.putUByte(1u) // HRZone + buffer.putUByte(0u) // SpO2% (no reading) + buffer.putUByte(0u) // SpO2 quality + + val parsed = parseStepsData(buffer.array().toByteArray(), itemSize = 45u) + + assertEquals(2, parsed.healthData.size) + assertEquals(1, parsed.spo2Readings.size) + + val healthData = parsed.healthData + assertEquals(100, healthData[0].steps) + assertEquals(60, healthData[0].heartRate) + assertEquals(150, healthData[1].steps) + assertEquals(65, healthData[1].heartRate) + + val spo2 = parsed.spo2Readings.first() + assertEquals(baseTime.toLong(), spo2.timestamp) + assertEquals(97, spo2.spo2Percent) + assertEquals(6, spo2.quality) + } + + @Test + fun testV13BackwardCompatibility() { + // v13 sample size is 16 bytes (no SpO2 fields). Ensure HR/steps still parse. + val baseTime = 1600001000u + val buffer = DataBuffer(UByteArray(25)) + buffer.setEndian(Endian.Little) + + buffer.putUShort(13u) // Version 13 + buffer.putUInt(baseTime) + buffer.putUByte(0u) + buffer.putUByte(16u) // sample_size / recordLength + buffer.putUByte(1u) // 1 sample + + buffer.putUByte(42u) // Steps + buffer.putUByte(3u) // Orientation + buffer.putUShort(250u) // VMC + buffer.putUByte(5u) // Light + buffer.putUByte(0u) // Flags + buffer.putUShort(8u) // RestingCal + buffer.putUShort(30u) // ActiveCal + buffer.putUShort(4000u) // Distance + buffer.putUByte(72u) // HR + buffer.putUShort(2u) // HRWeight + buffer.putUByte(2u) // HRZone + + val parsed = parseStepsData(buffer.array().toByteArray(), itemSize = 25u) + + assertEquals(1, parsed.healthData.size) + assertTrue(parsed.spo2Readings.isEmpty()) + + val sample = parsed.healthData.first() + assertEquals(42, sample.steps) + assertEquals(72, sample.heartRate) + assertEquals(baseTime.toLong(), sample.timestamp) + } + @Test fun testStepsParsing() { // Simulate a raw steps record buffer diff --git a/pebble/build.gradle.kts b/pebble/build.gradle.kts index 880ebb1d..8161d57b 100644 --- a/pebble/build.gradle.kts +++ b/pebble/build.gradle.kts @@ -139,6 +139,8 @@ kotlin { // commonMain by default and will correctly pull the Android artifacts of any KMP // dependencies declared in commonMain. implementation(compose.uiTooling) + // SpO2 (blood oxygen) Health Connect writing; health-kmp 1.4.0 lacks OxygenSaturation. + implementation(libs.health.connect) } } diff --git a/pebble/src/androidMain/AndroidManifest.xml b/pebble/src/androidMain/AndroidManifest.xml index 1b6a7a61..af582571 100644 --- a/pebble/src/androidMain/AndroidManifest.xml +++ b/pebble/src/androidMain/AndroidManifest.xml @@ -2,10 +2,22 @@ + + + + - \ No newline at end of file + diff --git a/pebble/src/androidMain/kotlin/coredevices/pebble/health/HealthConnectPermissions.android.kt b/pebble/src/androidMain/kotlin/coredevices/pebble/health/HealthConnectPermissions.android.kt new file mode 100644 index 00000000..24569701 --- /dev/null +++ b/pebble/src/androidMain/kotlin/coredevices/pebble/health/HealthConnectPermissions.android.kt @@ -0,0 +1,127 @@ +package coredevices.pebble.health + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.health.connect.client.PermissionController +import androidx.health.connect.client.permission.HealthPermission +import androidx.health.connect.client.records.ExerciseSessionRecord +import androidx.health.connect.client.records.HeartRateRecord +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.StepsRecord +import com.viktormykhailiv.kmp.health.HealthDataType +import com.viktormykhailiv.kmp.health.HealthManager +import kotlin.reflect.KClass +import kotlinx.coroutines.CompletableDeferred + +/** + * Requests every Health Connect write permission the app needs in a single system prompt. + * + * health-kmp 1.4.0 models its own type set and launches a separate permission activity for it; it + * also doesn't model OxygenSaturation, which previously forced a second prompt just for SpO2. To + * keep the UX to one prompt, this bypasses health-kmp's [requestAuthorization] on Android and asks + * for the standard permissions together with SpO2 via a single permission contract. SpO2 is treated + * as best-effort: the result reflects only whether the standard types were granted, so denying SpO2 + * doesn't disable sync for steps/heart rate/sleep/exercise. + */ +internal actual suspend fun requestPlatformHealthPermissions( + healthManager: HealthManager, + readTypes: List, + writeTypes: List, +): Boolean { + if (!healthManager.isAvailable().getOrDefault(false)) return false + val ctx = context() + val writePerms = writeTypes.flatMapTo(mutableSetOf()) { it.toHealthConnectWritePermissions() } + val readPerms = readTypes.flatMapTo(mutableSetOf()) { it.toHealthConnectReadPermissions() } + val standardPerms = writePerms + readPerms + val allPerms = standardPerms + WRITE_OXYGEN_SATURATION + val granted = HealthConnectPermissionBridge.request(ctx, allPerms) + return standardPerms.all { it in granted } +} + +private const val KEY_PERMISSIONS = "KEY_PERMISSIONS" + +private fun HealthDataType.toHealthConnectWritePermissions(): Set = when (this) { + HealthDataType.Steps -> setOf(writePermission(StepsRecord::class)) + HealthDataType.HeartRate -> setOf(writePermission(HeartRateRecord::class)) + HealthDataType.Sleep -> setOf(writePermission(SleepSessionRecord::class)) + is HealthDataType.Exercise -> setOf( + writePermission(ExerciseSessionRecord::class), + HealthPermission.PERMISSION_WRITE_EXERCISE_ROUTE, + ) + else -> emptySet() +} + +private fun HealthDataType.toHealthConnectReadPermissions(): Set = when (this) { + HealthDataType.Steps -> setOf(readPermission(StepsRecord::class)) + HealthDataType.HeartRate -> setOf(readPermission(HeartRateRecord::class)) + HealthDataType.Sleep -> setOf(readPermission(SleepSessionRecord::class)) + is HealthDataType.Exercise -> setOf(readPermission(ExerciseSessionRecord::class)) + else -> emptySet() +} + +private fun writePermission(recordType: KClass): String = + HealthPermission.getWritePermission(recordType) + +private fun readPermission(recordType: KClass): String = + HealthPermission.getReadPermission(recordType) + +internal object HealthConnectPermissionBridge { + @Volatile + private var pending: CompletableDeferred?>? = null + + suspend fun request(context: Context, permissions: Set): Set { + val deferred = begin() + return try { + context.startActivity( + Intent(context, HealthConnectWritePermissionActivity::class.java) + .putExtra(KEY_PERMISSIONS, permissions.toTypedArray()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + deferred.await() ?: emptySet() + } catch (e: Exception) { + logger.w(e) { "Failed launching Health Connect permission request" } + complete(null) + emptySet() + } + } + + private fun begin(): CompletableDeferred?> { + pending?.cancel() + val deferred = CompletableDeferred?>() + pending = deferred + return deferred + } + + fun complete(result: Set?) { + pending?.complete(result) + pending = null + } +} + +/** + * Translucent, no-history host that requests a set of Health Connect permissions in one prompt. + */ +class HealthConnectWritePermissionActivity : ComponentActivity() { + + private val requestPermissionLauncher = registerForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { granted: Set -> + HealthConnectPermissionBridge.complete(granted) + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val permissions = intent.getStringArrayExtra(KEY_PERMISSIONS)?.toSet().orEmpty() + try { + requestPermissionLauncher.launch(permissions) + } catch (e: Exception) { + logger.w(e) { "Failed launching Health Connect permission contract" } + HealthConnectPermissionBridge.complete(null) + finish() + } + } +} diff --git a/pebble/src/androidMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.android.kt b/pebble/src/androidMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.android.kt new file mode 100644 index 00000000..764f48c6 --- /dev/null +++ b/pebble/src/androidMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.android.kt @@ -0,0 +1,62 @@ +package coredevices.pebble.health + +import android.content.Context +import androidx.health.connect.client.HealthConnectClient +import androidx.health.connect.client.records.OxygenSaturationRecord +import androidx.health.connect.client.records.metadata.Device +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.units.Percentage +import co.touchlab.kermit.Logger +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity +import org.koin.mp.KoinPlatform +import java.time.Instant +import java.time.ZoneOffset + +internal val logger = Logger.withTag("OxygenSaturationHC") + +internal actual fun supportsOxygenSaturationWriting(): Boolean = true + +internal fun context(): Context = KoinPlatform.getKoin().get() + +private fun client(): HealthConnectClient = HealthConnectClient.getOrCreate(context()) + +internal const val WRITE_OXYGEN_SATURATION = "android.permission.health.WRITE_OXYGEN_SATURATION" + +internal actual suspend fun hasOxygenSaturationPermission(): Boolean { + return try { + if (HealthConnectClient.getSdkStatus(context()) != HealthConnectClient.SDK_AVAILABLE) return false + client().permissionController.getGrantedPermissions().contains(WRITE_OXYGEN_SATURATION) + } catch (e: Exception) { + logger.w(e) { "Failed checking oxygen saturation permission" } + false + } +} + +internal actual suspend fun writeOxygenSaturationToPlatform(readings: List): Boolean { + if (readings.isEmpty()) return true + return try { + if (!hasOxygenSaturationPermission()) { + logger.w { "Skipping SpO2 write: permission not granted" } + return false + } + val device = Device( + type = Device.TYPE_WATCH, + manufacturer = "Pebble", + model = "Watch", + ) + val records = readings.map { reading -> + OxygenSaturationRecord( + time = Instant.ofEpochSecond(reading.timestamp), + zoneOffset = ZoneOffset.UTC, + percentage = Percentage(reading.spo2Percent.toDouble()), + metadata = Metadata.autoRecorded(device = device), + ) + } + client().insertRecords(records) + logger.d { "Wrote ${records.size} SpO2 readings to Health Connect" } + true + } catch (e: Exception) { + logger.e(e) { "Failed writing SpO2 readings to Health Connect" } + false + } +} diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/health/HealthSyncTracker.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/health/HealthSyncTracker.kt index 196cf084..8ae74708 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/health/HealthSyncTracker.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/health/HealthSyncTracker.kt @@ -16,6 +16,7 @@ class HealthSyncTracker(private val settings: Settings) { private const val KEY_ENABLED = "health_platform_sync_enabled" private const val KEY_LAST_SYNCED_STEPS = "health_sync_last_steps_timestamp" private const val KEY_LAST_SYNCED_OVERLAY = "health_sync_last_overlay_timestamp" + private const val KEY_LAST_SYNCED_SPO2 = "health_sync_last_spo2_timestamp" } private val _enabled = MutableStateFlow(settings.getBoolean(KEY_ENABLED, false)) @@ -33,4 +34,8 @@ class HealthSyncTracker(private val settings: Settings) { var lastSyncedOverlayTimestamp: Long get() = settings.getLong(KEY_LAST_SYNCED_OVERLAY, 0L) set(value) { settings[KEY_LAST_SYNCED_OVERLAY] = value } + + var lastSyncedSpo2Timestamp: Long + get() = settings.getLong(KEY_LAST_SYNCED_SPO2, 0L) + set(value) { settings[KEY_LAST_SYNCED_SPO2] = value } } diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt new file mode 100644 index 00000000..818a74b2 --- /dev/null +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt @@ -0,0 +1,15 @@ +package coredevices.pebble.health + +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity + +/** + * Platform hooks for writing blood oxygen (SpO2) to the phone's health platform (Health Connect + * on Android). The health-kmp library (1.4.0) does not model OxygenSaturation, so SpO2 is + * handled through these expect/actual seams. On unsupported platforms every call is a no-op. + */ +internal expect fun supportsOxygenSaturationWriting(): Boolean + +internal expect suspend fun hasOxygenSaturationPermission(): Boolean + +/** Writes the given SpO2 readings to the platform. Returns true on success. */ +internal expect suspend fun writeOxygenSaturationToPlatform(readings: List): Boolean diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/health/PlatformHealthSync.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/health/PlatformHealthSync.kt index fe00819e..74e83c21 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/health/PlatformHealthSync.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/health/PlatformHealthSync.kt @@ -31,6 +31,18 @@ internal expect fun exerciseWriteTypes(): List internal expect fun supportsSleepWriting(): Boolean +/** + * Requests the platform's health read/write permissions in a single prompt where the platform + * allows it. On Android this bundles the standard types together with blood oxygen (SpO2), which + * health-kmp can't model, so they don't surface as two separate prompts. Returns true if the + * standard types were granted (SpO2 is treated as best-effort on Android). + */ +internal expect suspend fun requestPlatformHealthPermissions( + healthManager: HealthManager, + readTypes: List, + writeTypes: List, +): Boolean + class PlatformHealthSync( private val libPebble: LibPebble, private val tracker: HealthSyncTracker, @@ -74,17 +86,13 @@ class PlatformHealthSync( /** Request write permissions. Returns true if granted. */ suspend fun requestPermissions(): Boolean { - val result = try { - healthManager.requestAuthorization( - readTypes = RequestedReadTypes, - writeTypes = RequestedWriteTypes, - ) + val success = try { + requestPlatformHealthPermissions(healthManager, RequestedReadTypes, RequestedWriteTypes) } catch (e: Exception) { logger.w(e) { "Health platform doesn't support requested types" } tracker.setEnabled(false) return false } - val success = result.getOrDefault(false) logger.v { "requestPermissions success=$success" } tracker.setEnabled(success) GlobalScope.launch { @@ -119,6 +127,7 @@ class PlatformHealthSync( } syncStepsAndHeartRate() syncOverlays() + syncOxygenSaturation() logger.d { "Health platform sync completed" } } catch (e: Exception) { logger.e(e) { "Health platform sync failed" } @@ -180,6 +189,21 @@ class PlatformHealthSync( } } + private suspend fun syncOxygenSaturation() { + if (!supportsOxygenSaturationWriting()) return + val lastTimestamp = tracker.lastSyncedSpo2Timestamp + val readings = healthDataApi.getSpo2ReadingsAfter(lastTimestamp) + if (readings.isEmpty()) return + + val result = writeOxygenSaturationToPlatform(readings) + if (result) { + tracker.lastSyncedSpo2Timestamp = readings.last().timestamp + logger.d { "Synced ${readings.size} SpO2 records" } + } else { + logger.e { "Failed to write SpO2 records: keeping last-synced cursor" } + } + } + private suspend fun syncOverlays() { val lastTimestamp = tracker.lastSyncedOverlayTimestamp val sleepTypes = listOf( diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthCharts.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthCharts.kt index bbf6293c..b4d10dde 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthCharts.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthCharts.kt @@ -42,6 +42,8 @@ internal val SleepHeaderColor = Color(0xFF065C91) internal val SleepBgColor = Color(0xFF014981) internal val HRHeaderColor = Color(0xFF7C33A5) internal val HRBgColor = Color(0xFF6A1B9A) +internal val SpO2HeaderColor = Color(0xFF0288D1) +internal val SpO2BgColor = Color(0xFF01579B) internal val ActivityFillColor = Color(0xFF00C896) internal val ActivityBarColor = Color(0xFFA8E6CF) @@ -53,6 +55,7 @@ internal val HRLineColor = Color(0xFFF0437D) internal val HRZone1Color = Color(0xFFF0437D) internal val HRZone2Color = Color(0xFF179AC6) internal val HRZone3Color = Color(0xFF00C3FD) +internal val SpO2LineColor = Color(0xFF4FC3F7) internal val BarAlpha = 0.85f internal val BarAlphaSelected = 1.0f @@ -354,6 +357,36 @@ internal fun HRLineChart(hrs: List, scrub: ScrubState, tm: androidx.com } } +@Composable +internal fun Spo2LineChart(values: List, scrub: ScrubState, tm: androidx.compose.ui.text.TextMeasurer) { + val ls = TextStyle(fontSize = 9.sp, color = AxisLabelColor); val si = scrub.scrubIndex + Canvas(Modifier.fillMaxWidth().height(160.dp).scrubGesture(values.size, scrub)) { + val lH = 14.dp.toPx(); val cH = size.height - lH + // SpO2 sits in a narrow band (~85–100); show that range so variation is visible. + val dataMin = values.filterNotNull().minOrNull() + val dataMax = values.filterNotNull().maxOrNull() + val mn = (dataMin?.let { it - 3 } ?: 85.0).coerceIn(70.0, 97.0) + val mx = (dataMax?.let { it + 3 } ?: 100.0).coerceIn(mn + 1.0, 100.0) + val rg = (mx - mn).coerceAtLeast(1.0) + drawRect(ChartOverlayColor, Offset.Zero, Size(size.width, cH)) + val sx = size.width / (values.size - 1).coerceAtLeast(1) + val pts = values.mapIndexedNotNull { i, v -> v?.let { Offset(i * sx, cH - ((it - mn) / rg * cH).toFloat()) } } + if (pts.size >= 2) { + drawPath(smoothFilledPath(pts, cH), SpO2LineColor.copy(alpha = 0.2f)) + drawPath(smoothLinePath(pts), SpO2LineColor, style = Stroke(2.dp.toPx(), cap = StrokeCap.Round)) + } else if (pts.size == 1) { + // Single sparse reading: draw a dot so the user sees it. + drawCircle(SpO2LineColor, 4.dp.toPx(), pts.first()) + } + if (si != null && si in values.indices) { + val x = si * sx; drawLine(ScrubLineColor, Offset(x, 0f), Offset(x, cH), 1.5.dp.toPx()) + values[si]?.let { val y = cH - ((it - mn) / rg * cH).toFloat(); drawCircle(Color.White, 5.dp.toPx(), Offset(x, y)); drawCircle(SpO2LineColor, 3.5.dp.toPx(), Offset(x, y)) } + } + val pointsPerHour = (values.size / 24).coerceAtLeast(1) + for (i in values.indices step pointsPerHour * 6) { drawText(tm.measure("${i / pointsPerHour}", ls), topLeft = Offset(i * sx, cH + 2.dp.toPx())) } + } +} + internal fun smoothBarPath(x: Float, w: Float, baseline: Float, h: Float, prevH: Float, nextH: Float): Path { val top = baseline - h val leftEdgeY = baseline - (prevH * 0.15f + h * 0.85f) diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthScreen.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthScreen.kt index fbdc2f6a..9d255a3e 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthScreen.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthScreen.kt @@ -51,6 +51,7 @@ fun HealthScreen(topBarParams: TopBarParams, nav: NavBarNav) { val act by vm.activity.collectAsState() val slp by vm.sleep.collectAsState() val hr by vm.heartRate.collectAsState() + val spo2 by vm.spo2.collectAsState() val dl by vm.dateLabel.collectAsState() val imperial by vm.imperialUnits.collectAsState() val hasHrmWatch by vm.hasHrmWatch.collectAsState() @@ -73,6 +74,7 @@ fun HealthScreen(topBarParams: TopBarParams, nav: NavBarNav) { ActivityCard(act, vm.selectedTimeRange, imperial) SleepCard(slp, vm.selectedTimeRange) if (hasHrmWatch) HeartRateCard(hr, vm.selectedTimeRange) + if (hasHrmWatch) BloodOxygenCard(spo2, vm.selectedTimeRange) TextButton( onClick = { nav.navigateTo( @@ -336,6 +338,53 @@ private fun HeartRateCard(st: HeartRateUiState, range: HealthTimeRange) { } } +@Composable +private fun BloodOxygenCard(st: Spo2UiState, range: HealthTimeRange) { + val scrub = rememberScrubState() + val idx = scrub.scrubIndex + val minPerBucket = if (st.spo2Samples.isNotEmpty()) 1440 / st.spo2Samples.size else 60 + // Snap scrub to nearest non-null sample within ~1 hour, like the HR card. + val sv = if (idx != null && idx < st.spo2Samples.size) { + val maxDist = (60 / minPerBucket).coerceAtLeast(1) + var found: Double? = null + for (d in 0..maxDist) { + found = st.spo2Samples.getOrNull(idx - d) ?: st.spo2Samples.getOrNull(idx + d) + if (found != null) break + } + found?.roundToInt() + } else null + val tStr = if (idx != null) { val m = idx * minPerBucket; "${m / 60}:${(m % 60).toString().padStart(2, '0')}" } else "" + + val headerLabel = when (range) { HealthTimeRange.Daily -> "latest"; else -> "average" } + val hv = when { sv != null -> "$sv%"; st.latestSpo2 != null && range == HealthTimeRange.Daily -> "${st.latestSpo2}%"; st.averageSpo2 != null -> "${st.averageSpo2}%"; else -> "--" } + val hs = when { + sv != null && idx != null -> "% at $tStr" + idx != null -> "no data at $tStr" + range == HealthTimeRange.Daily -> "SpO2 · latest reading" + else -> "SpO2 · average" + } + + HealthCard(SpO2BgColor) { + CardHeader( + color = SpO2HeaderColor, + cardName = "Blood Oxygen", + label = headerLabel, + subtitle = hs, + mainValue = hv, + secondLabel = st.averageSpo2?.let { "Avg" }, + secondValue = st.averageSpo2?.let { "$it%" }, + ) + if (st.isLoading) { ChartPlaceholder("Loading...", "") } + else if (st.averageSpo2 == null && st.latestSpo2 == null) { + ChartPlaceholder("No blood oxygen data", "Enable Blood Oxygen in Health Settings, then wear your watch") + } else { + if (range == HealthTimeRange.Daily && st.spo2Samples.any { it != null }) { + val tm = rememberTextMeasurer(); Spo2LineChart(st.spo2Samples, scrub, tm) + } + } + } +} + @Composable private fun ChartPlaceholder(title: String, subtitle: String = "Wear your watch to start tracking") { Column(Modifier.fillMaxWidth().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally) { diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthViewModel.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthViewModel.kt index e56afb13..be132e28 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthViewModel.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/HealthViewModel.kt @@ -95,6 +95,14 @@ data class HeartRateUiState( val isLoading: Boolean = true, ) +data class Spo2UiState( + val averageSpo2: Int? = null, + val latestSpo2: Int? = null, + /** 5-minute buckets of SpO2 percent for the day; null where there's no reading. */ + val spo2Samples: List = emptyList(), + val isLoading: Boolean = true, +) + class HealthViewModel( private val libPebble: LibPebble, ) : ViewModel() { @@ -115,6 +123,8 @@ class HealthViewModel( val sleep: StateFlow = _sleep.asStateFlow() private val _heartRate = MutableStateFlow(HeartRateUiState()) val heartRate: StateFlow = _heartRate.asStateFlow() + private val _spo2 = MutableStateFlow(Spo2UiState()) + val spo2: StateFlow = _spo2.asStateFlow() private val _dateLabel = MutableStateFlow("") val dateLabel: StateFlow = _dateLabel.asStateFlow() @@ -175,6 +185,8 @@ class HealthViewModel( val zonesD = async { libPebble.getHRZoneMinutes(dayStart, dayEnd) } val latestHRD = async { libPebble.getLatestHeartRateReading() } val restingHRD = async { libPebble.getRestingHeartRate(dayStart) } + val spo2ReadingsD = async { libPebble.getSpo2Readings(dayStart, dayEnd) } + val avgSpo2D = async { libPebble.getAverageSpo2(dayStart, dayEnd) } val healthData = healthDataD.await() val aggregates = aggregatesD.await() @@ -184,6 +196,7 @@ class HealthViewModel( val hourlySteps = LongArray(24) val hrBuckets = Array>(288) { mutableListOf() } // 5-minute resolution + val spo2Buckets = Array>(288) { mutableListOf() } // 5-minute resolution for (entry in healthData) { val hour = ((entry.timestamp - dayStart) / 3600).toInt().coerceIn(0, 23) hourlySteps[hour] += entry.steps @@ -192,6 +205,12 @@ class HealthViewModel( hrBuckets[bucket].add(entry.heartRate) } } + // SpO2 comes from a separate (sparse) stream; bucket the same way for the line chart. + val spo2Readings = spo2ReadingsD.await() + for (reading in spo2Readings) { + val bucket = ((reading.timestamp - dayStart) / 300).toInt().coerceIn(0, 287) + spo2Buckets[bucket].add(reading.spo2Percent) + } val sessionUis = sessions.map { ov -> val sH = ((ov.startTime - dayStart).toFloat() / 3600).toInt().coerceIn(0, 23) @@ -238,6 +257,13 @@ class HealthViewModel( zoneMinutes = zonesD.await(), isLoading = false, ) + + _spo2.value = Spo2UiState( + averageSpo2 = avgSpo2D.await()?.roundToInt(), + latestSpo2 = spo2Readings.maxByOrNull { it.timestamp }?.spo2Percent, + spo2Samples = spo2Buckets.map { if (it.isEmpty()) null else it.average() }, + isLoading = false, + ) } private suspend fun loadWeekly(startDate: LocalDate, endDate: LocalDate, tz: TimeZone) { @@ -328,6 +354,15 @@ class HealthViewModel( ) } + private suspend fun buildSpo2State(start: Long, end: Long): Spo2UiState { + val latest = libPebble.getLatestSpo2Reading() + return Spo2UiState( + averageSpo2 = libPebble.getAverageSpo2(start, end)?.roundToInt(), + latestSpo2 = latest?.spo2Percent, + isLoading = false, + ) + } + private suspend fun buildSleepState( stackedData: List, daysWithData: Int, sleepEntries: List, tz: TimeZone, @@ -392,6 +427,7 @@ class HealthViewModel( } _sleep.value = buildSleepState(stackedSleep, daysWithData, sleepEntries, tz) _heartRate.value = buildHeartRateState(start, end, dayStarts, dayLabels) + _spo2.value = buildSpo2State(start, end) } private suspend fun loadAggregatedMonthly( @@ -423,6 +459,7 @@ class HealthViewModel( } _sleep.value = buildSleepState(stackedSleep, daysWithData, sleepEntries, tz) _heartRate.value = buildHeartRateState(start, end) + _spo2.value = buildSpo2State(start, end) } } diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt index eeb6220f..f2fbff02 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt @@ -245,6 +245,9 @@ object SettingsIds { const val HrmEnabled = "HrmEnabled" const val HrmMeasurementInterval = "HrmMeasurementInterval" const val HrmActivityTracking = "HrmActivityTracking" + const val BloodOxygenEnabled = "BloodOxygenEnabled" + const val BloodOxygenActivityEnabled = "BloodOxygenActivityEnabled" + const val Spo2MeasurementInterval = "Spo2MeasurementInterval" } data class SettingsItem( @@ -1016,8 +1019,65 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD checked = healthSettings.hrmActivityTrackingEnabled, show = { healthSettings.trackingEnabled && healthSettings.hrmEnabled }, onCheckChanged = { + // Turning HR During Activities off also disables activity SpO2 — the watch + // clears it locally, but keep the app's synced setting in sync too. libPebble.updateHealthSettings( - healthSettings.copy(hrmActivityTrackingEnabled = it) + healthSettings.copy( + hrmActivityTrackingEnabled = it, + bloodOxygenActivityEnabled = if (!it) false else healthSettings.bloodOxygenActivityEnabled, + ) + ) + }, + ), + basicSettingsToggleItem( + id = SettingsIds.BloodOxygenEnabled, + title = "Blood Oxygen", + description = "Allow the watch to measure blood oxygen (SpO2). The sampling rate is configurable below.", + topLevelType = TopLevelType.Phone, + section = Section.Health, + checked = healthSettings.bloodOxygenEnabled, + show = { healthSettings.trackingEnabled && healthSettings.hrmEnabled }, + onCheckChanged = { + libPebble.updateHealthSettings( + healthSettings.copy(bloodOxygenEnabled = it) + ) + }, + ), + basicSettingsDropdownItem( + id = SettingsIds.Spo2MeasurementInterval, + title = "SpO2 Monitoring Rate", + description = "How often the watch takes a background blood oxygen (SpO2) reading. Set to Off to pause background sampling. A faster rate can have an impact on battery life.", + topLevelType = TopLevelType.Phone, + section = Section.Health, + selectedItem = healthSettings.spo2MeasurementInterval, + items = HRMonitoringInterval.entries, + onItemSelected = { + libPebble.updateHealthSettings( + healthSettings.copy(spo2MeasurementInterval = it) + ) + }, + itemText = { + when (it) { + HRMonitoringInterval.TenMin -> "Every 10 minutes" + HRMonitoringInterval.ThirtyMin -> "Every 30 minutes" + HRMonitoringInterval.OneHour -> "Every hour" + HRMonitoringInterval.Disabled -> "Off" + } + }, + show = { healthSettings.trackingEnabled && healthSettings.hrmEnabled && healthSettings.bloodOxygenEnabled }, + ), + basicSettingsToggleItem( + id = SettingsIds.BloodOxygenActivityEnabled, + title = "Blood Oxygen During Activities", + description = "Take a blood oxygen (SpO2) reading roughly every 5 minutes during detected activities. Requires 'HR During Activities' to be enabled.", + topLevelType = TopLevelType.Phone, + section = Section.Health, + checked = healthSettings.bloodOxygenActivityEnabled, + enabled = healthSettings.hrmActivityTrackingEnabled, + show = { healthSettings.trackingEnabled && healthSettings.hrmEnabled }, + onCheckChanged = { + libPebble.updateHealthSettings( + healthSettings.copy(bloodOxygenActivityEnabled = it) ) }, ), @@ -2153,6 +2213,7 @@ fun basicSettingsToggleItem( keywords: String = "", show: () -> Boolean = { true }, isDebugSetting: Boolean = false, + enabled: Boolean = true, ) = SettingsItem( id = id, title = title, @@ -2170,6 +2231,7 @@ fun basicSettingsToggleItem( Checkbox( checked = checked, onCheckedChange = onCheckChanged, + enabled = enabled, ) }, supportingContent = { diff --git a/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt b/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt new file mode 100644 index 00000000..3f62597c --- /dev/null +++ b/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt @@ -0,0 +1,10 @@ +package coredevices.pebble.health + +import io.rebble.libpebblecommon.database.entity.Spo2ReadingEntity + +// Apple Health blood-oxygen writing is not yet wired up; treat as unsupported. +internal actual fun supportsOxygenSaturationWriting(): Boolean = false + +internal actual suspend fun hasOxygenSaturationPermission(): Boolean = false + +internal actual suspend fun writeOxygenSaturationToPlatform(readings: List): Boolean = false diff --git a/pebble/src/iosMain/kotlin/coredevices/pebble/health/PlatformHealthPermissions.ios.kt b/pebble/src/iosMain/kotlin/coredevices/pebble/health/PlatformHealthPermissions.ios.kt new file mode 100644 index 00000000..504a4d9d --- /dev/null +++ b/pebble/src/iosMain/kotlin/coredevices/pebble/health/PlatformHealthPermissions.ios.kt @@ -0,0 +1,16 @@ +package coredevices.pebble.health + +import com.viktormykhailiv.kmp.health.HealthDataType +import com.viktormykhailiv.kmp.health.HealthManager + +internal actual suspend fun requestPlatformHealthPermissions( + healthManager: HealthManager, + readTypes: List, + writeTypes: List, +): Boolean { + val result = healthManager.requestAuthorization( + readTypes = readTypes, + writeTypes = writeTypes, + ) + return result.getOrDefault(false) +}