From 3a83bd626d1e986a96e78bfde09586203348948a Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 12 Jun 2026 10:26:08 +0200 Subject: [PATCH 1/7] Enhance sync protocol and support generic measurement types * Implement Phase 2 of the sync protocol using a self-describing JSON format (`VALUES_JSON`) to synchronize all measurement types, including custom ones. * Add a snackbar notification in `SharedViewModel` to prompt the user to open the sync app if it is installed but unreachable (e.g., force-stopped). * Introduce coalesced "changed" sync triggers for bulk import and backup restore operations to improve efficiency and prevent foreground service spam. * Update sync intents and `DatabaseProvider` to include `userId` and measurement IDs for better multi-user routing and bidirectional synchronization. * Implement measurement deletion support in `DatabaseProvider` via timestamp matching. * Bump sync API version to 2. --- .../core/database/DatabaseProvider.kt | 133 +++++----- .../core/usecase/BackupRestoreUseCases.kt | 5 +- .../core/usecase/ImportExportUseCases.kt | 6 +- .../core/usecase/MeasurementCrudUseCases.kt | 6 +- .../usecase/MeasurementTypeCrudUseCases.kt | 10 +- .../openscale/core/usecase/SyncUseCases.kt | 242 +++++++++++++----- .../openscale/core/usecase/UserUseCases.kt | 6 +- .../openscale/ui/shared/SharedViewModel.kt | 28 +- .../app/src/main/res/values-de/strings.xml | 6 +- .../app/src/main/res/values/strings.xml | 2 + 10 files changed, 298 insertions(+), 146 deletions(-) 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 01d260f7c..ea07ff3dc 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -1,5 +1,7 @@ + Letzte Änderung wurde nicht synchronisiert. Bitte öffne openScale-sync und führe einen manuellen Sync aus. + Öffnen App-Logo Keine N.V. @@ -392,7 +394,7 @@ Zur Projekt-Webseite - Initialisierungsprozess... + Initialisierungsprozess… Verbindung verloren Verbindung getrennt Gerät nicht gefunden. %s @@ -467,11 +469,9 @@ 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 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 From 1095a4820b6c94ee88d79c8766a26a60add7fc96 Mon Sep 17 00:00:00 2001 From: VulcanoHex Date: Fri, 12 Jun 2026 10:31:17 +0200 Subject: [PATCH 2/7] Added support for BF450 scale (#1396) I added the values for my Beurer BF450 scale in BeurerSanitasHandler.kt and it worked --- .../bluetooth/scales/StandardBeurerSanitasHandler.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 } From cbcccf28498e59abea2db7c1a13d95373bc66d5a Mon Sep 17 00:00:00 2001 From: chropic <37276310+chropic@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:32:40 -0600 Subject: [PATCH 3/7] feat: add support for Taylor 5331891 (#1393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add support for Taylor 5331891 BIA Scale Adds TaylorBIAHandler for the Taylor Body Composition Bathroom Scale (advertised as "5331891 BIA Scale"). The scale exposes service 0xFFB0 (FFB1 write, FFB2 notify), the same GATT layout as the MGB family, so MGBHandler previously mis-claimed it and the session timed out with no measurement because the notification format differs. Protocol (reverse-engineered from btsnoop HCI logs): - 20-byte NOTIFY frames "AC 27 ... 24 D5 ". - weight_kg = (((chan & 0x01) << 16) | (hi << 8) | lo) / 1000.0, where the channel byte's low bit is weight bit 16 (0x8C < 65.536 kg, 0x8D >= 65.536 kg). - flag 0x80 = stable, 0x01 = summary record. Verified against two captures (171.2 lb and 173.0 lb readings). The scale transmits weight only; body composition is computed app-side by the vendor and is not exposed over BLE, so only LIVE_WEIGHT_STREAM is implemented. TaylorBIAHandler is registered ahead of MGBHandler in ScaleFactory (first non-null supportFor wins). Includes unit tests for the weight decode. Co-Authored-By: Claude Opus 4.8 * feat: publish Taylor 5331891 reading via stability detection Under openScale's minimal init the scale never sends an explicit stable (0x80) or summary (0x01) frame; it streams live values that plateau. The previous code published only via a flat fallback timer, which could capture a still-settling value. Publish instead when the same weight repeats STABLE_FRAMES (4) times in a row (~1s at the scale's ~4Hz stream) — the settled value repeats verbatim while step-on ramp values are all distinct. Explicit stable/summary frames are still honored, and a last-resort timer (FALLBACK_DELAY_MS) covers weight that never settles. Verified on device: records 77.65 kg = 171.2 lb. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../openscale/core/bluetooth/ScaleFactory.kt | 5 + .../core/bluetooth/scales/TaylorBIAHandler.kt | 294 ++++++++++++++++++ .../bluetooth/scales/TaylorBIAHandlerTest.kt | 69 ++++ 3 files changed, 368 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandler.kt create mode 100644 android_app/app/src/test/java/com/health/openscale/core/bluetooth/scales/TaylorBIAHandlerTest.kt 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..be234a619 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 @@ -61,6 +61,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 +95,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(), 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/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 + } +} From 3711192453c3a10053efe69e4c5afeb3141abf63 Mon Sep 17 00:00:00 2001 From: LucasDumont Date: Fri, 12 Jun 2026 10:35:08 +0200 Subject: [PATCH 4/7] feat: Add support for 1BODY CONNECT (Transtek family) (#1392) --- .../openscale/core/bluetooth/ScaleFactory.kt | 2 + .../bluetooth/scales/BodyConnectHandler.kt | 300 ++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/BodyConnectHandler.kt 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 be234a619..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 @@ -142,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()) + } +} From 752cfe6acf8acf0217d629fcd43a94c211888df9 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 12 Jun 2026 11:15:11 +0200 Subject: [PATCH 5/7] Refine device support logic in InlifeHandler * Simplify `supportFor` by removing the service UUID check and relying solely on device name matching. --- .../health/openscale/core/bluetooth/scales/InlifeHandler.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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, From fd1b5d7bb8ccae3f37a21d9531e2cd19bee1cad8 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 12 Jun 2026 11:24:31 +0200 Subject: [PATCH 6/7] Fixed test cases --- .../openscale/core/usecase/BackupRestoreUseCasesTest.kt | 4 +++- .../openscale/core/usecase/ImportExportUseCasesTest.kt | 4 +++- .../java/com/health/openscale/testutil/RoomTestSupport.kt | 4 ++-- .../com/health/openscale/ui/shared/SharedViewModelTest.kt | 5 ++++- 4 files changed, 12 insertions(+), 5 deletions(-) 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, ) } From 43adcda214c9f7d66dac7f63e90919ea12f2769f Mon Sep 17 00:00:00 2001 From: oliexdev Date: Fri, 12 Jun 2026 12:03:51 +0200 Subject: [PATCH 7/7] fixed DEU xml strings --- android_app/app/src/main/res/values-de/strings.xml | 2 -- 1 file changed, 2 deletions(-) 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 ea07ff3dc..c4402c892 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -1,7 +1,5 @@ - Letzte Änderung wurde nicht synchronisiert. Bitte öffne openScale-sync und führe einen manuellen Sync aus. - Öffnen App-Logo Keine N.V.