From 5c564e54028607cb6bc82beab2d4ced2d2749488 Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Wed, 17 Jun 2026 04:18:33 -0700 Subject: [PATCH 01/11] Add SpO2 readings table and DAO (db v38->39) Introduce a dedicated spo2_readings table for per-minute blood oxygen samples parsed from the watch's health stream, with an auto-migration from schema 38 to 39 and the exported schema. SpO2 is stored separately from HealthDataEntity because real readings are sparse (one per measurement interval) and keyed/deduped by minute timestamp. --- .../39.json | 1851 +++++++++++++++++ .../database/dao/Spo2DaoTest.kt | 47 + .../libpebblecommon/database/Database.kt | 7 +- .../libpebblecommon/database/dao/Spo2Dao.kt | 31 + .../database/entity/Spo2ReadingEntity.kt | 21 + .../libpebblecommon/di/LibPebbleModule.kt | 1 + 6 files changed, 1957 insertions(+), 1 deletion(-) create mode 100644 libpebble3/schema/io.rebble.libpebblecommon.database.Database/39.json create mode 100644 libpebble3/src/androidInstrumentedTest/kotlin/io/rebble/libpebblecommon/database/dao/Spo2DaoTest.kt create mode 100644 libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/dao/Spo2Dao.kt create mode 100644 libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/Spo2ReadingEntity.kt diff --git a/libpebble3/schema/io.rebble.libpebblecommon.database.Database/39.json b/libpebble3/schema/io.rebble.libpebblecommon.database.Database/39.json new file mode 100644 index 00000000..7efb3e58 --- /dev/null +++ b/libpebble3/schema/io.rebble.libpebblecommon.database.Database/39.json @@ -0,0 +1,1851 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "0a6e94ddb7ad4e2c35e9f9b8b4eaaf29", + "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)", + "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" + } + ], + "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, '0a6e94ddb7ad4e2c35e9f9b8b4eaaf29')" + ] + } +} \ 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/database/Database.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt index efd67c20..4faa242d 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 = 38, + version = 39, autoMigrations = [ AutoMigration(from = 10, to = 11), AutoMigration(from = 11, to = 12), @@ -123,6 +126,7 @@ internal const val DATABASE_FILENAME = "libpebble3.db" AutoMigration(from = 35, to = 36), AutoMigration(from = 36, to = 37), AutoMigration(from = 37, to = 38), + AutoMigration(from = 38, to = 39), ], exportSchema = true, ) @@ -142,6 +146,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/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/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/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() } From 6c94210cda392e2e971fe669ea8df67619da143c Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Wed, 17 Jun 2026 05:31:07 -0700 Subject: [PATCH 02/11] Parse SpO2 from firmware 4.4 (v14) health records ActivityMinuteData record version 14 appends spo2_percent and spo2_quality to each minute sample. parseStepsData now returns ParsedHealthData carrying both the step/HR entities and any SpO2 readings (only samples where spo2_percent > 0 are emitted). Adds a v14 parsing test plus a v13 backward-compatibility test. --- .../health/parsers/HealthDataParser.kt | 45 ++++++-- .../health/HealthParsingTest.kt | 102 ++++++++++++++++++ 2 files changed, 141 insertions(+), 6 deletions(-) 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 From d0bdfd82c5e22168226805496f44ee6bc569885b Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Wed, 17 Jun 2026 06:47:52 -0700 Subject: [PATCH 03/11] Persist parsed SpO2 readings in HealthDataProcessor Wire the new ParsedHealthData through processStepsData: insert step/HR records and SpO2 readings independently so a payload carrying only one of the two is still handled, and surface the SpO2 count in the debug summary. --- .../datalogging/HealthDataProcessor.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) 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? { From 7916560384969a6e0ab6c19fca69cde08a3bc683 Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Wed, 17 Jun 2026 08:09:41 -0700 Subject: [PATCH 04/11] Expose SpO2 readings through the LibPebble health API Add SpO2 query methods (range readings, readings-after cursor, average, latest) and a LatestSpo2 model to HealthDataApi, implement them in Health against the new spo2Dao, and stub them in FakeLibPebble. Also threads the bloodOxygenEnabled flag into the HealthSettings model. --- .../connection/FakeLibPebble.kt | 7 ++++++ .../libpebblecommon/connection/LibPebble.kt | 22 +++++++++++++++++ .../rebble/libpebblecommon/health/Health.kt | 24 +++++++++++++++++++ 3 files changed, 53 insertions(+) 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..084db2c2 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,7 @@ class FakeLibPebble : LibPebble { hrZone1Threshold = 130, hrZone2Threshold = 154, hrZone3Threshold = 172, + bloodOxygenEnabled = false, )) } override fun updateHealthSettings(healthSettings: HealthSettings) {} @@ -480,6 +483,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/health/Health.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt index 8af9219c..9e25fbf9 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,8 @@ data class HealthSettings( val hrZone1Threshold: Short, val hrZone2Threshold: Short, val hrZone3Threshold: Short, + // BloodOxygenPreferences + val bloodOxygenEnabled: Boolean, ) /** Time range for displaying health data */ From 70536533fb3b963499b0bd6d14b0a79fd0d83e8c Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Wed, 17 Jun 2026 09:24:18 -0700 Subject: [PATCH 05/11] Sync blood-oxygen enable setting with the watch Add a bloodOxygenPreferences health setting (single enabled byte, defaults off) that the watch gates SpO2 measurement on. Encodes/decodes the blob, routes the key from the WatchPrefs BlobDB to HealthParams, and adds a Blood Oxygen toggle under Health settings (shown only when tracking + HRM are enabled). --- .../endpointmanager/blobdb/BlobDB.kt | 7 +-- .../database/dao/HealthSettingsRealDao.kt | 10 +++++ .../database/entity/HealthSettings.kt | 44 ++++++++++++++++++- .../database/entity/WatchPrefEntity.kt | 1 + .../pebble/ui/WatchSettingsScreen.kt | 15 +++++++ 5 files changed, 73 insertions(+), 4 deletions(-) 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..10aa695b 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,8 @@ 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) 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 +309,8 @@ class BlobDB( "activityPreferences", "unitsDistance", "hrmPreferences", - "heartRatePreferences" -> BlobDatabase.HealthParams + "heartRatePreferences", + "bloodOxygenPreferences" -> BlobDatabase.HealthParams else -> message.database } } 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..9cde9914 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,9 @@ 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.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 @@ -97,6 +100,13 @@ 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() + } 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/entity/HealthSettings.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/HealthSettings.kt index 37b59a45..06e35218 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,6 +9,8 @@ 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.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 @@ -70,6 +72,7 @@ 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() else -> null } } @@ -83,6 +86,7 @@ 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 val json = Json { ignoreUnknownKeys = true } // ActivityHRMSettings struct grew in firmware: @@ -117,12 +121,16 @@ 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() + } return combine( activityPrefsFlow, unitPrefsFlow, hrmPrefsFlow, heartRatePrefsFlow, - ) { activityPrefs, unitPrefs, hrmPrefs, heartRatePrefs -> + bloodOxygenPrefsFlow, + ) { activityPrefs, unitPrefs, hrmPrefs, heartRatePrefs, bloodOxygenPrefs -> HealthSettings( heightMm = activityPrefs.heightMm, weightDag = activityPrefs.weightDag, @@ -141,6 +149,7 @@ fun HealthSettingsEntryDao.getWatchSettings(): Flow { hrZone1Threshold = heartRatePrefs.zone1Threshold, hrZone2Threshold = heartRatePrefs.zone2Threshold, hrZone3Threshold = heartRatePrefs.zone3Threshold, + bloodOxygenEnabled = bloodOxygenPrefs.enabled, ) } } @@ -196,6 +205,15 @@ suspend fun HealthSettingsEntryDao.setWatchSettings(healthSettings: HealthSettin timestamp = now, ) ) + insertOrReplace( + HealthSettingsEntry( + id = KEY_BLOOD_OXYGEN_PREFERENCES, + value = BloodOxygenPreferencesValue( + enabled = healthSettings.bloodOxygenEnabled, + ).encodeToString(), + timestamp = now, + ) + ) } @Serializable @@ -285,6 +303,24 @@ data class HeartRatePreferencesValue( } } +/** + * Blood oxygen (SpO2) monitoring preference. Defaults to OFF — the watch gates SpO2 on this + * synced setting, so nothing is produced until it's enabled. Single byte: 0x01 enabled, 0x00 + * disabled. The measurement interval is watch-local and not phone-settable. + */ +@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() + } +} + class ActivityPrefsBlobItem( heightMm: UShort, weightDag: UShort, @@ -337,6 +373,12 @@ class HeartRatePreferencesBlobItem( val zone3Threshold = SUByte(m, zone3Threshold) } +class BloodOxygenPreferencesBlobItem( + enabled: Boolean, +) : StructMappable(endianness = Endian.Little) { + val enabled = SByte(m, if (enabled) 0x01 else 0x00) +} + enum class HealthGender( val value: Byte, ) { 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..0aca8aa0 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,7 @@ enum class ColorWatchPref( - alarmsAppOpened - hrmPreferences (see HealthSettingsEntry) - heartRatePreferences (see HealthSettingsEntry) +- bloodOxygenPreferences (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/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt index eeb6220f..28d67cb6 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt @@ -245,6 +245,7 @@ object SettingsIds { const val HrmEnabled = "HrmEnabled" const val HrmMeasurementInterval = "HrmMeasurementInterval" const val HrmActivityTracking = "HrmActivityTracking" + const val BloodOxygenEnabled = "BloodOxygenEnabled" } data class SettingsItem( @@ -1021,6 +1022,20 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD ) }, ), + basicSettingsToggleItem( + id = SettingsIds.BloodOxygenEnabled, + title = "Blood Oxygen", + description = "Allow the watch to measure blood oxygen (SpO2). Readings are taken roughly every 10 minutes.", + topLevelType = TopLevelType.Phone, + section = Section.Health, + checked = healthSettings.bloodOxygenEnabled, + show = { healthSettings.trackingEnabled && healthSettings.hrmEnabled }, + onCheckChanged = { + libPebble.updateHealthSettings( + healthSettings.copy(bloodOxygenEnabled = it) + ) + }, + ), basicSettingsToggleItem( id = EnableActivityInsights, title = "Activity Insights", From 37420bf4ae3b73dc355ab5003d27961577729676 Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Wed, 17 Jun 2026 10:52:05 -0700 Subject: [PATCH 06/11] Write SpO2 to Health Connect on Android health-kmp 1.4.0 doesn't model OxygenSaturation, so add expect/actual seams for blood-oxygen platform writing: Android writes via androidx.health.connect with a dedicated translucent permission activity, iOS is a no-op for now. PlatformHealthSync requests the extra permission and syncs SpO2 records using its own last-synced cursor. --- gradle/libs.versions.toml | 2 + pebble/build.gradle.kts | 2 + pebble/src/androidMain/AndroidManifest.xml | 10 ++ .../OxygenSaturationPlatform.android.kt | 125 ++++++++++++++++++ .../pebble/health/HealthSyncTracker.kt | 5 + .../pebble/health/OxygenSaturationPlatform.kt | 18 +++ .../pebble/health/PlatformHealthSync.kt | 24 ++++ .../pebble/health/OxygenSaturationPlatform.kt | 12 ++ 8 files changed, 198 insertions(+) create mode 100644 pebble/src/androidMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.android.kt create mode 100644 pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt create mode 100644 pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b2aa19e..8464f6d6 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/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..14966242 100644 --- a/pebble/src/androidMain/AndroidManifest.xml +++ b/pebble/src/androidMain/AndroidManifest.xml @@ -2,10 +2,20 @@ + + + + () + +private fun client(): HealthConnectClient = HealthConnectClient.getOrCreate(context()) + +private 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 requestOxygenSaturationPermission(): Boolean { + if (hasOxygenSaturationPermission()) return true + val ctx = context() + if (HealthConnectClient.getSdkStatus(ctx) != HealthConnectClient.SDK_AVAILABLE) return false + val deferred = OxygenSaturationPermissionBridge.begin() + return try { + ctx.startActivity( + Intent(ctx, OxygenSaturationPermissionActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + deferred.await() + } catch (e: Exception) { + logger.w(e) { "Failed to launch oxygen saturation permission request" } + OxygenSaturationPermissionBridge.complete(false) + 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 + } +} + +internal object OxygenSaturationPermissionBridge { + @Volatile + private var pending: CompletableDeferred? = null + + fun begin(): CompletableDeferred { + val deferred = CompletableDeferred() + pending = deferred + return deferred + } + + fun complete(result: Boolean) { + pending?.complete(result) + pending = null + } +} + +/** + * Translucent, no-history host for the Health Connect blood-oxygen permission contract. health-kmp's + * [HealthConnectPermissionActivity] only requests its own type set, so SpO2 needs its own launcher. + */ +class OxygenSaturationPermissionActivity : ComponentActivity() { + + private val requestPermissionLauncher = registerForActivityResult( + PermissionController.createRequestPermissionResultContract() + ) { granted: Set -> + OxygenSaturationPermissionBridge.complete(WRITE_OXYGEN_SATURATION in granted) + finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + try { + requestPermissionLauncher.launch(setOf(WRITE_OXYGEN_SATURATION)) + } catch (e: Exception) { + logger.w(e) { "Failed launching SpO2 permission contract" } + OxygenSaturationPermissionBridge.complete(false) + finish() + } + } +} 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..9e3c420c --- /dev/null +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt @@ -0,0 +1,18 @@ +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 + +/** Requests the platform's blood-oxygen write permission. Returns true if granted. */ +internal expect suspend fun requestOxygenSaturationPermission(): 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..e3dc9694 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/health/PlatformHealthSync.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/health/PlatformHealthSync.kt @@ -85,6 +85,14 @@ class PlatformHealthSync( return false } val success = result.getOrDefault(false) + // Best-effort: also request blood-oxygen write permission (health-kmp doesn't cover SpO2). + if (success && supportsOxygenSaturationWriting()) { + try { + requestOxygenSaturationPermission() + } catch (e: Exception) { + logger.w(e) { "Failed requesting SpO2 permission" } + } + } 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/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt b/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt new file mode 100644 index 00000000..cf1c28cf --- /dev/null +++ b/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt @@ -0,0 +1,12 @@ +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 requestOxygenSaturationPermission(): Boolean = false + +internal actual suspend fun writeOxygenSaturationToPlatform(readings: List): Boolean = false From 6fca6b8819ec12cf2d4fb0d58d57c0a88a5bbfb5 Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Wed, 17 Jun 2026 11:46:29 -0700 Subject: [PATCH 07/11] Add Blood Oxygen card and chart to the Health screen Show a Blood Oxygen card (latest/average plus a daily SpO2 line chart scaled to the 85-100 band with scrub support) on the Health screen for HRM-capable watches, backed by a new Spo2UiState bucketed at 5-minute resolution in the view model. --- .../coredevices/pebble/ui/HealthCharts.kt | 33 +++++++++++++ .../coredevices/pebble/ui/HealthScreen.kt | 49 +++++++++++++++++++ .../coredevices/pebble/ui/HealthViewModel.kt | 37 ++++++++++++++ 3 files changed, 119 insertions(+) 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) } } From 013720a07007473ba40db0663ff8cf5efe5219c3 Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Thu, 18 Jun 2026 09:30:43 -0500 Subject: [PATCH 08/11] proper schema --- .../39.json | 30 - .../40.json | 1858 +++++++++++++++++ .../libpebblecommon/database/Database.kt | 3 +- 3 files changed, 1860 insertions(+), 31 deletions(-) create mode 100644 libpebble3/schema/io.rebble.libpebblecommon.database.Database/40.json diff --git a/libpebble3/schema/io.rebble.libpebblecommon.database.Database/39.json b/libpebble3/schema/io.rebble.libpebblecommon.database.Database/39.json index e8db6952..665685e1 100644 --- a/libpebble3/schema/io.rebble.libpebblecommon.database.Database/39.json +++ b/libpebble3/schema/io.rebble.libpebblecommon.database.Database/39.json @@ -1334,36 +1334,6 @@ ] } }, - { - "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`))", 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/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt index 4faa242d..060329c7 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/Database.kt @@ -96,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), @@ -127,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, ) From d97bfe637d6b72d01cc2aaa0e4c8e1ca01c7d0c8 Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Thu, 18 Jun 2026 09:56:40 -0500 Subject: [PATCH 09/11] Add activity SpO2 watch setting gated by HR During Activities --- .../connection/FakeLibPebble.kt | 1 + .../endpointmanager/blobdb/BlobDB.kt | 8 +-- .../database/dao/HealthSettingsRealDao.kt | 10 ++++ .../database/entity/HealthSettings.kt | 52 ++++++++++++++++++- .../database/entity/WatchPrefEntity.kt | 3 +- .../rebble/libpebblecommon/health/Health.kt | 2 + .../pebble/ui/WatchSettingsScreen.kt | 25 ++++++++- 7 files changed, 94 insertions(+), 7 deletions(-) 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 084db2c2..ac4475bb 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt @@ -373,6 +373,7 @@ class FakeLibPebble : LibPebble { hrZone2Threshold = 154, hrZone3Threshold = 172, bloodOxygenEnabled = false, + bloodOxygenActivityEnabled = false, )) } override fun updateHealthSettings(healthSettings: HealthSettings) {} 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 10aa695b..71b38695 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, bloodOxygenPreferences) via the WatchPrefs BlobDB, - // but the phone stores them in HealthParams. Route those keys to the health settings DAO. + // hrmPreferences, heartRatePreferences, bloodOxygenPreferences, bloodOxygenActivityPreferences) + // 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) @@ -310,7 +311,8 @@ class BlobDB( "unitsDistance", "hrmPreferences", "heartRatePreferences", - "bloodOxygenPreferences" -> BlobDatabase.HealthParams + "bloodOxygenPreferences", + "bloodOxygenActivityPreferences" -> BlobDatabase.HealthParams else -> message.database } } 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 9cde9914..4e501e60 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,9 @@ 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 @@ -107,6 +110,13 @@ interface HealthSettingsEntryRealDao : HealthSettingsEntryDao { enabled = blob.enabled.get() != 0.toByte(), ).encodeToString() } + "bloodOxygenActivityPreferences" -> { + val blob = BloodOxygenActivityPreferencesBlobItem(enabled = false) + blob.fromBytes(value) + BloodOxygenActivityPreferencesValue( + enabled = blob.enabled.get() != 0.toByte(), + ).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/entity/HealthSettings.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/HealthSettings.kt index 06e35218..81939906 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,6 +9,8 @@ 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 @@ -73,6 +75,7 @@ data class HealthSettingsEntry( 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() else -> null } } @@ -87,6 +90,7 @@ 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 val json = Json { ignoreUnknownKeys = true } // ActivityHRMSettings struct grew in firmware: @@ -124,13 +128,22 @@ fun HealthSettingsEntryDao.getWatchSettings(): Flow { 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() + } + // kotlinx.coroutines.flow.combine only has typed overloads up to 5 flows; bundle the two SpO2 + // prefs into a pair so the outer combine stays within that limit. + val bloodOxygenCombined = combine(bloodOxygenPrefsFlow, bloodOxygenActivityPrefsFlow) { prefs, activityPrefs -> + prefs to activityPrefs + } return combine( activityPrefsFlow, unitPrefsFlow, hrmPrefsFlow, heartRatePrefsFlow, - bloodOxygenPrefsFlow, - ) { activityPrefs, unitPrefs, hrmPrefs, heartRatePrefs, bloodOxygenPrefs -> + bloodOxygenCombined, + ) { activityPrefs, unitPrefs, hrmPrefs, heartRatePrefs, bloodOxygen -> + val (bloodOxygenPrefs, bloodOxygenActivityPrefs) = bloodOxygen HealthSettings( heightMm = activityPrefs.heightMm, weightDag = activityPrefs.weightDag, @@ -150,6 +163,7 @@ fun HealthSettingsEntryDao.getWatchSettings(): Flow { hrZone2Threshold = heartRatePrefs.zone2Threshold, hrZone3Threshold = heartRatePrefs.zone3Threshold, bloodOxygenEnabled = bloodOxygenPrefs.enabled, + bloodOxygenActivityEnabled = bloodOxygenActivityPrefs.enabled, ) } } @@ -214,6 +228,15 @@ suspend fun HealthSettingsEntryDao.setWatchSettings(healthSettings: HealthSettin timestamp = now, ) ) + insertOrReplace( + HealthSettingsEntry( + id = KEY_BLOOD_OXYGEN_ACTIVITY_PREFERENCES, + value = BloodOxygenActivityPreferencesValue( + enabled = healthSettings.bloodOxygenActivityEnabled, + ).encodeToString(), + timestamp = now, + ) + ) } @Serializable @@ -321,6 +344,25 @@ data class BloodOxygenPreferencesValue( } } +/** + * 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() + } +} + class ActivityPrefsBlobItem( heightMm: UShort, weightDag: UShort, @@ -379,6 +421,12 @@ class BloodOxygenPreferencesBlobItem( 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) +} + enum class HealthGender( val value: Byte, ) { 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 0aca8aa0..059fa40f 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,7 +648,8 @@ enum class ColorWatchPref( - alarmsAppOpened - hrmPreferences (see HealthSettingsEntry) - heartRatePreferences (see HealthSettingsEntry) -- bloodOxygenPreferences (see HealthSettingsEntry) + - bloodOxygenPreferences (see HealthSettingsEntry) + - bloodOxygenActivityPreferences (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/health/Health.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt index 9e25fbf9..c8c475e5 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt @@ -474,6 +474,8 @@ data class HealthSettings( val hrZone3Threshold: Short, // BloodOxygenPreferences val bloodOxygenEnabled: Boolean, + // BloodOxygenActivityPreferences (SpO2 during activities; gated by HR During Activities) + val bloodOxygenActivityEnabled: Boolean, ) /** Time range for displaying health data */ diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt index 28d67cb6..0071e5ac 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt @@ -246,6 +246,7 @@ object SettingsIds { const val HrmMeasurementInterval = "HrmMeasurementInterval" const val HrmActivityTracking = "HrmActivityTracking" const val BloodOxygenEnabled = "BloodOxygenEnabled" + const val BloodOxygenActivityEnabled = "BloodOxygenActivityEnabled" } data class SettingsItem( @@ -1017,8 +1018,13 @@ 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, + ) ) }, ), @@ -1036,6 +1042,21 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD ) }, ), + 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) + ) + }, + ), basicSettingsToggleItem( id = EnableActivityInsights, title = "Activity Insights", @@ -2168,6 +2189,7 @@ fun basicSettingsToggleItem( keywords: String = "", show: () -> Boolean = { true }, isDebugSetting: Boolean = false, + enabled: Boolean = true, ) = SettingsItem( id = id, title = title, @@ -2185,6 +2207,7 @@ fun basicSettingsToggleItem( Checkbox( checked = checked, onCheckedChange = onCheckChanged, + enabled = enabled, ) }, supportingContent = { From 51220114d05e1ba987e527428efb56601edbd451 Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Fri, 19 Jun 2026 15:52:32 -0500 Subject: [PATCH 10/11] Consolidate Health Connect SpO2 into single permission prompt --- pebble/src/androidMain/AndroidManifest.xml | 8 +- .../HealthConnectPermissions.android.kt | 127 ++++++++++++++++++ .../OxygenSaturationPlatform.android.kt | 69 +--------- .../pebble/health/OxygenSaturationPlatform.kt | 3 - .../pebble/health/PlatformHealthSync.kt | 28 ++-- .../pebble/health/OxygenSaturationPlatform.kt | 2 - .../health/PlatformHealthPermissions.ios.kt | 16 +++ 7 files changed, 165 insertions(+), 88 deletions(-) create mode 100644 pebble/src/androidMain/kotlin/coredevices/pebble/health/HealthConnectPermissions.android.kt create mode 100644 pebble/src/iosMain/kotlin/coredevices/pebble/health/PlatformHealthPermissions.ios.kt diff --git a/pebble/src/androidMain/AndroidManifest.xml b/pebble/src/androidMain/AndroidManifest.xml index 14966242..af582571 100644 --- a/pebble/src/androidMain/AndroidManifest.xml +++ b/pebble/src/androidMain/AndroidManifest.xml @@ -3,14 +3,16 @@ xmlns:tools="http://schemas.android.com/tools"> + so it is requested/written directly via androidx.health.connect. SpO2 is bundled into + the single permission prompt hosted by HealthConnectWritePermissionActivity below, + alongside the standard steps/heart rate/sleep/exercise write permissions. --> - \ 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 index af5e770b..764f48c6 100644 --- a/pebble/src/androidMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.android.kt +++ b/pebble/src/androidMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.android.kt @@ -1,31 +1,26 @@ 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.HealthConnectClient -import androidx.health.connect.client.PermissionController 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 kotlinx.coroutines.CompletableDeferred import org.koin.mp.KoinPlatform import java.time.Instant import java.time.ZoneOffset -private val logger = Logger.withTag("OxygenSaturationHC") +internal val logger = Logger.withTag("OxygenSaturationHC") internal actual fun supportsOxygenSaturationWriting(): Boolean = true -private fun context(): Context = KoinPlatform.getKoin().get() +internal fun context(): Context = KoinPlatform.getKoin().get() private fun client(): HealthConnectClient = HealthConnectClient.getOrCreate(context()) -private const val WRITE_OXYGEN_SATURATION = "android.permission.health.WRITE_OXYGEN_SATURATION" +internal const val WRITE_OXYGEN_SATURATION = "android.permission.health.WRITE_OXYGEN_SATURATION" internal actual suspend fun hasOxygenSaturationPermission(): Boolean { return try { @@ -37,23 +32,6 @@ internal actual suspend fun hasOxygenSaturationPermission(): Boolean { } } -internal actual suspend fun requestOxygenSaturationPermission(): Boolean { - if (hasOxygenSaturationPermission()) return true - val ctx = context() - if (HealthConnectClient.getSdkStatus(ctx) != HealthConnectClient.SDK_AVAILABLE) return false - val deferred = OxygenSaturationPermissionBridge.begin() - return try { - ctx.startActivity( - Intent(ctx, OxygenSaturationPermissionActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - deferred.await() - } catch (e: Exception) { - logger.w(e) { "Failed to launch oxygen saturation permission request" } - OxygenSaturationPermissionBridge.complete(false) - false - } -} - internal actual suspend fun writeOxygenSaturationToPlatform(readings: List): Boolean { if (readings.isEmpty()) return true return try { @@ -82,44 +60,3 @@ internal actual suspend fun writeOxygenSaturationToPlatform(readings: List? = null - - fun begin(): CompletableDeferred { - val deferred = CompletableDeferred() - pending = deferred - return deferred - } - - fun complete(result: Boolean) { - pending?.complete(result) - pending = null - } -} - -/** - * Translucent, no-history host for the Health Connect blood-oxygen permission contract. health-kmp's - * [HealthConnectPermissionActivity] only requests its own type set, so SpO2 needs its own launcher. - */ -class OxygenSaturationPermissionActivity : ComponentActivity() { - - private val requestPermissionLauncher = registerForActivityResult( - PermissionController.createRequestPermissionResultContract() - ) { granted: Set -> - OxygenSaturationPermissionBridge.complete(WRITE_OXYGEN_SATURATION in granted) - finish() - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - try { - requestPermissionLauncher.launch(setOf(WRITE_OXYGEN_SATURATION)) - } catch (e: Exception) { - logger.w(e) { "Failed launching SpO2 permission contract" } - OxygenSaturationPermissionBridge.complete(false) - finish() - } - } -} diff --git a/pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt index 9e3c420c..818a74b2 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt @@ -11,8 +11,5 @@ internal expect fun supportsOxygenSaturationWriting(): Boolean internal expect suspend fun hasOxygenSaturationPermission(): Boolean -/** Requests the platform's blood-oxygen write permission. Returns true if granted. */ -internal expect suspend fun requestOxygenSaturationPermission(): 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 e3dc9694..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,25 +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) - // Best-effort: also request blood-oxygen write permission (health-kmp doesn't cover SpO2). - if (success && supportsOxygenSaturationWriting()) { - try { - requestOxygenSaturationPermission() - } catch (e: Exception) { - logger.w(e) { "Failed requesting SpO2 permission" } - } - } logger.v { "requestPermissions success=$success" } tracker.setEnabled(success) GlobalScope.launch { diff --git a/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt b/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt index cf1c28cf..3f62597c 100644 --- a/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt +++ b/pebble/src/iosMain/kotlin/coredevices/pebble/health/OxygenSaturationPlatform.kt @@ -7,6 +7,4 @@ internal actual fun supportsOxygenSaturationWriting(): Boolean = false internal actual suspend fun hasOxygenSaturationPermission(): Boolean = false -internal actual suspend fun requestOxygenSaturationPermission(): 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) +} From 581cd3aebf10bc8246d5828f2260f9eb3bb0052a Mon Sep 17 00:00:00 2001 From: 32bitx64bit Date: Tue, 23 Jun 2026 09:11:46 -0500 Subject: [PATCH 11/11] Add SpO2 monitoring rate watch setting via spo2Preferences --- .../connection/FakeLibPebble.kt | 1 + .../endpointmanager/blobdb/BlobDB.kt | 9 ++- .../database/dao/HealthSettingsRealDao.kt | 14 ++++ .../database/entity/HealthSettings.kt | 73 +++++++++++++++---- .../database/entity/WatchPrefEntity.kt | 1 + .../rebble/libpebblecommon/health/Health.kt | 2 + .../pebble/ui/WatchSettingsScreen.kt | 26 ++++++- 7 files changed, 108 insertions(+), 18 deletions(-) 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 ac4475bb..be040136 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/FakeLibPebble.kt @@ -374,6 +374,7 @@ class FakeLibPebble : LibPebble { hrZone3Threshold = 172, bloodOxygenEnabled = false, bloodOxygenActivityEnabled = false, + spo2MeasurementInterval = HRMonitoringInterval.TenMin, )) } override fun updateHealthSettings(healthSettings: HealthSettings) {} 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 71b38695..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,9 +300,9 @@ class BlobDB( } // The watch firmware sends health settings (activityPreferences, unitsDistance, - // hrmPreferences, heartRatePreferences, bloodOxygenPreferences, bloodOxygenActivityPreferences) - // 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) @@ -312,7 +312,8 @@ class BlobDB( "hrmPreferences", "heartRatePreferences", "bloodOxygenPreferences", - "bloodOxygenActivityPreferences" -> BlobDatabase.HealthParams + "bloodOxygenActivityPreferences", + "spo2Preferences" -> BlobDatabase.HealthParams else -> message.database } } 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 4e501e60..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 @@ -25,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 @@ -117,6 +120,17 @@ interface HealthSettingsEntryRealDao : HealthSettingsEntryDao { 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/entity/HealthSettings.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/HealthSettings.kt index 81939906..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 @@ -17,6 +17,8 @@ import io.rebble.libpebblecommon.database.entity.HeartRatePreferencesValue.Compa 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 @@ -24,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 @@ -59,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)) { @@ -76,6 +78,7 @@ data class HealthSettingsEntry( 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 } } @@ -91,6 +94,7 @@ 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: @@ -131,10 +135,17 @@ fun HealthSettingsEntryDao.getWatchSettings(): Flow { val bloodOxygenActivityPrefsFlow = getEntryFlow(KEY_BLOOD_OXYGEN_ACTIVITY_PREFERENCES).map { BloodOxygenActivityPreferencesValue.fromString(it?.value) ?: BloodOxygenActivityPreferencesValue() } - // kotlinx.coroutines.flow.combine only has typed overloads up to 5 flows; bundle the two SpO2 - // prefs into a pair so the outer combine stays within that limit. - val bloodOxygenCombined = combine(bloodOxygenPrefsFlow, bloodOxygenActivityPrefsFlow) { prefs, activityPrefs -> - prefs to activityPrefs + 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, @@ -143,7 +154,7 @@ fun HealthSettingsEntryDao.getWatchSettings(): Flow { heartRatePrefsFlow, bloodOxygenCombined, ) { activityPrefs, unitPrefs, hrmPrefs, heartRatePrefs, bloodOxygen -> - val (bloodOxygenPrefs, bloodOxygenActivityPrefs) = bloodOxygen + val (bloodOxygenPrefs, bloodOxygenActivityPrefs, spo2Prefs) = bloodOxygen HealthSettings( heightMm = activityPrefs.heightMm, weightDag = activityPrefs.weightDag, @@ -164,6 +175,7 @@ fun HealthSettingsEntryDao.getWatchSettings(): Flow { hrZone3Threshold = heartRatePrefs.zone3Threshold, bloodOxygenEnabled = bloodOxygenPrefs.enabled, bloodOxygenActivityEnabled = bloodOxygenActivityPrefs.enabled, + spo2MeasurementInterval = spo2Prefs.measurementInterval, ) } } @@ -237,6 +249,15 @@ suspend fun HealthSettingsEntryDao.setWatchSettings(healthSettings: HealthSettin timestamp = now, ) ) + insertOrReplace( + HealthSettingsEntry( + id = KEY_SPO2_PREFERENCES, + value = Spo2PreferencesValue( + measurementInterval = healthSettings.spo2MeasurementInterval, + ).encodeToString(), + timestamp = now, + ) + ) } @Serializable @@ -328,8 +349,9 @@ data class HeartRatePreferencesValue( /** * Blood oxygen (SpO2) monitoring preference. Defaults to OFF — the watch gates SpO2 on this - * synced setting, so nothing is produced until it's enabled. Single byte: 0x01 enabled, 0x00 - * disabled. The measurement interval is watch-local and not phone-settable. + * 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( @@ -363,6 +385,25 @@ data class BloodOxygenActivityPreferencesValue( } } +/** + * 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, @@ -427,6 +468,12 @@ class BloodOxygenActivityPreferencesBlobItem( 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/WatchPrefEntity.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/database/entity/WatchPrefEntity.kt index 059fa40f..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 @@ -650,6 +650,7 @@ enum class ColorWatchPref( - 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/health/Health.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt index c8c475e5..37ea3d8e 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/health/Health.kt @@ -476,6 +476,8 @@ data class HealthSettings( 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/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt index 0071e5ac..f2fbff02 100644 --- a/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt +++ b/pebble/src/commonMain/kotlin/coredevices/pebble/ui/WatchSettingsScreen.kt @@ -247,6 +247,7 @@ object SettingsIds { const val HrmActivityTracking = "HrmActivityTracking" const val BloodOxygenEnabled = "BloodOxygenEnabled" const val BloodOxygenActivityEnabled = "BloodOxygenActivityEnabled" + const val Spo2MeasurementInterval = "Spo2MeasurementInterval" } data class SettingsItem( @@ -1031,7 +1032,7 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD basicSettingsToggleItem( id = SettingsIds.BloodOxygenEnabled, title = "Blood Oxygen", - description = "Allow the watch to measure blood oxygen (SpO2). Readings are taken roughly every 10 minutes.", + description = "Allow the watch to measure blood oxygen (SpO2). The sampling rate is configurable below.", topLevelType = TopLevelType.Phone, section = Section.Health, checked = healthSettings.bloodOxygenEnabled, @@ -1042,6 +1043,29 @@ fun rememberSettingsItemsState(navBarNav: NavBarNav?, snackbarDisplay: SnackbarD ) }, ), + 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",