diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt index 396739cd7..0b4b85abc 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleFactory.kt @@ -46,6 +46,7 @@ import com.health.openscale.core.bluetooth.scales.MGBHandler import com.health.openscale.core.bluetooth.scales.MedisanaBs44xHandler import com.health.openscale.core.bluetooth.scales.MiScaleHandler import com.health.openscale.core.bluetooth.scales.MiScaleS400Handler +import com.health.openscale.core.bluetooth.scales.BodyConnectHandler import com.health.openscale.core.bluetooth.scales.OkOkHandler import com.health.openscale.core.bluetooth.scales.OneByoneHandler import com.health.openscale.core.bluetooth.scales.OneByoneNewHandler @@ -61,6 +62,7 @@ import com.health.openscale.core.bluetooth.scales.SenssunHandler import com.health.openscale.core.bluetooth.scales.SinocareHandler import com.health.openscale.core.bluetooth.scales.SoehnleHandler import com.health.openscale.core.bluetooth.scales.SppScaleAdapter +import com.health.openscale.core.bluetooth.scales.TaylorBIAHandler import com.health.openscale.core.bluetooth.scales.DrTrustSSW532Handler import com.health.openscale.core.bluetooth.scales.StandardBeurerSanitasHandler import com.health.openscale.core.bluetooth.scales.TrisaBodyAnalyzeHandler @@ -94,7 +96,11 @@ class ScaleFactory @Inject constructor( private val TAG = "ScaleHandlerFactory" // List of modern Kotlin-based device handlers. + // Order matters: createCommunicator() returns the FIRST handler whose supportFor() is non-null. + // TaylorBIAHandler must stay ahead of MGBHandler — both live on service 0xFFB0, and MGBHandler + // also matches that service, so a later position would let MGB wrongly claim the Taylor scale. private val modernKotlinHandlers: List = listOf( + TaylorBIAHandler(), RyFitHandler(), CultSmartScaleProHandler(), RealmeSmartScaleHandler(), @@ -137,6 +143,7 @@ class ScaleFactory @Inject constructor( AAAxHandler(), ActiveEraBF06Handler(), DrTrustSSW532Handler(), + BodyConnectHandler(), ) /** diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt new file mode 100644 index 000000000..d3e19db3b --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt @@ -0,0 +1,300 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.service.ScannedDeviceInfo +import com.health.openscale.core.utils.ConverterUtils + +import java.util.Date +import java.util.UUID +import kotlin.random.Random + +/** + * BodyConnectHandler + * ------------------ + * Modern Kotlin handler for the **1BODY CONNECT** smart scale (Transtek family). + * + * Protocol highlights (per BTSnoop analysis): + * - GATT Service: 0x7892 + * - Weight: 0x8A24 (0x1F frames — weight records) + * - Body Comp: 0x8A22 (0x7F frames — body composition) + * - Download (host→dev): 0x8A81 (commands) + * - Upload (dev→host): 0x8A82 (notifications) + * + * Device→host opcodes: + * - 0xA0 = Password (32-bit, unknown; persisted per device) + * - 0xA1 = Challenge (always 0x11111111; host XORs with password and replies) + * - 0x83 = Slot Status (8 user slots, each with a 16-char name) + * - 0xC0 = Profile Echo (confirms user profile after time set) + * + * Host→device opcodes: + * - 0x02 = Set Time (UTC timestamp as seconds since 2010-01-01) + * - 0x03 = Slot Ack (echo of a slot status record) + * - 0x20 = Challenge Resp (challenge XOR password) + * - 0x21 = Broadcast ID (sent during pairing) + * - 0x22 = Enable Disconnect + * - 0x51 = User Profile (gender, age, height) + * + * @see TrisaBodyAnalyzeHandler similar challenge-response protocol + */ +class BodyConnectHandler : ScaleDeviceHandler() { + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + val name = device.name + if (!name.startsWith("1BODY CONNECT") && !name.startsWith("0BODY CONNECT")) return null + return DeviceSupport( + displayName = "1BODY CONNECT", + capabilities = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC, DeviceCapability.USER_SYNC, DeviceCapability.HISTORY_READ), + implemented = setOf(DeviceCapability.BODY_COMPOSITION, DeviceCapability.TIME_SYNC, DeviceCapability.HISTORY_READ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + // --- UUIDs (Bluetooth Base UUID, 16-bit short codes) ----------------------- + + private val SVC = uuid16(0x7892) + private val CHR_WEIGHT = uuid16(0x8A24) // 0x1F weight frames + private val CHR_BODY = uuid16(0x8A22) // 0x7F body comp frames + private val CHR_DNLD = uuid16(0x8A81) // host → device + private val CHR_UPLD = uuid16(0x8A82) // device → host + + // --- Upload (device → host) opcodes ---------------------------------------- + + private val CMD_PASSWORD: Byte = 0xA0.toByte() + private val CMD_CHALLENGE: Byte = 0xA1.toByte() + private val CMD_SLOT_STATUS: Byte = 0x83.toByte() + private val CMD_PROFILE_ECHO: Byte = 0xC0.toByte() + + // --- Download (host → device) opcodes -------------------------------------- + + private val CMD_ACK: Byte = 0x03 + private val CMD_TIME: Byte = 0x02 + private val CMD_CHALLENGE_RESPONSE: Byte = 0x20 + private val CMD_BROADCAST: Byte = 0x21 + private val CMD_ENABLE_DISCONNECT: Byte = 0x22 + + // Non-zero broadcast ID required for pairing to succeed; generated randomly per device instance + private val BROADCAST_ID = Random.nextInt(Int.MAX_VALUE - 1) + 1 + + // Timestamp base: 2010-01-01 00:00:00 UTC; device stores seconds since this epoch + private val TS_OFFSET = 1262304000L + + // --- Pairing state --------------------------------------------------------- + + private var pairing = false + private var password: Int? = null + private var statusAcked = false + + // --- Frame matching -------------------------------------------------------- + // 0x1F and 0x7F frames share a device timestamp; we cache the weight from + // 0x1F and match it when 0x7F arrives with the same timestamp. + + private var lastTS: Int? = null + private var lastWeight: Float? = null + + // --- Lifecycle ------------------------------------------------------------- + + override fun onConnected(user: ScaleUser) { + setNotifyOn(SVC, CHR_WEIGHT) + setNotifyOn(SVC, CHR_BODY) + setNotifyOn(SVC, CHR_UPLD) + + // Restore password persisted from a previous pairing session + password = settingsGetInt("bodyconnect/password", -1).takeIf { it != -1 } + pairing = false + statusAcked = false + } + + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + when (characteristic) { + CHR_UPLD -> onUpload(data) + CHR_WEIGHT -> onWeight(data) + CHR_BODY -> onBody(data, user) + else -> logW("Unknown characteristic: $characteristic") + } + } + + // --- Upload (device → host) processing ------------------------------------- + + private fun onUpload(data: ByteArray) { + if (data.isEmpty()) return + when (data[0]) { + CMD_PASSWORD -> onPassword(data) + CMD_CHALLENGE -> onChallenge(data) + CMD_PROFILE_ECHO -> onProfileEcho() + CMD_SLOT_STATUS -> onSlotStatus(data) + } + } + + private fun onPassword(data: ByteArray) { + if (data.size < 5) return + val pw = ConverterUtils.fromSignedInt32Le(data, 1) + password = pw + settingsPutInt("bodyconnect/password", pw) + + userInfo(R.string.bluetooth_scale_trisa_success_pairing) + pairing = true + // Broadcast ID must be set before the scale accepts further commands + writeCommand(CMD_BROADCAST, BROADCAST_ID) + } + + private fun onChallenge(data: ByteArray) { + if (data.size < 5) return + val pw = password ?: run { + userWarn(R.string.bluetooth_scale_trisa_message_not_paired_instruction) + requestDisconnect() + return + } + val challenge = ConverterUtils.fromSignedInt32Le(data, 1) + writeCommand(CMD_CHALLENGE_RESPONSE, challenge xor pw) + + if (!pairing) { + // Already paired: send profile + time directly (scale skips slot negotiation) + writeProfile(currentAppUser()) + writeCommand(CMD_TIME, javaTimeToDevice(System.currentTimeMillis())) + } + } + + private fun onProfileEcho() { + // Scale confirms the user profile; we signal that setup is complete. + writeCommand(CMD_ENABLE_DISCONNECT) + } + + private fun onSlotStatus(data: ByteArray) { + // Scale lists its 8 user slots (first non‑empty one must be acknowledged) + if (data.size < 3 || statusAcked) return + val hasName = (2 until data.size).any { i -> + val b = data[i].toInt() and 0xFF + b != 0x00 && b != 0x20 + } + if (!hasName) return + statusAcked = true + + // Echo the slot record with opcode 0x03 to acknowledge it + val ack = data.copyOf().also { it[0] = CMD_ACK } + writeTo(SVC, CHR_DNLD, ack, withResponse = true) + + // Now send profile, time, and signal setup complete + val user = currentAppUser() + writeProfile(user) + writeCommand(CMD_TIME, javaTimeToDevice(System.currentTimeMillis())) + writeCommand(CMD_ENABLE_DISCONNECT) + } + + // --- Frame parsing --------------------------------------------------------- + + private fun onWeight(data: ByteArray) { + if (data.size < 20 || data[0] != 0x1F.toByte()) return + + // 0x1F frame layout: + // off 0: opcode 0x1F + // off 1-2: weight (LE uint16, /100 = kg) + // off 5-8: device timestamp (LE int32) + lastWeight = ConverterUtils.fromUnsignedInt16Le(data, 1) / 100f + lastTS = ConverterUtils.fromSignedInt32Le(data, 5) + } + + private fun onBody(data: ByteArray, user: ScaleUser) { + if (data.size < 20 || data[0] != 0x7F.toByte()) return + + // 0x7F frame layout: + // off 0: opcode 0x7F + // off 1-4: device timestamp (LE int32, matches paired 0x1F) + // off 5: (0x01 ?) + // off 8-9: fat % (if hi nibble == 0xF) + // off 10-11: water % (if hi nibble == 0xF) + // off 14-15: muscle % (if hi nibble == 0xF) + // off 16-17: bone % (if hi nibble == 0xF) + + val fat = parseBodyComp(data, 8) + val water = parseBodyComp(data, 10) + val muscle = parseBodyComp(data, 14) + val bone = parseBodyComp(data, 16) + + if (fat == null && water == null && muscle == null && bone == null) return + + val ts = ConverterUtils.fromSignedInt32Le(data, 1) + val weight = if (lastTS == ts) lastWeight else null + if (weight == null || weight <= 0f) return + + val m = ScaleMeasurement().apply { + dateTime = Date(deviceTimeToJava(ts)) + this.weight = weight + } + fat?.let { m.fat = it } + water?.let { m.water = it } + muscle?.let { m.muscle = it } + bone?.let { m.bone = it } + publish(m) + } + + // --- Download (host → device) helpers -------------------------------------- + + private fun writeProfile(user: ScaleUser) { + val b = ByteArray(15) + b[0] = 0x51.toByte() + b[1] = 0xDF.toByte() + b[2] = 0x01.toByte() + b[3] = if (user.gender.isMale()) 0x01.toByte() else 0x00.toByte() + b[4] = user.age.toByte() + b[5] = user.bodyHeight.toInt().toByte() + b[6] = 0xE0.toByte() + b[7] = 0x00.toByte() + b[8] = 0x00.toByte() + b[9] = 0x40.toByte() + b[10] = 0x1F.toByte() + b[11] = 0x00.toByte() + b[12] = 0xFE.toByte() + b[13] = 0x00.toByte() + b[14] = 0x00.toByte() + writeTo(SVC, CHR_DNLD, b, withResponse = true) + } + + private fun parseBodyComp(data: ByteArray, off: Int): Float? { + // Two bytes: hi nibble of second byte must be 0xF to indicate valid data + if (off + 1 >= data.size) return null + val lo = data[off].toInt() and 0xFF + val hi = data[off + 1].toInt() and 0xFF + return if (hi and 0xF0 == 0xF0) ((hi and 0x0F) shl 8 or lo) / 10f else null + } + + private fun writeCommand(opcode: Byte) { + writeTo(SVC, CHR_DNLD, byteArrayOf(opcode), withResponse = true) + } + + private fun writeCommand(opcode: Byte, arg: Int) { + val b = ByteArray(5).also { + it[0] = opcode + ConverterUtils.toInt32Le(it, 1, arg.toLong()) + } + writeTo(SVC, CHR_DNLD, b, withResponse = true) + } + + // --- Timestamp conversion -------------------------------------------------- + + private fun javaTimeToDevice(ms: Long): Int { + return (((ms + 500) / 1000) - TS_OFFSET).toInt() + } + + private fun deviceTimeToJava(s: Int): Long { + return 1000L * (TS_OFFSET + s.toLong()) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/InlifeHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/InlifeHandler.kt index c55217f00..4446e812b 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/InlifeHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/InlifeHandler.kt @@ -53,9 +53,8 @@ class InlifeHandler : ScaleDeviceHandler() { override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { val name = device.name.lowercase(Locale.ROOT) val byName = name in setOf("000fatscale01", "000fatscale02", "042fatscale01") - val bySvc = device.serviceUuids.any { it == SVC } - if (!byName && !bySvc) return null + if (!byName) return null val caps = setOf( DeviceCapability.LIVE_WEIGHT_STREAM, diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt index f02a57d67..910de4ddf 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/StandardBeurerSanitasHandler.kt @@ -32,7 +32,7 @@ import java.util.UUID */ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { - private enum class Model { BEURER_BF105, BEURER_BF950, BEURER_BF500, BEURER_BF600 } + private enum class Model { BEURER_BF105, BEURER_BF950, BEURER_BF500, BEURER_BF600, BEURER_BF450 } private val scaleUserList = mutableListOf() private data class Profile( @@ -81,6 +81,14 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { chrInitials = uuid16(0xFFF6), // BF850 initials chrTargetWeight = null ) + Model.BEURER_BF450 -> Profile( + service = uuid16(0xFFFF), + chrUserList = uuid16(0xFFF1), + chrActivity = uuid16(0xFFF2), + chrTakeMeasurement = uuid16(0xFFF4), + chrInitials = null, + chrTargetWeight = null + ) } private fun nameFor(m: Model) = when (m) { @@ -88,6 +96,7 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { Model.BEURER_BF950 -> "Beurer BF950" Model.BEURER_BF500 -> "Beurer BF500" Model.BEURER_BF600 -> "Beurer BF600" + Model.BEURER_BF450 -> "Beurer BF450" } fun driverName(): String = friendlyName ?: "Beurer" @@ -101,6 +110,7 @@ class StandardBeurerSanitasHandler : StandardWeightProfileHandler() { "bf950" in name || "sbf77" in name || "sbf76" in name -> Model.BEURER_BF950 "bf500" in name -> Model.BEURER_BF500 "bf600" in name || "bf850" in name -> Model.BEURER_BF600 + "bf450" in name -> Model.BEURER_BF450 else -> return null } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandler.kt new file mode 100644 index 000000000..eafc2a573 --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandler.kt @@ -0,0 +1,294 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import com.health.openscale.R +import com.health.openscale.core.bluetooth.data.ScaleMeasurement +import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.service.ScannedDeviceInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Calendar +import java.util.Date +import java.util.Locale +import java.util.UUID + +/** + * Taylor 5331891 BIA (Body Composition) bathroom scale. + * + * Advertises as "5331891 BIA Scale" and exposes the same GATT layout as the MGB family + * (service 0xFFB0), which is why [MGBHandler] previously mis-claimed it. The Taylor's NOTIFY + * payload format is different, so it needs its own handler. Register this handler *before* + * [MGBHandler] in ScaleFactory so it wins the name match. + * + * Service 0xFFB0: + * 0xFFB1 – config write (App → Scale) + * 0xFFB2 – data NOTIFY (Scale → App, 20 bytes) + * + * NOTIFY frame (20 bytes), reverse-engineered from btsnoop_hci.log: + * AC 27 02 00 00 01 00 00 00 00 00 00 00 24 D5 + * 0 1 2 3 4 5 ...................................... 17 18 19 + * - byte[2] flag : 0x00 = live/streaming, 0x80 = stable/locked + * - byte[3] chan : weight channel; the low bit is weight bit 16, so 0x8C = weight < 65.536 kg + * (also the idle frame 8C 00 00) and 0x8D = weight ≥ 65.536 kg + * - byte[4..5] : value, big-endian + * - weight_kg = (((chan & 0x01) << 16) | (hi << 8) | lo) / 1000.0 + * Verified across two captures: + * AC 27 80 8D 2F 52 → 77.650 kg = 171.2 lb + * AC 27 80 8D 32 40 → 78.400 kg = 172.8 lb (≈ the app's 173.0 lb) + * + * Summary frame (emitted once when the reading locks): + * AC 27 01 00 02 01 80 8D 00 … — echoes the locked weight at bytes[8..10]. + * + * Publishing: in practice, under openScale's basic init the scale never emits a stable (0x80) or + * summary (0x01) frame — it just streams live values that plateau. So we publish using a stability + * heuristic: once the same weight has repeated [STABLE_FRAMES] times in a row the reading is final. + * The explicit stable/summary frames are still honored if they ever arrive, and [armFallback] is a + * last-resort timer for the case where the weight never settles. + * + * Body composition: this scale transmits WEIGHT ONLY over BLE. A clean single-measurement capture + * contains nothing but 0x8D weight frames (plus one idle 0x8C 00 00) — no impedance channel. The + * Taylor app computes fat/water/muscle/etc. on the phone from weight + the user profile using vendor + * formulas that are not exposed over Bluetooth, so they cannot be reproduced here. We therefore + * publish weight only and declare BODY_COMPOSITION as a (theoretical) capability but NOT implemented. + */ +class TaylorBIAHandler : ScaleDeviceHandler() { + + companion object { + /** + * Number of consecutive identical weight readings that marks the measurement as final. + * The scale's settled value repeats verbatim once the user is steady, whereas the step-on + * ramp values are all distinct — so a short run of identical frames (~1 s at ~4 Hz) is a + * reliable "stable" signal even though this scale never sends an explicit stable/summary frame. + */ + private const val STABLE_FRAMES = 4 + + /** + * Last-resort timeout (ms): if the weight never settles into a [STABLE_FRAMES] run (e.g. the + * user keeps shifting), publish the latest reading anyway so a measurement is still recorded. + */ + private const val FALLBACK_DELAY_MS = 8000L + + /** + * Decode the weight (kg) from a NOTIFY frame's channel/hi/lo bytes. + * + * The scale reports grams as a 17-bit big-endian value: bits 0-15 are [hi][lo] and bit 16 is + * the low bit of the channel byte (0x8C → 0, 0x8D → +65 536 g). Hence: + * weight_kg = (((chan & 0x01) << 16) | (hi << 8) | lo) / 1000.0 + * + * Pure and side-effect free so it can be unit-tested directly (see TaylorBIAHandlerTest). + */ + fun decodeWeightKg(chan: Byte, hi: Byte, lo: Byte): Float { + val g = ((chan.toInt() and 0x01) shl 16) or + ((hi.toInt() and 0xFF) shl 8) or + (lo.toInt() and 0xFF) + return g / 1000.0f + } + } + + private val SERVICE: UUID = uuid16(0xFFB0) + private val CHAR_CFG: UUID = uuid16(0xFFB1) // FFB1: config/command writes (App → Scale) + private val CHAR_DATA: UUID = uuid16(0xFFB2) // FFB2: measurement notifications (Scale → App) + + /** Most recent live (non-zero) weight seen this session; the value we publish once it settles. */ + private var pendingWeightKg: Float = 0f + + /** Run-length of consecutive identical readings; when it hits [STABLE_FRAMES] we publish. */ + private var stableCount = 0 + + /** Guards against publishing more than once per session (stability, summary and fallback can all fire). */ + private var published = false + + /** Last-resort timer (see [FALLBACK_DELAY_MS]) for the case where the weight never settles. */ + private var fallbackJob: Job? = null + + override fun supportFor(device: ScannedDeviceInfo): DeviceSupport? { + // Match on the advertised name (NOT service 0xFFB0) so we don't re-collide with MGBHandler. + val name = device.name.uppercase(Locale.ROOT) + if (!name.startsWith("5331891") && !name.contains("BIA SCALE")) return null + + return DeviceSupport( + displayName = "Taylor 5331891 BIA Scale", + capabilities = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + DeviceCapability.BODY_COMPOSITION, + DeviceCapability.USER_SYNC, + DeviceCapability.TIME_SYNC, + DeviceCapability.UNIT_CONFIG, + ), + // The scale transmits weight only over BLE; body composition is computed app-side by the + // vendor and is not available to us, so it stays out of `implemented`. + implemented = setOf( + DeviceCapability.LIVE_WEIGHT_STREAM, + ), + linkMode = LinkMode.CONNECT_GATT + ) + } + + override fun onConnected(user: ScaleUser) { + // Handlers are long-lived singletons reused across connections (see ScaleFactory), so reset + // all per-session state at the start of every connection. + pendingWeightKg = 0f + stableCount = 0 + published = false + fallbackJob?.cancel() + fallbackJob = null + + // 1) Subscribe to measurement notifications on FFB2. + setNotifyOn(SERVICE, CHAR_DATA) + + // 2) Minimal MGB-style init sequence on FFB1. We don't strictly need the user/clock data to + // read weight, but the scale only began streaming weight frames after this exact 8-byte + // "AC 02 .. CC" handshake in the openScale debug capture, so we replay it verbatim. + writeCfg(0xF7, 0, 0, 0) // magic init #1 + writeCfg(0xFA, 0, 0, 0) // magic init #2 + + // User profile: sex (1=male, 2=female), age in years, height in cm. + val sexByte = if (user.gender.isMale()) 1 else 2 + val heightCm = user.bodyHeight.toInt().coerceAtLeast(0) + writeCfg(0xFB, sexByte, user.age, heightCm) + + // Date (year since 2000, month 1-12, day) and time (HH, MM, SS) from the phone clock. + val now = Calendar.getInstance() + val yy = (now.get(Calendar.YEAR) - 2000).coerceIn(0, 99) + writeCfg(0xFD, yy, now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH)) + writeCfg(0xFC, now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND)) + + // Display unit: legacy WeightUnit.toInt() mapping (KG=1, LB=2, ST=3). + writeCfg(0xFE, 6, user.scaleUnit.toInt(), 0) + + // Prompt the user to step on; the result arrives asynchronously via onNotification(). + userInfo(R.string.bt_info_step_on_scale) + } + + override fun onDisconnected() { + fallbackJob?.cancel() + fallbackJob = null + // Last-chance weight-only publish if a live weight was seen but never finalized. + if (!published && pendingWeightKg > 0f) { + published = true + publish(ScaleMeasurement().apply { + dateTime = Date() + weight = pendingWeightKg + }) + } + } + + // `user` is unused: this scale reports only weight, which needs no per-user decoding. + override fun onNotification(characteristic: UUID, data: ByteArray, user: ScaleUser) { + if (characteristic != CHAR_DATA) return + // Every valid measurement frame is 20 bytes and starts with the "AC 27" header; ignore the rest. + if (data.size < 20 || data[0].toInt() and 0xFF != 0xAC || data[1].toInt() and 0xFF != 0x27) return + + val flag = data[2].toInt() and 0xFF // 0x80 = stable/locked, 0x01 = summary record, else live + val chan = data[3].toInt() and 0xFF // weight channel (0x8C/0x8D) or 0x00 for the summary frame + + // Summary frame: AC 27 01 00 02 01 80 00 … + // It echoes the locked weight at bytes[8..10]; publishWeight() is idempotent so it is safe even + // when the preceding stable frame already published this same measurement. + if (flag == 0x01 && chan == 0x00) { + publishWeight(weightKg(data[8], data[9], data[10])) + return + } + + // Weight streams on the 0x8C/0x8D channel: the channel byte's low bit is weight bit 16, so + // readings < 65.536 kg use 0x8C and readings ≥ 65.536 kg use 0x8D. Idle is 8C 00 00, which + // decodes to 0 and is filtered by the w > 0 guard below. + if (chan == 0x8C || chan == 0x8D) { + val w = weightKg(data[3], data[4], data[5]) + if (w <= 0f) return + + // Stability detection: count how many identical readings arrive back-to-back. The settled + // value repeats exactly while the step-on ramp values are all distinct, so once we've seen + // the same weight STABLE_FRAMES times in a row we treat it as final and publish immediately. + stableCount = if (w == pendingWeightKg) stableCount + 1 else 1 + pendingWeightKg = w + + if (flag == 0x80 || stableCount >= STABLE_FRAMES) { + publishWeight(w) // explicit stable frame, or our own stability heuristic + } else { + armFallback() // safety net while the reading is still moving + } + } + } + + // --- Finalize / publish --------------------------------------------------- + + /** + * Emit the final measurement exactly once, then close the link. Idempotent: the first caller wins + * (stability run, stable frame, summary frame or fallback timer), the rest are no-ops via [published]. + */ + private fun publishWeight(weightKg: Float) { + if (published || weightKg <= 0f) return + published = true + fallbackJob?.cancel() + fallbackJob = null + + // Weight-only: the scale transmits no impedance/body-composition data over BLE. + val measurement = ScaleMeasurement().apply { + dateTime = Date() + weight = weightKg + } + + publish(measurement) + requestDisconnect() // free the connection; this scale sends nothing more after a locked reading + } + + /** + * Safety net: armed on the first live reading, fires once after [FALLBACK_DELAY_MS]. Normally the + * stability heuristic publishes first; this only triggers if the weight never settles into a + * [STABLE_FRAMES] run, so we still record the latest value instead of hanging until disconnect. + */ + private fun armFallback() { + if (fallbackJob != null || published) return + fallbackJob = scope.launch { + delay(FALLBACK_DELAY_MS) + if (!published && pendingWeightKg > 0f) { + logD("Weight never stabilized within ${FALLBACK_DELAY_MS} ms; publishing latest reading") + publishWeight(pendingWeightKg) + } + } + } + + // --- Helpers -------------------------------------------------------------- + + private fun weightKg(chan: Byte, hi: Byte, lo: Byte): Float = decodeWeightKg(chan, hi, lo) + + /** + * Writes an 8-byte config packet to 0xFFB1 (same framing as the MGB family): + * [AC, 02, b2, b3, b4, b5, CC, checksum], checksum = (b2 + b3 + b4 + b5 + 0xCC) & 0xFF. + */ + private fun writeCfg(b2: Int, b3: Int, b4: Int, b5: Int) { + val buf = ByteArray(8) + buf[0] = 0xAC.toByte() + buf[1] = 0x02.toByte() + buf[2] = (b2 and 0xFF).toByte() + buf[3] = (b3 and 0xFF).toByte() + buf[4] = (b4 and 0xFF).toByte() + buf[5] = (b5 and 0xFF).toByte() + buf[6] = 0xCC.toByte() + val sum = (buf[2].toUByte().toInt() + + buf[3].toUByte().toInt() + + buf[4].toUByte().toInt() + + buf[5].toUByte().toInt() + + buf[6].toUByte().toInt()) and 0xFF + buf[7] = sum.toByte() + writeTo(SERVICE, CHAR_CFG, buf, withResponse = true) + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt index c5be7fca5..f821e2737 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt @@ -31,7 +31,7 @@ import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.UnitType import com.health.openscale.core.facade.SettingsFacade -import com.health.openscale.core.usecase.MeasurementTypeCrudUseCases +import com.health.openscale.core.usecase.GenericValueJson import com.health.openscale.core.utils.ConverterUtils import com.health.openscale.core.utils.LogManager import dagger.hilt.EntryPoint @@ -49,7 +49,6 @@ import kotlinx.coroutines.runBlocking interface DatabaseProviderEntryPoint { fun databaseRepository(): DatabaseRepository fun userSettingsFacade(): SettingsFacade - fun measurementTypeCrudUseCases(): MeasurementTypeCrudUseCases } /** @@ -62,7 +61,6 @@ class DatabaseProvider : ContentProvider() { private lateinit var databaseRepository: DatabaseRepository private lateinit var userSettingsFacade: SettingsFacade - private lateinit var measurementTypeUseCases: MeasurementTypeCrudUseCases object UserColumns { const val _ID = "_ID" @@ -76,6 +74,8 @@ class DatabaseProvider : ContentProvider() { const val BODY_FAT = "fat" const val WATER = "water" const val MUSCLE = "muscle" + // Phase 2: self-describing generic value set (all types incl. custom) as a JSON string. + const val VALUES_JSON = "values_json" } override fun onCreate(): Boolean { @@ -87,7 +87,6 @@ class DatabaseProvider : ContentProvider() { ) databaseRepository = entryPoint.databaseRepository() userSettingsFacade = entryPoint.userSettingsFacade() - measurementTypeUseCases = entryPoint.measurementTypeCrudUseCases() CoroutineScope(Dispatchers.IO).launch { val isFileLogging = userSettingsFacade.isFileLoggingEnabled.first() @@ -155,58 +154,29 @@ class DatabaseProvider : ContentProvider() { } runBlocking { try { - val weightType = measurementTypeUseCases.getByKey(MeasurementTypeKey.WEIGHT) - val measurementsWithValuesList = databaseRepository.getMeasurementsWithValuesForUser(userIdFromUri).first() + // The self-describing generic value set (values_json) is the single source of + // truth; the sync app derives weight/fat/water/muscle from it. val defaultMeasurementProjection = arrayOf( MeasurementColumns._ID, MeasurementColumns.DATETIME, - MeasurementColumns.WEIGHT, - MeasurementColumns.BODY_FAT, - MeasurementColumns.WATER, - MeasurementColumns.MUSCLE + MeasurementColumns.VALUES_JSON ) val currentProjection = projection ?: defaultMeasurementProjection val matrixCursor = MatrixCursor(currentProjection) - val allMeasurementTypes = databaseRepository.getAllMeasurementTypes().first() - - measurementsWithValuesList.forEachIndexed { index, mcv -> // mcv is MeasurementWithValues + measurementsWithValuesList.forEach { mcv -> // mcv is MeasurementWithValues val measurement = mcv.measurement - val valuesMap = mcv.values.associateBy { it.type.key } - val weightInKg = valuesMap[MeasurementTypeKey.WEIGHT]?.value?.floatValue?.let { - weightType?.unit?.let { unit -> ConverterUtils.convertFloatValueUnit(it, unit, UnitType.KG) } - } - - fun convertToPercent(key: MeasurementTypeKey): Float? { - val value = valuesMap[key]?.value?.floatValue ?: return null - val fromUnit = valuesMap[key]?.type?.unit ?: return null - if (fromUnit == UnitType.PERCENT) return value - if (fromUnit.isWeightUnit() && weightInKg != null && weightInKg > 0) { - val valueInKg = ConverterUtils.convertFloatValueUnit(value, fromUnit, UnitType.KG) - return (valueInKg / weightInKg) * 100f - } - return null - } - - val fatPercent = convertToPercent(MeasurementTypeKey.BODY_FAT) - val waterPercent = convertToPercent(MeasurementTypeKey.WATER) - val musclePercent = convertToPercent(MeasurementTypeKey.MUSCLE) - val rowData = mutableListOf() if (currentProjection.contains(MeasurementColumns._ID)) rowData.add(measurement.id) if (currentProjection.contains(MeasurementColumns.DATETIME)) rowData.add(measurement.timestamp) - if (currentProjection.contains(MeasurementColumns.WEIGHT)) rowData.add(weightInKg) - if (currentProjection.contains(MeasurementColumns.BODY_FAT)) rowData.add(fatPercent ?: 0.0f) - if (currentProjection.contains(MeasurementColumns.WATER)) rowData.add(waterPercent ?: 0.0f) - if (currentProjection.contains(MeasurementColumns.MUSCLE)) rowData.add(musclePercent ?: 0.0f) - - LogManager.d(TAG, "Query Row #${index + 1} for user $userIdFromUri (MeasID: ${measurement.id}): ${ - currentProjection.zip(rowData).joinToString { "${it.first}=${it.second}" } - }") - + if (currentProjection.contains(MeasurementColumns.VALUES_JSON)) { + val typesById = mcv.values.associate { it.type.id to it.type } + val rawValues = mcv.values.map { it.value } + rowData.add(GenericValueJson.build(rawValues, typesById)) + } matrixCursor.addRow(rowData.toTypedArray()) } matrixCursor @@ -310,6 +280,21 @@ class DatabaseProvider : ContentProvider() { addConvertedValue(fatFromProviderPercent, fatType) addConvertedValue(waterFromProviderPercent, waterType) addConvertedValue(muscleFromProviderPercent, muscleType) + + // Inbound flexibility: any additional generic values (all types incl. custom) + // supplied as a "values_json" payload are written too (canonical → user unit). + val valuesJson = values.getAsString(MeasurementColumns.VALUES_JSON) + if (valuesJson != null) { + val typesByKey = allMeasurementTypes.associateBy { it.key.name } + val typesById = allMeasurementTypes.associateBy { it.id } + val existingTypeIds = measurementValuesToInsert.mapTo(HashSet()) { it.typeId } + GenericValueJson.parse(valuesJson, typesByKey, typesById).forEach { (typeId, v) -> + if (typeId !in existingTypeIds) { + measurementValuesToInsert.add(MeasurementValue(measurementId = 0, typeId = typeId, floatValue = v)) + existingTypeIds.add(typeId) + } + } + } } if (weightTypeIdFound == null) { // Double check if weight type ID was resolved @@ -363,7 +348,7 @@ class DatabaseProvider : ContentProvider() { return 0 // Return 0 rows affected } - var rowsAffected = 0 + var rowsAffected: Int when (uriMatcher.match(uri)) { MATCH_TYPE_MEASUREMENT_LIST_FOR_USER -> { @@ -516,33 +501,53 @@ class DatabaseProvider : ContentProvider() { override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int { - LogManager.w(TAG, "Delete operation is not supported by this provider.") - // To implement delete: - // 1. Identify user from URI (MATCH_TYPE_MEASUREMENT_LIST_FOR_USER implies user ID in URI) - // 2. Identify specific measurement to delete. This usually requires more than just user ID. - // Commonly, the `selection` and `selectionArgs` would specify criteria like `DATETIME = ?`. - // Or, you'd have a URI like "measurements//" (MATCH_TYPE_SINGLE_MEASUREMENT). - // Example (conceptual): - /* - if (uriMatcher.match(uri) == MATCH_TYPE_MEASUREMENT_LIST_FOR_USER) { - val userId = ContentUris.parseId(uri).toInt() - if (selection != null && selectionArgs != null) { - // Parse selection to find the measurement (e.g., by datetime) - // val measurementToDelete = databaseRepository.findMeasurementByCriteria(userId, selection, selectionArgs) - // if (measurementToDelete != null) { - // databaseRepository.deleteMeasurementWithValues(measurementToDelete) - // rowsAffected = 1 - // context!!.contentResolver.notifyChange(uri, null) - // } + if (!::databaseRepository.isInitialized) { + LogManager.e(TAG, "DatabaseRepository not initialized in delete.") + return 0 + } + + when (uriMatcher.match(uri)) { + MATCH_TYPE_MEASUREMENT_LIST_FOR_USER -> { + val userId = try { + ContentUris.parseId(uri).toInt() + } catch (e: NumberFormatException) { + LogManager.e(TAG, "Invalid User ID in URI for measurement delete: $uri", e) + return 0 + } + // The measurement is identified by its datetime (epoch millis), passed as the first + // selectionArg (e.g. selection = "datetime = ?"). Enables external (bidirectional) + // sync apps to propagate a delete into openScale. + val datetime = selectionArgs?.firstOrNull()?.toLongOrNull() + if (datetime == null) { + LogManager.e(TAG, "Delete requires a datetime selectionArg (epoch millis).") + return 0 + } + return runBlocking { + try { + val target = databaseRepository.getMeasurementsWithValuesForUser(userId).first() + .find { it.measurement.timestamp == datetime } + if (target == null) { + LogManager.d(TAG, "No measurement to delete for user $userId at $datetime.") + return@runBlocking 0 + } + databaseRepository.deleteMeasurement(target.measurement) + context!!.contentResolver.notifyChange(uri, null) + 1 + } catch (e: Exception) { + LogManager.e(TAG, "Error deleting measurement for user $userId: ${e.message}", e) + 0 + } + } } + else -> throw IllegalArgumentException("Unknown URI for delete: $uri") } - */ - throw UnsupportedOperationException("Delete not supported by this provider") } companion object { private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH) - private const val API_VERSION = 1 + // v2: sync Intents carry userId on delete/clear (multi-user routing). openScale-sync + // requires >= 2 and warns the user to update openScale otherwise. + private const val API_VERSION = 2 val AUTHORITY = BuildConfig.APPLICATION_ID + ".provider" private const val MATCH_TYPE_META = 1 diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/BackupRestoreUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/BackupRestoreUseCases.kt index 4d5ca0145..5bec0eafd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/BackupRestoreUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/BackupRestoreUseCases.kt @@ -51,7 +51,8 @@ import javax.inject.Singleton class BackupRestoreUseCases @Inject constructor( @param:ApplicationContext private val appContext: Context, private val repository: DatabaseRepository, - private val settings: SettingsFacade + private val settings: SettingsFacade, + private val sync: SyncUseCases ) { private val TAG = "BackupRestoreUseCase" @@ -140,6 +141,8 @@ class BackupRestoreUseCases @Inject constructor( } LogManager.i(TAG, "Restore completed. Format=$format, Files=$restored") + // Whole DB replaced → one coalesced "changed" wake so sync apps reconcile their full state. + sync.triggerSyncChangedAll() } finally { if (restoreSessionDir.exists() && !restoreSessionDir.deleteRecursively()) { LogManager.w( diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt index a22f30497..da0423a37 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/ImportExportUseCases.kt @@ -63,7 +63,8 @@ data class ImportReport( */ @Singleton class ImportExportUseCases @Inject constructor( - private val repository: DatabaseRepository + private val repository: DatabaseRepository, + private val sync: SyncUseCases ) { private val TAG = "ImportExportUseCase" @@ -382,6 +383,9 @@ class ImportExportUseCases @Inject constructor( LogManager.e(TAG, "Derived recalculation failed for measurementId=$id", e) } } + + // Bulk import: one coalesced "changed" wake-up instead of N per-measurement events. + sync.triggerSyncChangedAll() } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt index 7ab517cf0..9f25465d8 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementCrudUseCases.kt @@ -158,9 +158,9 @@ class MeasurementCrudUseCases @Inject constructor( measurement: Measurement ): Result = runCatching { databaseRepository.deleteMeasurement(measurement) - sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync") - sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync.oss") - sync.triggerSyncDelete(Date(measurement.timestamp), "com.health.openscale.sync.debug") + sync.triggerSyncDelete(measurement.id, measurement.userId, Date(measurement.timestamp), "com.health.openscale.sync") + sync.triggerSyncDelete(measurement.id, measurement.userId, Date(measurement.timestamp), "com.health.openscale.sync.oss") + sync.triggerSyncDelete(measurement.id, measurement.userId, Date(measurement.timestamp), "com.health.openscale.sync.debug") MeasurementWidget.refreshAll(appContext) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt index 2a810ad54..369a2e6db 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/MeasurementTypeCrudUseCases.kt @@ -23,7 +23,6 @@ import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.UnitType import com.health.openscale.core.data.WeightUnit import com.health.openscale.core.database.DatabaseRepository -import com.health.openscale.core.utils.CalculationUtils import com.health.openscale.core.utils.ConverterUtils import com.health.openscale.core.utils.LogManager import kotlinx.coroutines.flow.first @@ -54,10 +53,9 @@ class MeasurementTypeCrudUseCases @Inject constructor( repository.updateMeasurementType(type) } - /** Finds and returns a specific MeasurementType by its key. */ - suspend fun getByKey(key: MeasurementTypeKey): MeasurementType? { - return repository.getAllMeasurementTypes().first().find { it.key == key } - } + /** All measurement types (predefined + custom). Used by the sync layer to build the + * generic, self-describing value set for external sync apps. */ + suspend fun getAll(): List = repository.getAllMeasurementTypes().first() /** Deletes a measurement type. Caller must ensure cascading semantics are OK. */ suspend fun delete(type: MeasurementType): Result = runCatching { @@ -129,7 +127,7 @@ class MeasurementTypeCrudUseCases @Inject constructor( var updatedCount = 0 for (mv in allValuesForType) { val current = mv.floatValue ?: continue - var converted: Float? = null + var converted: Float? // Percent <-> absolute conversions for composition-like metrics if (typeKey == MeasurementTypeKey.BODY_FAT || diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt index 60159dc2c..d8d4d5cea 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/SyncUseCases.kt @@ -21,11 +21,18 @@ import android.app.Application import android.content.ComponentName import android.content.Intent import androidx.core.content.ContextCompat +import com.health.openscale.core.data.InputFieldType import com.health.openscale.core.data.Measurement -import com.health.openscale.core.data.MeasurementTypeKey +import com.health.openscale.core.data.MeasurementType import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.UnitType import com.health.openscale.core.utils.ConverterUtils +import com.health.openscale.core.utils.LogManager +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.json.JSONArray +import org.json.JSONObject import java.util.Date import javax.inject.Inject import javax.inject.Singleton @@ -44,6 +51,9 @@ class SyncUseCases @Inject constructor( private val application: Application, private val measurementTypeUseCases: MeasurementTypeCrudUseCases ) { + private val _syncAppUnreachable = MutableSharedFlow(extraBufferCapacity = 1) + @Volatile private var lastUnreachableEmit = 0L + /** * Triggers an **insert** sync event for [measurement] with its [values] to the given [pkgName]. @@ -65,9 +75,9 @@ class SyncUseCases @Inject constructor( putExtra("id", measurement.id) putExtra("userId", measurement.userId) putExtra("date", measurement.timestamp) - putBodyCompositionExtras(values) + putGenericValues(values) } - ContextCompat.startForegroundService(application.applicationContext, intent) + startSyncService(pkgName, intent) } /** @@ -90,110 +100,224 @@ class SyncUseCases @Inject constructor( putExtra("id", measurement.id) putExtra("userId", measurement.userId) putExtra("date", measurement.timestamp) - putBodyCompositionExtras(values) + putGenericValues(values) } - ContextCompat.startForegroundService(application.applicationContext, intent) + startSyncService(pkgName, intent) } /** - * Triggers a **delete** sync event for the given [date] to the target [pkgName]. + * Triggers a **delete** sync event for the given [id] / [userId] + [date] to the target [pkgName]. * * Extras: * - "mode" = "delete" + * - "id" = stable measurement id (lets the sync side forget the exact ledger entry) + * - "userId" = owning user id (for multi-user routing on the sync side) * - "date" = [Date.getTime] (epoch millis) */ fun triggerSyncDelete( + id: Int, + userId: Int, date: Date, pkgName: String ): Result = runCatching { val intent = Intent().apply { component = ComponentName(pkgName, SYNC_SERVICE_CLASS) putExtra("mode", "delete") + putExtra("id", id) + putExtra("userId", userId) putExtra("date", date.time) } - ContextCompat.startForegroundService(application.applicationContext, intent) + startSyncService(pkgName, intent) } /** - * Triggers a **clear** sync event to wipe all synced measurements on the target [pkgName]. + * Triggers a **clear** sync event to wipe the synced measurements of [userId] on [pkgName]. * * Extras: * - "mode" = "clear" + * - "userId" = the user whose measurements were cleared */ fun triggerSyncClear( + userId: Int, pkgName: String ): Result = runCatching { val intent = Intent().apply { component = ComponentName(pkgName, SYNC_SERVICE_CLASS) putExtra("mode", "clear") + putExtra("userId", userId) } - ContextCompat.startForegroundService(application.applicationContext, intent) + startSyncService(pkgName, intent) } - // --- helpers --- + /** + * Coalesced **changed** wake-up (no payload) for bulk operations (CSV import / backup restore): + * the sync app reconciles its full state against openScale instead of receiving hundreds of + * individual events. Avoids foreground-service spam. + */ + fun triggerSyncChanged( + pkgName: String + ): Result = runCatching { + val intent = Intent().apply { + component = ComponentName(pkgName, SYNC_SERVICE_CLASS) + putExtra("mode", "changed") + } + startSyncService(pkgName, intent) + } - private suspend fun Intent.putBodyCompositionExtras(values: List) { - val keyById = MeasurementTypeKey.values().associateBy { it.id } - val valuesByType = values.associateBy { v -> keyById[v.typeId] } + /** Convenience: fire a coalesced "changed" wake to all known sync app variants. */ + fun triggerSyncChangedAll() { + SYNC_PACKAGES.forEach { triggerSyncChanged(it) } + } - val weightType = measurementTypeUseCases.getByKey(MeasurementTypeKey.WEIGHT) - val fatType = measurementTypeUseCases.getByKey(MeasurementTypeKey.BODY_FAT) - val waterType = measurementTypeUseCases.getByKey(MeasurementTypeKey.WATER) - val muscleType = measurementTypeUseCases.getByKey(MeasurementTypeKey.MUSCLE) + /** + * Emits the package name of a sync app that is **installed but could not be woken** (the + * foreground-service start failed — typically because the OEM/user force-stopped it). The UI + * shows an unobtrusive "please open openScale-sync" hint with an Open action ([openSyncApp]). + */ + val syncAppUnreachable: SharedFlow = _syncAppUnreachable.asSharedFlow() - val weightValue = valuesByType[MeasurementTypeKey.WEIGHT]?.floatValue - val weightInKg = if (weightValue != null && weightType != null) { - ConverterUtils.convertFloatValueUnit(weightValue, weightType.unit, UnitType.KG) - } else { - null + /** Launches an installed sync app (clears its FLAG_STOPPED so future pushes work again). */ + fun openSyncApp(pkgName: String) { + runCatching { + application.packageManager.getLaunchIntentForPackage(pkgName) + ?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ?.let { application.startActivity(it) } } + } - weightInKg?.let { putExtra("weight", it) } - - fun convertToPercent( - value: Float?, - fromUnit: UnitType?, - totalWeightInKg: Float? - ): Float? { - if (value == null || fromUnit == null || totalWeightInKg == null || totalWeightInKg == 0f) { - return null - } + // --- helpers --- - if (fromUnit == UnitType.PERCENT) { - return value - } + /** Pre-check: only start a sync app that is actually installed (avoids FGS-start exceptions + * and log spam when a variant isn't present). Requires the entries in the manifest. */ + private fun isSyncAppInstalled(pkgName: String): Boolean = try { + application.packageManager.getPackageInfo(pkgName, 0) + true + } catch (_: Exception) { + false + } - if (fromUnit.isWeightUnit()) { - val valueInKg = ConverterUtils.convertFloatValueUnit(value, fromUnit, UnitType.KG) - return (valueInKg / totalWeightInKg) * 100f + /** + * Starts an installed sync app's foreground service. If the start fails although the app is + * installed (force-stopped / blocked), emits [syncAppUnreachable] (rate-limited) so the UI can + * nudge the user to open it. + */ + private fun startSyncService(pkgName: String, intent: Intent) { + if (!isSyncAppInstalled(pkgName)) return + try { + ContextCompat.startForegroundService(application.applicationContext, intent) + } catch (e: Exception) { + LogManager.w("SyncUseCases", "Sync app $pkgName installed but not startable (force-stopped?): ${e.message}") + val now = System.currentTimeMillis() + if (now - lastUnreachableEmit > 60 * 60 * 1000L) { // at most hourly + lastUnreachableEmit = now + _syncAppUnreachable.tryEmit(pkgName) } - - return null } + } - val fatPercent = convertToPercent( - valuesByType[MeasurementTypeKey.BODY_FAT]?.floatValue, - fatType?.unit, - weightInKg - ) - fatPercent?.let { putExtra("fat", it) } + /** + * Adds the full, self-describing generic value set as a JSON "values" extra (Phase 2): every + * MeasurementValue with its type metadata (key/name/unit/inputType/isDerived), the numeric value + * already converted to the dimension's canonical base unit, unit as a UCUM code. Custom types + * ride along (key=="CUSTOM", distinguished by typeId). Lets the sync app forward all 34 types + + * custom without knowing openScale's enums. + */ + private suspend fun Intent.putGenericValues(values: List) { + val typesById = runCatching { measurementTypeUseCases.getAll() }.getOrNull() + ?.associateBy { it.id } ?: return + putExtra("values", GenericValueJson.build(values, typesById)) + } - val waterPercent = convertToPercent( - valuesByType[MeasurementTypeKey.WATER]?.floatValue, - waterType?.unit, - weightInKg + private companion object { + const val SYNC_SERVICE_CLASS = "com.health.openscale.sync.core.service.SyncService" + val SYNC_PACKAGES = listOf( + "com.health.openscale.sync", + "com.health.openscale.sync.oss", + "com.health.openscale.sync.debug" ) - waterPercent?.let { putExtra("water", it) } + } +} - val musclePercent = convertToPercent( - valuesByType[MeasurementTypeKey.MUSCLE]?.floatValue, - muscleType?.unit, - weightInKg - ) - musclePercent?.let { putExtra("muscle", it) } +/** + * Builds/parses the self-describing **generic value set** shared between the sync Intent + * ([SyncUseCases]) and the ContentProvider ([com.health.openscale.core.database.DatabaseProvider]). + * + * Each value carries its type metadata (`typeId`, `key` = MeasurementTypeKey enum name, `name`, + * `unit` as a UCUM code, `inputType`, `isDerived`); the numeric value is in the **canonical base + * unit** of its dimension. Custom types ride along (key == "CUSTOM", distinguished by `typeId`). + * HL7 FHIR Quantity / UCUM-inspired — the sync app forwards all types incl. custom without knowing + * openScale's enums. + */ +object GenericValueJson { + + fun build(values: List, typesById: Map): String { + val arr = JSONArray() + for (v in values) { + val type = typesById[v.typeId] ?: continue + val obj = JSONObject() + obj.put("typeId", type.id) + obj.put("key", type.key.name) + obj.put("name", type.name ?: type.key.name) + obj.put("unit", ucumCode(type.unit)) + obj.put("inputType", type.inputType.name) + obj.put("isDerived", type.isDerived) + when (type.inputType) { + InputFieldType.FLOAT, InputFieldType.INT -> { + val raw = v.floatValue ?: v.intValue?.toFloat() + if (raw != null) { + obj.put("value", ConverterUtils.convertFloatValueUnit(raw, type.unit, canonicalUnit(type.unit)).toDouble()) + } + } + InputFieldType.TEXT -> v.textValue?.let { obj.put("text", it) } + InputFieldType.DATE, InputFieldType.TIME -> v.dateValue?.let { obj.put("text", it.toString()) } + else -> {} + } + arr.put(obj) + } + return arr.toString() } - private companion object { - const val SYNC_SERVICE_CLASS = "com.health.openscale.sync.core.service.SyncService" + /** + * Reverse of [build] (inbound): parse a generic value JSON into (typeId, valueInUserUnit) pairs. + * Predefined types are matched by [typesByKey] (enum name), custom by [typesById] (typeId). + */ + fun parse( + json: String, + typesByKey: Map, + typesById: Map + ): List> { + val out = mutableListOf>() + runCatching { + val arr = JSONArray(json) + for (i in 0 until arr.length()) { + val o = arr.getJSONObject(i) + if (!o.has("value")) continue + val key = o.optString("key", "") + val type = if (key == "CUSTOM") typesById[o.optInt("typeId", -1)] else typesByKey[key] + if (type == null) continue + val canonical = o.getDouble("value").toFloat() + val userValue = ConverterUtils.convertFloatValueUnit(canonical, canonicalUnit(type.unit), type.unit) + out.add(type.id to userValue) + } + } + return out + } + + /** Canonical base unit per dimension (everything is converted to this before sending). */ + fun canonicalUnit(u: UnitType): UnitType = when (u) { + UnitType.LB, UnitType.ST -> UnitType.KG + UnitType.INCH -> UnitType.CM + else -> u + } + + /** UCUM code for the canonical unit of the given unit's dimension. */ + fun ucumCode(u: UnitType): String = when (u) { + UnitType.KG, UnitType.LB, UnitType.ST -> "kg" + UnitType.PERCENT -> "%" + UnitType.CM, UnitType.INCH -> "cm" + UnitType.KCAL -> "kcal" + UnitType.BPM -> "/min" + UnitType.OHM -> "Ohm" + UnitType.NONE -> "" } } diff --git a/android_app/app/src/main/java/com/health/openscale/core/usecase/UserUseCases.kt b/android_app/app/src/main/java/com/health/openscale/core/usecase/UserUseCases.kt index 1d21c254e..e00c8665c 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/usecase/UserUseCases.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/usecase/UserUseCases.kt @@ -127,9 +127,9 @@ class UserUseCases @Inject constructor( /** Delete all measurements for the given user. Returns number of deleted rows. */ suspend fun purgeMeasurementsForUser(userId: Int): Result = runCatching { - sync.triggerSyncClear("com.health.openscale.sync") - sync.triggerSyncClear("com.health.openscale.sync.oss") - sync.triggerSyncClear("com.health.openscale.sync.debug") + sync.triggerSyncClear(userId, "com.health.openscale.sync") + sync.triggerSyncClear(userId, "com.health.openscale.sync.oss") + sync.triggerSyncClear(userId, "com.health.openscale.sync.debug") databaseRepository.deleteAllMeasurementsForUser(userId) } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt index a280dec61..c8baa7151 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/shared/SharedViewModel.kt @@ -44,6 +44,7 @@ import com.health.openscale.core.model.MeasurementInsight import com.health.openscale.core.model.MeasurementWithValues import com.health.openscale.core.model.UserEvaluationContext import com.health.openscale.core.usecase.MeasurementDemoUseCase +import com.health.openscale.core.usecase.SyncUseCases import com.health.openscale.core.utils.LogManager import com.health.openscale.ui.screen.components.AGGREGATION_LEVEL_SUFFIX import com.health.openscale.ui.screen.components.CUSTOM_END_DATE_MILLIS_SUFFIX @@ -72,8 +73,8 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import kotlin.time.Duration.Companion.seconds import java.text.DateFormat -import java.time.LocalDate import java.util.Date import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -97,8 +98,23 @@ class SharedViewModel @Inject constructor( private val measurementFacade: MeasurementFacade, private val dataManagementFacade: DataManagementFacade, private val settingsFacade: SettingsFacade, + private val sync: SyncUseCases, ) : ViewModel(), SettingsFacade by settingsFacade { + // When openScale can't wake an installed-but-force-stopped sync app, nudge the user to open it. + init { + viewModelScope.launch { + sync.syncAppUnreachable.collect { pkg -> + showSnackbar( + messageResId = R.string.sync_app_unreachable_hint, + duration = SnackbarDuration.Long, + actionLabelResId = R.string.sync_app_open_action, + onAction = { sync.openSyncApp(pkg) } + ) + } + } + } + companion object { private const val TAG = "SharedViewModel" } @@ -417,7 +433,7 @@ class SharedViewModel @Inject constructor( /** * Emits a fully computed [MeasurementInsight] for the currently selected user. * Reacts automatically to user switches, measurement data changes, and primary - * type selection changes from [insightsPrimaryTypeIdFlow]. + * type selection changes from insightsPrimaryTypeIdFlow. * * The computation is dispatched to [kotlinx.coroutines.Dispatchers.Default] inside * [MeasurementFacade.insightsForUser] to keep the main thread free. @@ -492,7 +508,7 @@ class SharedViewModel @Inject constructor( * Resolves a [TimeRangeFilter] into concrete start/end epoch-millisecond bounds. * Returns `null` for an open bound (no filter applied on that side). * - * Previously this logic was spread across [rememberResolvedTimeRangeState] in + * Previously this logic was spread across rememberResolvedTimeRangeState in * multiple Composables. Keeping it here makes it testable without Android instrumentation. */ private fun resolveTimeRange( @@ -541,7 +557,7 @@ class SharedViewModel @Inject constructor( * Used by drill-down screens that show the individual measurements within a period. * * The flow is cached per (startMillis, endMillis) pair so repeated calls from - * recompositions or from [resolveSelectedMeasurementIds] are free after the first access. + * recompositions or from resolveSelectedMeasurementIds are free after the first access. * * Each [AggregatedMeasurement] in the result has [AggregatedMeasurement.aggregatedFromCount] == 1. */ @@ -867,7 +883,7 @@ class SharedViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { try { - val types = withTimeoutOrNull(10_000) { + val types = withTimeoutOrNull(10.seconds) { measurementFacade.getAllMeasurementTypes().first { it.isNotEmpty() } } if (types.isNullOrEmpty()) { @@ -880,7 +896,7 @@ class SharedViewModel @Inject constructor( return@launch } - val users = withTimeoutOrNull(10_000) { + val users = withTimeoutOrNull(10.seconds) { allUsers.first { it.isNotEmpty() } } if (users.isNullOrEmpty()) { diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index f2c27fa9a..7a10ee1bb 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -378,7 +378,7 @@ Verstanden & Speichern Zur Projekt-Webseite - Initialisierungsprozess... + Initialisierungsprozess… Verbindung verloren Verbindung getrennt Gerät nicht gefunden. %s @@ -453,11 +453,10 @@ Typ löschen? Möchtest du den benutzerdefinierten Typ „%1$s“ wirklich löschen? Alle zugehörigen Messungen werden ebenfalls endgültig gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. %1$d Elemente erfolgreich gelöscht. - Fehler beim Löschen der Elemente. Nutzer für Zuweisung auswählen Keine anderen Nutzer vorhanden, zu denen gewechselt werden kann. Nutzer für %1$d Elemente erfolgreich geändert. - Fehler beim Ändern des Nutzers für einige Elemente. + Begrenzte Datenbasis Rekomposition diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 4aad66ad8..1fe8a5c50 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -1,6 +1,8 @@ openScale + Last change wasn\'t synchronized. Please open openScale-sync and run a manual sync. + Open App Logo None N/A diff --git a/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandlerTest.kt b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandlerTest.kt new file mode 100644 index 000000000..717c546ee --- /dev/null +++ b/android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandlerTest.kt @@ -0,0 +1,69 @@ +/* + * openScale + * Copyright (C) 2026 openScale contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.bluetooth.scales + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Unit tests for [TaylorBIAHandler.decodeWeightKg] — the weight decode reverse-engineered + * from the Taylor 5331891 BIA Scale's NOTIFY frames in btsnoop_hci.log. + * + * Frame layout (20 bytes): AC 27 ... 24 D5 + * weight_kg = (((chan & 0x01) << 16) | (hi << 8) | lo) / 1000.0 + * + * Ground truth: the user's recorded 12:49 pm weigh-in read 171.2 lb, and the matching stable + * frame in the capture was AC 27 80 8D 2F 52 … → 0x12F52 = 77 650 g = 77.650 kg = 171.2 lb. + */ +class TaylorBIAHandlerTest { + + private fun b(v: Int): Byte = v.toByte() + + @Test + fun `idle frame decodes to zero`() { + // AC 27 00 8C 00 00 … — nobody on the scale. + assertThat(TaylorBIAHandler.decodeWeightKg(b(0x8C), b(0x00), b(0x00))) + .isWithin(1e-6f).of(0.0f) + } + + @Test + fun `stable frame decodes to recorded 171_2 lb`() { + // AC 27 80 8D 2F 52 … — the locked reading; matches the user's 171.2 lb weigh-in. + val kg = TaylorBIAHandler.decodeWeightKg(b(0x8D), b(0x2F), b(0x52)) + assertThat(kg).isWithin(1e-4f).of(77.650f) + assertThat(kg * 2.20462f).isWithin(0.1f).of(171.2f) + } + + @Test + fun `stable frame decodes to recorded 173_0 lb`() { + // AC 27 80 8D 32 40 … — second capture; app showed 173.0 lb. + val kg = TaylorBIAHandler.decodeWeightKg(b(0x8D), b(0x32), b(0x40)) + assertThat(kg).isWithin(1e-4f).of(78.400f) + assertThat(kg * 2.20462f).isWithin(0.2f).of(173.0f) + } + + @Test + fun `overflow bit adds 65_536 grams`() { + // 0x8D sets bit 16: weight = 65536 + (hi<<8 | lo) grams. + assertThat(TaylorBIAHandler.decodeWeightKg(b(0x8D), b(0x00), b(0x00))) + .isWithin(1e-6f).of(65.536f) + // A sub-65.5 kg reading on the 0x8C channel uses no overflow bit. + assertThat(TaylorBIAHandler.decodeWeightKg(b(0x8C), b(0x9C), b(0x40))) + .isWithin(1e-4f).of(40.0f) // 0x9C40 = 40000 g + } +} diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt index 261d77dc5..a9d2a2ae2 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/BackupRestoreUseCasesTest.kt @@ -1,5 +1,6 @@ package com.health.openscale.core.usecase +import android.app.Application import android.content.Context import android.content.ContextWrapper import android.database.sqlite.SQLiteDatabase @@ -87,7 +88,8 @@ class BackupRestoreUseCasesTest { produceFile = { File(sandboxRoot, "settings.preferences_pb") } ) val settings = SettingsFacadeImpl(dataStore) - useCases = BackupRestoreUseCases(sandboxContext, repository, settings) + val sync = SyncUseCases(baseContext as Application, MeasurementTypeCrudUseCases(repository)) + useCases = BackupRestoreUseCases(sandboxContext, repository, settings, sync) repository.insertUser( User( diff --git a/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt b/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt index 342bf2d61..ad9c09c77 100644 --- a/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/core/usecase/ImportExportUseCasesTest.kt @@ -17,6 +17,7 @@ */ package com.health.openscale.core.usecase +import android.app.Application import android.content.Context import android.net.Uri import androidx.test.core.app.ApplicationProvider @@ -66,7 +67,8 @@ class ImportExportUseCasesTest { db = RoomTestSupport.inMemory(context) repo = RoomTestSupport.repositoryFor(db) repo.insertAllMeasurementTypes(getDefaultMeasurementTypes()) - useCases = ImportExportUseCases(repo) + val sync = SyncUseCases(context as Application, MeasurementTypeCrudUseCases(repo)) + useCases = ImportExportUseCases(repo, sync) userId = db.userDao().insert( User( diff --git a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt index a81738c88..76625436a 100644 --- a/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt +++ b/android_app/app/src/test/java/com/health/openscale/testutil/RoomTestSupport.kt @@ -211,8 +211,8 @@ object RoomTestSupport { val dataManagementFacade = DataManagementFacade( AutoBackupUseCases(app, settings), - BackupRestoreUseCases(app, repo, settings), - ImportExportUseCases(repo), + BackupRestoreUseCases(app, repo, settings, sync), + ImportExportUseCases(repo, sync), ) return Facades(userFacade, measurementFacade, dataManagementFacade) } diff --git a/android_app/app/src/test/java/com/health/openscale/ui/shared/SharedViewModelTest.kt b/android_app/app/src/test/java/com/health/openscale/ui/shared/SharedViewModelTest.kt index 9d975d532..f9dee13a1 100644 --- a/android_app/app/src/test/java/com/health/openscale/ui/shared/SharedViewModelTest.kt +++ b/android_app/app/src/test/java/com/health/openscale/ui/shared/SharedViewModelTest.kt @@ -28,6 +28,8 @@ import com.health.openscale.core.data.MeasurementValue import com.health.openscale.core.data.User import com.health.openscale.core.database.AppDatabase import com.health.openscale.core.database.DatabaseRepository +import com.health.openscale.core.usecase.MeasurementTypeCrudUseCases +import com.health.openscale.core.usecase.SyncUseCases import com.health.openscale.getDefaultMeasurementTypes import com.health.openscale.testutil.MainDispatcherRule import com.health.openscale.testutil.RoomTestSupport @@ -80,8 +82,9 @@ class SharedViewModelTest { scope, File(app.cacheDir, "shared-${System.nanoTime()}.preferences_pb") ) val facades = RoomTestSupport.facadesFor(app, repo, settings) + val sync = SyncUseCases(app, MeasurementTypeCrudUseCases(repo)) vm = SharedViewModel( - facades.userFacade, facades.measurementFacade, facades.dataManagementFacade, settings, + facades.userFacade, facades.measurementFacade, facades.dataManagementFacade, settings, sync, ) }