1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-30 11:40:23 +02:00

Complete rewrite of the entire openScale application from Java to Kotlin. This is the initial commit for the Kotlin version on this branch, aiming for improved code quality, conciseness, and modern Android development practices.

This commit is contained in:
oliexdev
2025-08-02 15:31:47 +02:00
parent 85d6de56da
commit e535e5e4b7
402 changed files with 16742 additions and 46418 deletions

View File

@@ -1,157 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: "androidx.navigation.safeargs"
android {
compileSdk 34
defaultConfig {
applicationId "com.health.openscale"
testApplicationId "com.health.openscale.test"
minSdkVersion 23
targetSdkVersion 34
versionCode 66
versionName "2.5.4"
manifestPlaceholders = [
appIcon: "@drawable/ic_launcher_openscale",
appIconRound: "@mipmap/ic_launcher_openscale_round"
]
javaCompileOptions {
annotationProcessorOptions {
arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
}
}
buildFeatures {
buildConfig = true
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
signingConfigs {
release {
def keystorePropertiesFile = rootProject.file("../../openScale.keystore")
def keystoreProperties = new Properties()
try {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
} catch (FileNotFoundException e) {
keystoreProperties = null;
}
if (keystoreProperties != null) {
storeFile file(rootDir.getCanonicalPath() + '/' + keystoreProperties['releaseKeyStore'])
keyAlias keystoreProperties['releaseKeyAlias']
keyPassword keystoreProperties['releaseKeyPassword']
storePassword keystoreProperties['releaseStorePassword']
}
}
oss {
def keystoreOSSPropertiesFile = rootProject.file("../../openScale_oss.keystore")
def keystoreOSSProperties = new Properties()
try {
keystoreOSSProperties.load(new FileInputStream(keystoreOSSPropertiesFile))
}
catch (FileNotFoundException e) {
keystoreOSSProperties = null;
}
if (keystoreOSSProperties != null) {
storeFile file(rootDir.getCanonicalPath() + '/' + keystoreOSSProperties['releaseKeyStore'])
keyAlias keystoreOSSProperties['releaseKeyAlias']
keyPassword keystoreOSSProperties['releaseKeyPassword']
storePassword keystoreOSSProperties['releaseStorePassword']
}
}
}
buildTypes {
debug {
// don't include version number into the apk filename for debug build type so Travis can find it
applicationVariants.all { variant ->
variant.outputs.all { output ->
if (variant.buildType.name == "debug") {
outputFileName = "openScale-debug.apk"
}
}
}
}
release {
archivesBaseName = "openScale-"+defaultConfig.versionName
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
signingConfig signingConfigs.release
}
oss {
archivesBaseName = "openScale-"+defaultConfig.versionName
applicationIdSuffix ".oss"
versionNameSuffix "-oss"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
signingConfig signingConfigs.oss
}
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
namespace 'com.health.openscale'
lint {
abortOnError false
}
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.3'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.navigation:navigation-fragment:2.8.4'
implementation 'androidx.navigation:navigation-ui:2.8.4'
implementation "android.arch.lifecycle:extensions:1.1.1"
annotationProcessor "androidx.lifecycle:lifecycle-common-java8:2.8.7"
// MPAndroidChart
implementation 'com.github.PhilJay:MPAndroidChart:v3.1.0'
// Simple CSV
implementation 'com.j256.simplecsv:simplecsv:2.6'
// Blessed Android
implementation 'com.github.weliem:blessed-android:2.5.0'
// CustomActivityOnCrash
implementation 'cat.ereza:customactivityoncrash:2.3.0'
// AppIntro
implementation 'com.github.AppIntro:AppIntro:6.2.0'
// implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.8.20'
// Room
implementation 'androidx.room:room-runtime:2.6.1'
annotationProcessor 'androidx.room:room-compiler:2.6.1'
androidTestImplementation 'androidx.room:room-testing:2.6.1'
// Timber
implementation 'com.jakewharton.timber:timber:5.0.1'
// Local unit tests
testImplementation 'junit:junit:4.13.2'
// Instrumented unit tests
implementation 'androidx.annotation:annotation:1.9.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:rules:1.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.6.1'
}
tasks.withType(Test) {
testLogging {
exceptionFormat "full"
events "started", "skipped", "passed", "failed"
showStandardStreams true
}
}

View File

@@ -0,0 +1,93 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("kotlin-kapt")
}
android {
namespace = "com.health.openscale"
compileSdk = 36
defaultConfig {
applicationId = "com.health.openscale"
minSdk = 31
targetSdk = 36
versionCode = 67
versionName = "3.0 beta"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.navigation.compose)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
// Room
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
kapt(libs.androidx.room.compiler)
implementation(libs.datastore.preferences)
// ViewModel
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// Vico charts
implementation(libs.compose.charts)
implementation(libs.compose.charts.m3)
// Compose reorderable
implementation(libs.compose.reorderable)
implementation(libs.compose.material.icons.extended)
// Kotlin-CSV
implementation(libs.kotlin.csv.jvm)
// Blessed Kotlin
// implementation(libs.blessed.kotlin)
implementation(libs.blessed.java)
}

21
android_app/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -1,182 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "f7147b87965bad6c8417519fa7d0f7d2",
"entities": [
{
"tableName": "scaleMeasurements",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `lbw` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `comment` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateTime",
"columnName": "datetime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "fat",
"columnName": "fat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "water",
"columnName": "water",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "muscle",
"columnName": "muscle",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lbw",
"columnName": "lbw",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "waist",
"columnName": "waist",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "hip",
"columnName": "hip",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "bone",
"columnName": "bone",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_scaleMeasurements_datetime",
"unique": true,
"columnNames": [
"datetime"
],
"createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_datetime` ON `${TABLE_NAME}` (`datetime`)"
}
],
"foreignKeys": []
},
{
"tableName": "scaleUsers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT, `birthday` INTEGER, `bodyHeight` INTEGER NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userName",
"columnName": "username",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bodyHeight",
"columnName": "bodyHeight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scaleUnit",
"columnName": "scaleUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gender",
"columnName": "gender",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "initialWeight",
"columnName": "initialWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalWeight",
"columnName": "goalWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalDate",
"columnName": "goalDate",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"f7147b87965bad6c8417519fa7d0f7d2\")"
]
}
}

View File

@@ -1,195 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "29790d4babbe129963d2c9282393c2d2",
"entities": [
{
"tableName": "scaleMeasurements",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `lbw` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateTime",
"columnName": "datetime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "fat",
"columnName": "fat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "water",
"columnName": "water",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "muscle",
"columnName": "muscle",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lbm",
"columnName": "lbw",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "waist",
"columnName": "waist",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "hip",
"columnName": "hip",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "bone",
"columnName": "bone",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_scaleMeasurements_userId_datetime",
"unique": true,
"columnNames": [
"userId",
"datetime"
],
"createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)"
}
],
"foreignKeys": [
{
"table": "scaleUsers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "scaleUsers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT, `birthday` INTEGER, `bodyHeight` INTEGER NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userName",
"columnName": "username",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "bodyHeight",
"columnName": "bodyHeight",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "scaleUnit",
"columnName": "scaleUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gender",
"columnName": "gender",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "initialWeight",
"columnName": "initialWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalWeight",
"columnName": "goalWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalDate",
"columnName": "goalDate",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"29790d4babbe129963d2c9282393c2d2\")"
]
}
}

View File

@@ -1,255 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "974ad0a810bf389300cf67b40862bb75",
"entities": [
{
"tableName": "scaleMeasurements",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `visceralFat` REAL NOT NULL, `lbm` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `chest` REAL NOT NULL, `thigh` REAL NOT NULL, `biceps` REAL NOT NULL, `neck` REAL NOT NULL, `caliper1` REAL NOT NULL, `caliper2` REAL NOT NULL, `caliper3` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateTime",
"columnName": "datetime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "fat",
"columnName": "fat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "water",
"columnName": "water",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "muscle",
"columnName": "muscle",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "visceralFat",
"columnName": "visceralFat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lbm",
"columnName": "lbm",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "waist",
"columnName": "waist",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "hip",
"columnName": "hip",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "bone",
"columnName": "bone",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "chest",
"columnName": "chest",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "thigh",
"columnName": "thigh",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "biceps",
"columnName": "biceps",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "neck",
"columnName": "neck",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper1",
"columnName": "caliper1",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper2",
"columnName": "caliper2",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper3",
"columnName": "caliper3",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_scaleMeasurements_userId_datetime",
"unique": true,
"columnNames": [
"userId",
"datetime"
],
"createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)"
}
],
"foreignKeys": [
{
"table": "scaleUsers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "scaleUsers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `birthday` INTEGER NOT NULL, `bodyHeight` REAL NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER, `measureUnit` INTEGER NOT NULL, `activityLevel` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userName",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyHeight",
"columnName": "bodyHeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "scaleUnit",
"columnName": "scaleUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gender",
"columnName": "gender",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "initialWeight",
"columnName": "initialWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalWeight",
"columnName": "goalWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalDate",
"columnName": "goalDate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "measureUnit",
"columnName": "measureUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activityLevel",
"columnName": "activityLevel",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"974ad0a810bf389300cf67b40862bb75\")"
]
}
}

View File

@@ -1,262 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "2db259b9e244ebad0c664f2c9fb36068",
"entities": [
{
"tableName": "scaleMeasurements",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `visceralFat` REAL NOT NULL, `lbm` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `chest` REAL NOT NULL, `thigh` REAL NOT NULL, `biceps` REAL NOT NULL, `neck` REAL NOT NULL, `caliper1` REAL NOT NULL, `caliper2` REAL NOT NULL, `caliper3` REAL NOT NULL, `calories` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateTime",
"columnName": "datetime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "fat",
"columnName": "fat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "water",
"columnName": "water",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "muscle",
"columnName": "muscle",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "visceralFat",
"columnName": "visceralFat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lbm",
"columnName": "lbm",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "waist",
"columnName": "waist",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "hip",
"columnName": "hip",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "bone",
"columnName": "bone",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "chest",
"columnName": "chest",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "thigh",
"columnName": "thigh",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "biceps",
"columnName": "biceps",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "neck",
"columnName": "neck",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper1",
"columnName": "caliper1",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper2",
"columnName": "caliper2",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper3",
"columnName": "caliper3",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "calories",
"columnName": "calories",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_scaleMeasurements_userId_datetime",
"unique": true,
"columnNames": [
"userId",
"datetime"
],
"createSql": "CREATE UNIQUE INDEX `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)"
}
],
"foreignKeys": [
{
"table": "scaleUsers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "scaleUsers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `birthday` INTEGER NOT NULL, `bodyHeight` REAL NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER, `measureUnit` INTEGER NOT NULL, `activityLevel` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userName",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyHeight",
"columnName": "bodyHeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "scaleUnit",
"columnName": "scaleUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gender",
"columnName": "gender",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "initialWeight",
"columnName": "initialWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalWeight",
"columnName": "goalWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalDate",
"columnName": "goalDate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "measureUnit",
"columnName": "measureUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activityLevel",
"columnName": "activityLevel",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"2db259b9e244ebad0c664f2c9fb36068\")"
]
}
}

View File

@@ -1,280 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "d66fc1fc2752b2d6f41700fa2102492a",
"entities": [
{
"tableName": "scaleMeasurements",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `visceralFat` REAL NOT NULL, `lbm` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `chest` REAL NOT NULL, `thigh` REAL NOT NULL, `biceps` REAL NOT NULL, `neck` REAL NOT NULL, `caliper1` REAL NOT NULL, `caliper2` REAL NOT NULL, `caliper3` REAL NOT NULL, `calories` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateTime",
"columnName": "datetime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "fat",
"columnName": "fat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "water",
"columnName": "water",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "muscle",
"columnName": "muscle",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "visceralFat",
"columnName": "visceralFat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lbm",
"columnName": "lbm",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "waist",
"columnName": "waist",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "hip",
"columnName": "hip",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "bone",
"columnName": "bone",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "chest",
"columnName": "chest",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "thigh",
"columnName": "thigh",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "biceps",
"columnName": "biceps",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "neck",
"columnName": "neck",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper1",
"columnName": "caliper1",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper2",
"columnName": "caliper2",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper3",
"columnName": "caliper3",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "calories",
"columnName": "calories",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_scaleMeasurements_userId_datetime",
"unique": true,
"columnNames": [
"userId",
"datetime"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)"
}
],
"foreignKeys": [
{
"table": "scaleUsers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "scaleUsers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `birthday` INTEGER NOT NULL, `bodyHeight` REAL NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER, `measureUnit` INTEGER NOT NULL, `activityLevel` INTEGER NOT NULL, `assistedWeighing` INTEGER NOT NULL, `leftAmputationLevel` INTEGER NOT NULL, `rightAmputationLevel` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userName",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyHeight",
"columnName": "bodyHeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "scaleUnit",
"columnName": "scaleUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gender",
"columnName": "gender",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "initialWeight",
"columnName": "initialWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalWeight",
"columnName": "goalWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalDate",
"columnName": "goalDate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "measureUnit",
"columnName": "measureUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activityLevel",
"columnName": "activityLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "assistedWeighing",
"columnName": "assistedWeighing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "leftAmputationLevel",
"columnName": "leftAmputationLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rightAmputationLevel",
"columnName": "rightAmputationLevel",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd66fc1fc2752b2d6f41700fa2102492a')"
]
}
}

View File

@@ -1,287 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "363295f46fda89cfa9f94179971dc240",
"entities": [
{
"tableName": "scaleMeasurements",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `datetime` INTEGER, `weight` REAL NOT NULL, `fat` REAL NOT NULL, `water` REAL NOT NULL, `muscle` REAL NOT NULL, `visceralFat` REAL NOT NULL, `lbm` REAL NOT NULL, `waist` REAL NOT NULL, `hip` REAL NOT NULL, `bone` REAL NOT NULL, `chest` REAL NOT NULL, `thigh` REAL NOT NULL, `biceps` REAL NOT NULL, `neck` REAL NOT NULL, `caliper1` REAL NOT NULL, `caliper2` REAL NOT NULL, `caliper3` REAL NOT NULL, `calories` REAL NOT NULL, `comment` TEXT, FOREIGN KEY(`userId`) REFERENCES `scaleUsers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "userId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "enabled",
"columnName": "enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "dateTime",
"columnName": "datetime",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "weight",
"columnName": "weight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "fat",
"columnName": "fat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "water",
"columnName": "water",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "muscle",
"columnName": "muscle",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "visceralFat",
"columnName": "visceralFat",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "lbm",
"columnName": "lbm",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "waist",
"columnName": "waist",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "hip",
"columnName": "hip",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "bone",
"columnName": "bone",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "chest",
"columnName": "chest",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "thigh",
"columnName": "thigh",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "biceps",
"columnName": "biceps",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "neck",
"columnName": "neck",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper1",
"columnName": "caliper1",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper2",
"columnName": "caliper2",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "caliper3",
"columnName": "caliper3",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "calories",
"columnName": "calories",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "comment",
"columnName": "comment",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_scaleMeasurements_userId_datetime",
"unique": true,
"columnNames": [
"userId",
"datetime"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_scaleMeasurements_userId_datetime` ON `${TABLE_NAME}` (`userId`, `datetime`)"
}
],
"foreignKeys": [
{
"table": "scaleUsers",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"userId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "scaleUsers",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT NOT NULL, `birthday` INTEGER NOT NULL, `bodyHeight` REAL NOT NULL, `scaleUnit` INTEGER NOT NULL, `gender` INTEGER NOT NULL, `goalEnabled` INTEGER NOT NULL, `initialWeight` REAL NOT NULL, `goalWeight` REAL NOT NULL, `goalDate` INTEGER, `measureUnit` INTEGER NOT NULL, `activityLevel` INTEGER NOT NULL, `assistedWeighing` INTEGER NOT NULL, `leftAmputationLevel` INTEGER NOT NULL, `rightAmputationLevel` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userName",
"columnName": "username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "birthday",
"columnName": "birthday",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "bodyHeight",
"columnName": "bodyHeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "scaleUnit",
"columnName": "scaleUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "gender",
"columnName": "gender",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "goalEnabled",
"columnName": "goalEnabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "initialWeight",
"columnName": "initialWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalWeight",
"columnName": "goalWeight",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "goalDate",
"columnName": "goalDate",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "measureUnit",
"columnName": "measureUnit",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "activityLevel",
"columnName": "activityLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "assistedWeighing",
"columnName": "assistedWeighing",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "leftAmputationLevel",
"columnName": "leftAmputationLevel",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "rightAmputationLevel",
"columnName": "rightAmputationLevel",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '363295f46fda89cfa9f94179971dc240')"
]
}
}

View File

@@ -1,264 +0,0 @@
/* Copyright (C) 2018 Erik Johansson <erik@ejohansson.se>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import com.health.openscale.core.database.AppDatabase;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.room.testing.MigrationTestHelper;
import androidx.sqlite.db.SupportSQLiteDatabase;
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import static junit.framework.Assert.assertEquals;
import static junit.framework.Assert.assertNotSame;
import static junit.framework.Assert.assertTrue;
@RunWith(AndroidJUnit4.class)
public class DatabaseMigrationTest {
private static final String TEST_DB = "migration-test";
@Rule
public MigrationTestHelper helper;
public DatabaseMigrationTest() {
helper = new MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase.class.getCanonicalName(),
new FrameworkSQLiteOpenHelperFactory());
}
@Test
public void migrate1To2() throws Exception {
SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
ContentValues users = new ContentValues();
for (int i = 1; i < 4; ++i) {
users.put("id", i);
users.put("username", String.format("test%d", i));
users.put("bodyHeight", i * 50);
users.put("scaleUnit", 0);
users.put("gender", 0);
users.put("initialWeight", i * 25);
users.put("goalWeight", i * 20);
assertNotSame(-1, db.insert("scaleUsers", SQLiteDatabase.CONFLICT_ABORT, users));
}
ContentValues measurement = new ContentValues();
for (int i = 2; i < 4; ++i) {
for (int j = 0; j < 2; ++j) {
measurement.put("userId", i);
measurement.put("enabled", j);
measurement.put("comment", "a string");
for (String type : new String[]{"weight", "fat", "water", "muscle", "lbw", "waist", "hip", "bone"}) {
measurement.put(type, i * j + type.hashCode());
}
assertNotSame(-1, db.insert("scaleMeasurements", SQLiteDatabase.CONFLICT_ABORT, measurement));
}
}
// Prepare for the next version.
db.close();
// Re-open the database with version 2 and provide MIGRATION_1_2 as the migration process.
db = helper.runMigrationsAndValidate(TEST_DB, 2, true, AppDatabase.MIGRATION_1_2);
// MigrationTestHelper automatically verifies the schema changes.
Cursor cursor = db.query("SELECT * FROM scaleMeasurements ORDER BY id, userId");
assertEquals(2 * 2, cursor.getCount());
cursor.moveToFirst();
for (int i = 2; i < 4; ++i) {
for (int j = 0; j < 2; ++j) {
assertEquals(i, cursor.getInt(cursor.getColumnIndex("userId")));
assertEquals(j, cursor.getInt(cursor.getColumnIndex("enabled")));
assertEquals("a string", cursor.getString(cursor.getColumnIndex("comment")));
for (String type : new String[]{"weight", "fat", "water", "muscle", "lbw", "waist", "hip", "bone"}) {
assertEquals((float) i * j + type.hashCode(),
cursor.getFloat(cursor.getColumnIndex(type)));
}
cursor.moveToNext();
}
}
assertTrue(cursor.isAfterLast());
}
@Test
public void migrate2To3() throws Exception {
SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 2);
ContentValues users = new ContentValues();
for (int i = 1; i < 4; ++i) {
users.put("id", i);
users.put("username", String.format("test%d", i));
users.put("birthday", i*100);
users.put("bodyHeight", i * 50);
users.put("scaleUnit", 0);
users.put("gender", 0);
users.put("initialWeight", i * 25);
users.put("goalWeight", i * 20);
assertNotSame(-1, db.insert("scaleUsers", SQLiteDatabase.CONFLICT_ABORT, users));
}
ContentValues measurement = new ContentValues();
for (int i = 2; i < 4; ++i) {
for (int j = 0; j < 2; ++j) {
measurement.put("userId", i);
measurement.put("enabled", j);
measurement.put("comment", "a string");
for (String type : new String[]{"weight", "fat", "water", "muscle", "lbw", "waist", "hip", "bone"}) {
measurement.put(type, i * j + type.hashCode());
}
assertNotSame(-1, db.insert("scaleMeasurements", SQLiteDatabase.CONFLICT_ABORT, measurement));
}
}
// Prepare for the next version.
db.close();
// Re-open the database with version 3 and provide MIGRATION_2_3 as the migration process.
db = helper.runMigrationsAndValidate(TEST_DB, 3, true, AppDatabase.MIGRATION_2_3);
// MigrationTestHelper automatically verifies the schema changes.
assertEquals(3, db.query("SELECT * FROM scaleUsers WHERE measureUnit = 0").getCount());
assertEquals(3, db.query("SELECT * FROM scaleUsers WHERE activityLevel = 0").getCount());
Cursor cursor = db.query("SELECT * FROM scaleUsers ORDER BY id");
cursor.moveToFirst();
for (int i = 1; i < 4; ++i) {
assertEquals(i, cursor.getInt(cursor.getColumnIndex("id")));
assertEquals(i*100, cursor.getInt(cursor.getColumnIndex("birthday")));
assertEquals(i*50, cursor.getInt(cursor.getColumnIndex("bodyHeight")));
assertEquals(i*25, cursor.getInt(cursor.getColumnIndex("initialWeight")));
assertEquals(i*20, cursor.getInt(cursor.getColumnIndex("goalWeight")));
cursor.moveToNext();
}
cursor = db.query("SELECT * FROM scaleMeasurements ORDER BY id, userId");
assertEquals(2 * 2, cursor.getCount());
cursor.moveToFirst();
for (int i = 2; i < 4; ++i) {
for (int j = 0; j < 2; ++j) {
assertEquals(i, cursor.getInt(cursor.getColumnIndex("userId")));
assertEquals(j, cursor.getInt(cursor.getColumnIndex("enabled")));
assertEquals("a string", cursor.getString(cursor.getColumnIndex("comment")));
for (String type : new String[]{"weight", "fat", "water", "muscle", "lbm", "waist", "hip", "bone"}) {
float value = i * j;
if (type.equals("lbm")) {
value += "lbw".hashCode();
}
else {
value += type.hashCode();
}
assertEquals(value, cursor.getFloat(cursor.getColumnIndex(type)));
}
for (String type : new String[]{"visceralFat", "chest", "thigh", "biceps", "neck",
"caliper1", "caliper2", "caliper3"}) {
assertEquals(0.0f, cursor.getFloat(cursor.getColumnIndex(type)));
}
cursor.moveToNext();
}
}
assertTrue(cursor.isAfterLast());
}
@Test
public void migrate3To4() throws Exception {
SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 3);
ContentValues users = new ContentValues();
for (int i = 1; i < 4; ++i) {
users.put("id", i);
users.put("username", String.format("test%d", i));
users.put("birthday", i*100);
users.put("bodyHeight", i * 50);
users.put("scaleUnit", 0);
users.put("gender", 0);
users.put("initialWeight", i * 25);
users.put("goalWeight", i * 20);
users.put("measureUnit", 0);
users.put("activityLevel", 0);
assertNotSame(-1, db.insert("scaleUsers", SQLiteDatabase.CONFLICT_ABORT, users));
}
ContentValues measurement = new ContentValues();
for (int i = 2; i < 4; ++i) {
for (int j = 0; j < 2; ++j) {
measurement.put("userId", i);
measurement.put("enabled", j);
measurement.put("comment", "a string");
for (String type : new String[]{"weight", "fat", "water", "muscle", "lbm", "waist", "hip", "bone",
"visceralFat", "chest", "thigh", "biceps", "neck", "caliper1", "caliper2", "caliper3"}) {
measurement.put(type, (float) i * j + type.hashCode());
}
assertNotSame(-1, db.insert("scaleMeasurements", SQLiteDatabase.CONFLICT_ABORT, measurement));
}
}
// Prepare for the next version.
db.close();
// Re-open the database with version 4 and provide MIGRATION_3_4 as the migration process.
db = helper.runMigrationsAndValidate(TEST_DB, 4, true, AppDatabase.MIGRATION_3_4);
// MigrationTestHelper automatically verifies the schema changes.
Cursor cursor = db.query("SELECT * FROM scaleMeasurements ORDER BY id, userId");
assertEquals(2 * 2, cursor.getCount());
cursor.moveToFirst();
for (int i = 2; i < 4; ++i) {
for (int j = 0; j < 2; ++j) {
assertEquals(i, cursor.getInt(cursor.getColumnIndex("userId")));
assertEquals(j, cursor.getInt(cursor.getColumnIndex("enabled")));
assertEquals("a string", cursor.getString(cursor.getColumnIndex("comment")));
for (String type : new String[]{"weight", "fat", "water", "muscle", "lbm", "waist", "hip", "bone",
"visceralFat", "chest", "thigh", "biceps", "neck", "caliper1", "caliper2", "caliper3"}) {
assertEquals((float) i * j + type.hashCode(),
cursor.getFloat(cursor.getColumnIndex(type)));
}
for (String type : new String[]{"calories"}) {
assertEquals(0.0f, cursor.getFloat(cursor.getColumnIndex(type)));
}
cursor.moveToNext();
}
}
assertTrue(cursor.isAfterLast());
}
}

View File

@@ -1,243 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale;
import android.content.Context;
import com.health.openscale.core.database.AppDatabase;
import com.health.openscale.core.database.ScaleMeasurementDAO;
import com.health.openscale.core.database.ScaleUserDAO;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import androidx.room.Room;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
// run this test as an Android instrumented test!
@RunWith(AndroidJUnit4.class)
public class DatabaseTest {
private static final double DELTA = 1e-15;
private AppDatabase appDB;
private ScaleUserDAO userDao;
private ScaleMeasurementDAO measurementDAO;
@Before
public void initDatabase() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
appDB = Room.inMemoryDatabaseBuilder(context, AppDatabase.class).build();
userDao = appDB.userDAO();
measurementDAO = appDB.measurementDAO();
}
@After
public void closeDatabase() throws IOException {
appDB.close();
}
@Test
public void userOperations() throws Exception {
ScaleUser user1 = new ScaleUser();
ScaleUser user2 = new ScaleUser();
user1.setUserName("foo");
user2.setUserName("bar");
// is user database empty on initialization
assertTrue(userDao.getAll().isEmpty());
userDao.insert(user1);
// was the user successfully inserted
assertEquals(1, userDao.getAll().size());
assertEquals("foo", userDao.getAll().get(0).getUserName());
userDao.insert(user2);
assertEquals(2, userDao.getAll().size());
assertEquals("foo", userDao.getAll().get(0).getUserName());
assertEquals("bar", userDao.getAll().get(1).getUserName());
// check if get(id) works
List<ScaleUser> scaleUserList = userDao.getAll();
ScaleUser firstUser = scaleUserList.get(0);
ScaleUser secondUser = scaleUserList.get(1);
assertEquals(firstUser.getUserName(), userDao.get(firstUser.getId()).getUserName());
// check delete method
userDao.delete(firstUser);
assertEquals(1, userDao.getAll().size());
assertEquals(secondUser.getUserName(), userDao.getAll().get(0).getUserName());
// check update method
secondUser.setUserName("foobar");
userDao.update(secondUser);
assertEquals("foobar", userDao.get(secondUser.getId()).getUserName());
// clear database
userDao.delete(secondUser);
assertTrue(userDao.getAll().isEmpty());
// check insert user list
ScaleUser user3 = new ScaleUser();
user3.setUserName("bob");
List<ScaleUser> myScaleUserList = new ArrayList<>();
myScaleUserList.add(user1);
myScaleUserList.add(user2);
myScaleUserList.add(user3);
userDao.insertAll(myScaleUserList);
assertEquals(3, userDao.getAll().size());
}
@Test
public void measurementOperations() throws Exception {
final ScaleUser scaleUser1 = new ScaleUser();
final ScaleUser scaleUser2 = new ScaleUser();
scaleUser1.setId((int)userDao.insert(scaleUser1));
scaleUser2.setId((int)userDao.insert(scaleUser2));
// User 1 data initialization
final int user1 = scaleUser1.getId();
ScaleMeasurement measurement11 = new ScaleMeasurement();
ScaleMeasurement measurement12 = new ScaleMeasurement();
ScaleMeasurement measurement13 = new ScaleMeasurement();
measurement11.setUserId(user1);
measurement12.setUserId(user1);
measurement13.setUserId(user1);
measurement11.setWeight(10.0f);
measurement12.setWeight(20.0f);
measurement13.setWeight(30.0f);
measurement11.setDateTime(new Date(100));
measurement12.setDateTime(new Date(200));
measurement13.setDateTime(new Date(300));
// User 2 data initialization
final int user2 = scaleUser2.getId();
ScaleMeasurement measurement21 = new ScaleMeasurement();
ScaleMeasurement measurement22 = new ScaleMeasurement();
measurement21.setUserId(user2);
measurement22.setUserId(user2);
measurement21.setWeight(15.0f);
measurement22.setWeight(25.0f);
measurement21.setDateTime(new Date(150));
measurement22.setDateTime(new Date(250));
// check if database is empty
assertTrue(measurementDAO.getAll(user1).isEmpty());
assertTrue(measurementDAO.getAll(user2).isEmpty());
// insert measurement as list and single insertion
List<ScaleMeasurement> scaleMeasurementList = new ArrayList<>();
scaleMeasurementList.add(measurement11);
scaleMeasurementList.add(measurement13);
scaleMeasurementList.add(measurement12);
measurementDAO.insertAll(scaleMeasurementList);
assertEquals(3, measurementDAO.getAll(user1).size());
measurementDAO.insert(measurement22);
measurementDAO.insert(measurement21);
assertEquals(2, measurementDAO.getAll(user2).size());
// check if sorted DESC by date correctly
assertEquals(30.0f, measurementDAO.getAll(user1).get(0).getWeight(), DELTA);
assertEquals(25.0f, measurementDAO.getAll(user2).get(0).getWeight(), DELTA);
// don't allow insertion with the same date
long id = measurementDAO.insert(measurement11);
assertEquals(-1 , id);
assertEquals(3, measurementDAO.getAll(user1).size());
// test get(datetime) method
assertEquals(20.0f, measurementDAO.get(new Date(200), user1).getWeight(), DELTA);
// test get(id) method
scaleMeasurementList = measurementDAO.getAll(user1);
assertEquals(scaleMeasurementList.get(2).getWeight(), measurementDAO.get(scaleMeasurementList.get(2).getId()).getWeight(), DELTA);
// test getPrevious(id) method
assertNull(measurementDAO.getPrevious(scaleMeasurementList.get(2).getId(), user1));
assertEquals(scaleMeasurementList.get(2).getWeight(), measurementDAO.getPrevious(scaleMeasurementList.get(1).getId(), user1).getWeight(), DELTA);
assertEquals(scaleMeasurementList.get(1).getWeight(), measurementDAO.getPrevious(scaleMeasurementList.get(0).getId(), user1).getWeight(), DELTA);
// test getNext(id) method
assertNull(measurementDAO.getNext(scaleMeasurementList.get(0).getId(), user1));
assertEquals(scaleMeasurementList.get(0).getWeight(), measurementDAO.getNext(scaleMeasurementList.get(1).getId(), user1).getWeight(), DELTA);
assertEquals(scaleMeasurementList.get(1).getWeight(), measurementDAO.getNext(scaleMeasurementList.get(2).getId(), user1).getWeight(), DELTA);
// test getAllInRange method
assertEquals(1, measurementDAO.getAllInRange(new Date(0), new Date(200), user1).size());
assertEquals(0, measurementDAO.getAllInRange(new Date(0), new Date(50), user1).size());
assertEquals(2, measurementDAO.getAllInRange(new Date(100), new Date(201), user1).size());
assertEquals(1, measurementDAO.getAllInRange(new Date(0), new Date(200), user1).size());
assertEquals(3, measurementDAO.getAllInRange(new Date(0), new Date(1000), user1).size());
assertEquals(2, measurementDAO.getAllInRange(new Date(150), new Date(400), user1).size());
assertEquals(0, measurementDAO.getAllInRange(new Date(10), new Date(20), user2).size());
assertEquals(1, measurementDAO.getAllInRange(new Date(70), new Date(200), user2).size());
assertEquals(2, measurementDAO.getAllInRange(new Date(0), new Date(1000), user2).size());
// test update method
assertEquals(30.0f, measurementDAO.get(scaleMeasurementList.get(0).getId()).getWeight(), DELTA);
scaleMeasurementList.get(0).setWeight(42.0f);
measurementDAO.update(scaleMeasurementList.get(0));
assertEquals(42.0f, measurementDAO.get(scaleMeasurementList.get(0).getId()).getWeight(), DELTA);
// test delete method
assertEquals(3, measurementDAO.getAll(user1).size());
measurementDAO.delete(scaleMeasurementList.get(0).getId());
assertEquals(2, measurementDAO.getAll(user1).size());
// test delete all method
assertEquals(2, measurementDAO.getAll(user1).size());
assertEquals(2, measurementDAO.getAll(user2).size());
measurementDAO.deleteAll(user1);
measurementDAO.deleteAll(user2);
assertEquals(0, measurementDAO.getAll(user1).size());
assertEquals(0, measurementDAO.getAll(user2).size());
assertTrue(measurementDAO.getAll(user1).isEmpty());
assertTrue(measurementDAO.getAll(user2).isEmpty());
}
}

View File

@@ -1,180 +0,0 @@
package com.health.openscale;
import com.health.openscale.core.bluetooth.BluetoothTrisaBodyAnalyze;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import com.health.openscale.gui.MainActivity;
import junit.framework.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import static junit.framework.Assert.assertEquals;
/** Unit tests for {@link com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib}.*/
@RunWith(AndroidJUnit4.class)
public class TrisaBodyAnalyzeLibTest {
@Rule
public ActivityTestRule<MainActivity> mActivityTestRule = new ActivityTestRule<>(MainActivity.class, false, false);
public BluetoothTrisaBodyAnalyze trisaBodyAnalyze;
@Before
public void initTest() {
try {
mActivityTestRule.runOnUiThread(new Runnable() {
public void run() {
trisaBodyAnalyze =new BluetoothTrisaBodyAnalyze(InstrumentationRegistry.getInstrumentation().getTargetContext());
}
});
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
@Test
public void getBase10FloatTests() {
double eps = 1e-9; // margin of error for inexact floating point comparisons
assertEquals(0.0f, trisaBodyAnalyze.getBase10Float(new byte[]{0, 0, 0, 0}, 0));
assertEquals(0.0f, trisaBodyAnalyze.getBase10Float(new byte[]{0, 0, 0, -1}, 0));
assertEquals(76.1f, trisaBodyAnalyze.getBase10Float(new byte[]{-70, 29, 0, -2}, 0), eps);
assertEquals(1234.5678f, trisaBodyAnalyze.getBase10Float(new byte[]{78, 97, -68, -4}, 0), eps);
assertEquals(12345678e20f, trisaBodyAnalyze.getBase10Float(new byte[]{78, 97, -68, 20}, 0));
assertEquals(12345678e-20f, trisaBodyAnalyze.getBase10Float(new byte[]{78, 97, -68, -20}, 0), eps);
byte[] data = new byte[]{1,2,3,4,5};
assertEquals(0x030201*1e4f, trisaBodyAnalyze.getBase10Float(data, 0));
assertEquals(0x040302*1e5f, trisaBodyAnalyze.getBase10Float(data, 1));
assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(data, -1));
assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(data, 5));
assertThrows(IndexOutOfBoundsException.class, getBase10FloatRunnable(new byte[]{1,2,3}, 0));
}
@Test
public void convertJavaTimestampToDeviceTests() {
assertEquals(275852082, trisaBodyAnalyze.convertJavaTimestampToDevice(1538156082000L));
// Rounds down.
assertEquals(275852082, trisaBodyAnalyze.convertJavaTimestampToDevice(1538156082499L));
// Rounds up.
assertEquals(275852083, trisaBodyAnalyze.convertJavaTimestampToDevice(1538156082500L));
}
@Test
public void convertDeviceTimestampToJavaTests() {
assertEquals(1538156082000L, trisaBodyAnalyze.convertDeviceTimestampToJava(275852082));
}
@Test
public void parseScaleMeasurementData_validUserData() {
long expected_timestamp_seconds = 1539205852L; // Wed Oct 10 21:10:52 UTC 2018
byte[] bytes = hexToBytes("9f:b0:1d:00:fe:dc:2f:81:10:00:00:00:ff:0a:15:00:ff:00:09:00");
ScaleUser user = new ScaleUser();
user.setGender(Converters.Gender.MALE);
user.setBirthday(ageToBirthday(36));
user.setBodyHeight(186);
user.setMeasureUnit(Converters.MeasureUnit.CM);
ScaleMeasurement measurement = trisaBodyAnalyze.parseScaleMeasurementData(bytes, user);
float eps = 1e-3f;
assertEquals(76.0f, measurement.getWeight(), eps);
assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime());
assertEquals(14.728368f, measurement.getFat(), eps);
assertEquals(64.37914f, measurement.getWater(), eps);
assertEquals(43.36414f, measurement.getMuscle(), eps);
assertEquals(4.525733f, measurement.getBone());
}
@Test
public void parseScaleMeasurementData_missingUserData() {
long expected_timestamp_seconds = 1538156082L; // Fri Sep 28 17:34:42 UTC 2018
byte[] bytes = hexToBytes("9f:ba:1d:00:fe:32:2b:71:10:00:00:00:ff:8d:14:00:ff:00:09:00");
ScaleMeasurement measurement = trisaBodyAnalyze.parseScaleMeasurementData(bytes, null);
assertEquals(76.1f, measurement.getWeight(), 1e-3f);
assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime());
assertEquals(0f, measurement.getFat());
}
@Test
public void parseScaleMeasurementData_invalidUserData() {
long expected_timestamp_seconds = 1538156082L; // Fri Sep 28 17:34:42 UTC 2018
byte[] bytes = hexToBytes("9f:ba:1d:00:fe:32:2b:71:10:00:00:00:ff:8d:14:00:ff:00:09:00");
ScaleMeasurement measurement = trisaBodyAnalyze.parseScaleMeasurementData(bytes, new ScaleUser());
assertEquals(76.1f, measurement.getWeight(), 1e-3f);
assertEquals(new Date(expected_timestamp_seconds * 1000), measurement.getDateTime());
assertEquals(0f, measurement.getFat());
}
/**
* Creates a {@link Runnable} that will call getBase10Float(). In Java 8, this can be done more
* easily with a lambda expression at the call site, but we are using Java 7.
*/
private Runnable getBase10FloatRunnable(final byte[] data, final int offset) {
return new Runnable() {
@Override
public void run() {
trisaBodyAnalyze.getBase10Float(data, offset);
}
};
}
/**
* Runs the given {@link Runnable} and verifies that it throws an exception of class {@code
* exceptionClass}. If it does, the exception will be caught and returned. If it does not (i.e.
* the runnable throws no exception, or throws an exception of a different class), then {@link
* Assert#fail} is called to abort the test.
*/
private static <T extends Throwable> T assertThrows(Class<T> exceptionClass, Runnable run) {
try {
run.run();
Assert.fail("Expected an exception to be thrown.");
} catch (Throwable t) {
if (exceptionClass.isInstance(t)) {
return exceptionClass.cast(t);
}
Assert.fail("Wrong kind of exception was thrown; expected " + exceptionClass + ", received " + t.getClass());
}
return null; // unreachable, because Assert.fail() throws an exception
}
/** Parses a colon-separated hex-encoded string like "aa:bb:cc:dd" into an array of bytes. */
private static byte[] hexToBytes(String s) {
String[] parts = s.split(":");
byte[] bytes = new byte[parts.length];
for (int i = 0; i < bytes.length; ++i) {
if (parts[i].length() != 2) {
throw new IllegalArgumentException();
}
bytes[i] = (byte)Integer.parseInt(parts[i], 16);
}
return bytes;
}
private static Date ageToBirthday(int years) {
int currentYear = GregorianCalendar.getInstance().get(Calendar.YEAR);
return new GregorianCalendar(currentYear - years, Calendar.JANUARY, 1).getTime();
}
}

View File

@@ -1,218 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.gui;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.widget.DatePicker;
import android.widget.EditText;
import android.widget.TimePicker;
import androidx.test.espresso.contrib.PickerActions;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import com.health.openscale.gui.measurement.BicepsMeasurementView;
import com.health.openscale.gui.measurement.BoneMeasurementView;
import com.health.openscale.gui.measurement.Caliper1MeasurementView;
import com.health.openscale.gui.measurement.Caliper2MeasurementView;
import com.health.openscale.gui.measurement.Caliper3MeasurementView;
import com.health.openscale.gui.measurement.ChestMeasurementView;
import com.health.openscale.gui.measurement.CommentMeasurementView;
import com.health.openscale.gui.measurement.DateMeasurementView;
import com.health.openscale.gui.measurement.FatMeasurementView;
import com.health.openscale.gui.measurement.HipMeasurementView;
import com.health.openscale.gui.measurement.LBMMeasurementView;
import com.health.openscale.gui.measurement.MuscleMeasurementView;
import com.health.openscale.gui.measurement.NeckMeasurementView;
import com.health.openscale.gui.measurement.ThighMeasurementView;
import com.health.openscale.gui.measurement.TimeMeasurementView;
import com.health.openscale.gui.measurement.VisceralFatMeasurementView;
import com.health.openscale.gui.measurement.WaistMeasurementView;
import com.health.openscale.gui.measurement.WaterMeasurementView;
import com.health.openscale.gui.measurement.WeightMeasurementView;
import org.hamcrest.Matchers;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.Calendar;
import java.util.List;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.matcher.ViewMatchers.withClassName;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.junit.Assert.assertEquals;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class AddMeasurementTest {
private static Context context;
private static OpenScale openScale;
private static final ScaleUser male = TestData.getMaleUser();
private static final ScaleUser female = TestData.getFemaleUser();
@Rule
public ActivityTestRule<MainActivity> mActivityTestRule = new ActivityTestRule<>(MainActivity.class, false, true);
@BeforeClass
public static void initTest() {
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
openScale = OpenScale.getInstance();
male.setId(openScale.addScaleUser(male));
female.setId(openScale.addScaleUser(female));
// Set first start to true to get the user add dialog
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefs.edit()
.putBoolean("firstStart", false)
.putString(MainActivity.PREFERENCE_LANGUAGE, "en")
.putBoolean(VisceralFatMeasurementView.KEY + "Enable", true)
.putBoolean(LBMMeasurementView.KEY + "Enable", true)
.putBoolean(BoneMeasurementView.KEY + "Enable", true)
.putBoolean(WaistMeasurementView.KEY + "Enable", true)
.putBoolean(HipMeasurementView.KEY + "Enable", true)
.putBoolean(ChestMeasurementView.KEY + "Enable", true)
.putBoolean(BicepsMeasurementView.KEY + "Enable", true)
.putBoolean(ThighMeasurementView.KEY + "Enable", true)
.putBoolean(NeckMeasurementView.KEY + "Enable", true)
.putBoolean(Caliper1MeasurementView.KEY + "Enable", true)
.putBoolean(Caliper2MeasurementView.KEY + "Enable", true)
.putBoolean(Caliper3MeasurementView.KEY + "Enable", true)
.commit();
}
@AfterClass
public static void cleanup() {
openScale.deleteScaleUser(male.getId());
openScale.deleteScaleUser(female.getId());
}
@Test
public void addMeasurementMaleTest() {
openScale.selectScaleUser(male.getId());
ScaleMeasurement measurement = TestData.getMeasurement(1);
onView(withId(R.id.action_add_measurement)).perform(click());
onView(withClassName(Matchers.equalTo(DateMeasurementView.class.getName()))).perform(scrollTo(), click());
Calendar date = Calendar.getInstance();
date.setTime(measurement.getDateTime());
onView(withClassName(Matchers.equalTo(DatePicker.class.getName()))).perform(PickerActions.setDate(date.get(Calendar.YEAR), date.get(Calendar.MONTH)+1, date.get(Calendar.DAY_OF_MONTH)));
onView(withId(android.R.id.button1)).perform(click());
onView(withClassName(Matchers.equalTo(TimeMeasurementView.class.getName()))).perform(scrollTo(), click());
onView(withClassName(Matchers.equalTo(TimePicker.class.getName()))).perform(PickerActions.setTime(date.get(Calendar.HOUR_OF_DAY), date.get(Calendar.MINUTE)));
onView(withId(android.R.id.button1)).perform(click());
setMeasuremntField(WeightMeasurementView.class.getName(), measurement.getWeight());
setMeasuremntField(FatMeasurementView.class.getName(), measurement.getFat());
setMeasuremntField(WaterMeasurementView.class.getName(), measurement.getWater());
setMeasuremntField(MuscleMeasurementView.class.getName(), measurement.getMuscle());
setMeasuremntField(LBMMeasurementView.class.getName(), measurement.getLbm());
setMeasuremntField(BoneMeasurementView.class.getName(), measurement.getBone());
setMeasuremntField(WaistMeasurementView.class.getName(), measurement.getWaist());
setMeasuremntField(HipMeasurementView.class.getName(), measurement.getHip());
setMeasuremntField(VisceralFatMeasurementView.class.getName(), measurement.getVisceralFat());
setMeasuremntField(ChestMeasurementView.class.getName(), measurement.getChest());
setMeasuremntField(ThighMeasurementView.class.getName(), measurement.getThigh());
setMeasuremntField(BicepsMeasurementView.class.getName(), measurement.getBiceps());
setMeasuremntField(NeckMeasurementView.class.getName(), measurement.getNeck());
setMeasuremntField(Caliper1MeasurementView.class.getName(), measurement.getCaliper1());
setMeasuremntField(Caliper2MeasurementView.class.getName(), measurement.getCaliper2());
setMeasuremntField(Caliper3MeasurementView.class.getName(), measurement.getCaliper3());
onView(withClassName(Matchers.equalTo(CommentMeasurementView.class.getName()))).perform(scrollTo(), click());
onView(withClassName(Matchers.equalTo(EditText.class.getName()))).perform(replaceText(measurement.getComment()));
onView(withId(android.R.id.button1)).perform(click());
onView(withId(R.id.saveButton)).perform(click());
List<ScaleMeasurement> scaleMeasurementList = openScale.getScaleMeasurementList();
assertEquals(1, scaleMeasurementList.size());
TestData.compareMeasurements(measurement, scaleMeasurementList.get(0));
}
@Test
public void addMeasurementFemaleTest() {
openScale.selectScaleUser(female.getId());
ScaleMeasurement measurement = TestData.getMeasurement(2);
onView(withId(R.id.action_add_measurement)).perform(click());
onView(withClassName(Matchers.equalTo(DateMeasurementView.class.getName()))).perform(scrollTo(), click());
Calendar date = Calendar.getInstance();
date.setTime(measurement.getDateTime());
onView(withClassName(Matchers.equalTo(DatePicker.class.getName()))).perform(PickerActions.setDate(date.get(Calendar.YEAR), date.get(Calendar.MONTH)+1, date.get(Calendar.DAY_OF_MONTH)));
onView(withId(android.R.id.button1)).perform(click());
onView(withClassName(Matchers.equalTo(TimeMeasurementView.class.getName()))).perform(scrollTo(), click());
onView(withClassName(Matchers.equalTo(TimePicker.class.getName()))).perform(PickerActions.setTime(date.get(Calendar.HOUR_OF_DAY), date.get(Calendar.MINUTE)));
onView(withId(android.R.id.button1)).perform(click());
setMeasuremntField(WeightMeasurementView.class.getName(), Converters.fromKilogram(measurement.getWeight(), Converters.WeightUnit.LB));
setMeasuremntField(FatMeasurementView.class.getName(), measurement.getFat());
setMeasuremntField(WaterMeasurementView.class.getName(), measurement.getWater());
setMeasuremntField(MuscleMeasurementView.class.getName(), measurement.getMuscle());
setMeasuremntField(LBMMeasurementView.class.getName(), Converters.fromKilogram(measurement.getLbm(), Converters.WeightUnit.LB));
setMeasuremntField(BoneMeasurementView.class.getName(), Converters.fromKilogram(measurement.getBone(), Converters.WeightUnit.LB));
setMeasuremntField(WaistMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getWaist(), Converters.MeasureUnit.INCH));
setMeasuremntField(HipMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getHip(), Converters.MeasureUnit.INCH));
setMeasuremntField(VisceralFatMeasurementView.class.getName(), measurement.getVisceralFat());
setMeasuremntField(ChestMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getChest(), Converters.MeasureUnit.INCH));
setMeasuremntField(ThighMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getThigh(), Converters.MeasureUnit.INCH));
setMeasuremntField(BicepsMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getBiceps(), Converters.MeasureUnit.INCH));
setMeasuremntField(NeckMeasurementView.class.getName(), Converters.fromCentimeter(measurement.getNeck(), Converters.MeasureUnit.INCH));
setMeasuremntField(Caliper1MeasurementView.class.getName(), Converters.fromCentimeter(measurement.getCaliper1(), Converters.MeasureUnit.INCH));
setMeasuremntField(Caliper2MeasurementView.class.getName(), Converters.fromCentimeter(measurement.getCaliper2(), Converters.MeasureUnit.INCH));
setMeasuremntField(Caliper3MeasurementView.class.getName(), Converters.fromCentimeter(measurement.getCaliper3(), Converters.MeasureUnit.INCH));
onView(withClassName(Matchers.equalTo(CommentMeasurementView.class.getName()))).perform(scrollTo(), click());
onView(withClassName(Matchers.equalTo(EditText.class.getName()))).perform(replaceText(measurement.getComment()));
onView(withId(android.R.id.button1)).perform(click());
onView(withId(R.id.saveButton)).perform(click());
List<ScaleMeasurement> scaleMeasurementList = openScale.getScaleMeasurementList();
assertEquals(1, scaleMeasurementList.size());
TestData.compareMeasurements(measurement, scaleMeasurementList.get(0));
}
private void setMeasuremntField(String className, float value) {
onView(withClassName(Matchers.equalTo(className))).perform(scrollTo(), click());
onView(withId(R.id.float_input)).perform(replaceText(String.valueOf(value)));
onView(withId(android.R.id.button1)).perform(click());
}
}

View File

@@ -1,194 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.gui;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.DatePicker;
import androidx.test.espresso.ViewInteraction;
import androidx.test.espresso.contrib.PickerActions;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleUser;
import org.hamcrest.Description;
import org.hamcrest.Matcher;
import org.hamcrest.Matchers;
import org.hamcrest.TypeSafeMatcher;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.Calendar;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.action.ViewActions.closeSoftKeyboard;
import static androidx.test.espresso.action.ViewActions.replaceText;
import static androidx.test.espresso.action.ViewActions.scrollTo;
import static androidx.test.espresso.matcher.ViewMatchers.withClassName;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import static org.hamcrest.Matchers.allOf;
import static org.junit.Assert.assertEquals;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class AddUserTest {
private static final double DELTA = 1e-15;
private Context context;
@Rule
public ActivityTestRule<MainActivity> mActivityTestRule = new ActivityTestRule<>(MainActivity.class, false, false);
@Before
public void initTest() {
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
// Set first start to true to get the user add dialog
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefs.edit()
.putBoolean("firstStart", true)
.putString(MainActivity.PREFERENCE_LANGUAGE, "en")
.commit();
}
@After
public void addUserVerification() {
ScaleUser user = OpenScale.getInstance().getSelectedScaleUser();
assertEquals("test", user.getUserName());
assertEquals(180, user.getBodyHeight(), DELTA);
assertEquals(80, user.getInitialWeight(), DELTA);
assertEquals(60, user.getGoalWeight(), DELTA);
Calendar birthday = Calendar.getInstance();
birthday.setTimeInMillis(0);
birthday.set(Calendar.YEAR, 1990);
birthday.set(Calendar.MONTH, Calendar.JANUARY);
birthday.set(Calendar.DAY_OF_MONTH, 19);
birthday.set(Calendar.HOUR_OF_DAY, 0);
assertEquals(birthday.getTime().getTime(), user.getBirthday().getTime());
Calendar goalDate = Calendar.getInstance();
goalDate.setTimeInMillis(0);
goalDate.set(Calendar.YEAR, 2018);
goalDate.set(Calendar.MONTH, Calendar.JANUARY);
goalDate.set(Calendar.DAY_OF_MONTH, 31);
goalDate.set(Calendar.HOUR_OF_DAY, 0);
assertEquals(goalDate.getTime().getTime(), user.getGoalDate().getTime());
OpenScale.getInstance().deleteScaleUser(user.getId());
}
@Test
public void addUserTest() {
mActivityTestRule.launchActivity(null);
ViewInteraction editText = onView(
allOf(withId(R.id.txtUserName),
childAtPosition(
allOf(withId(R.id.rowUserName),
childAtPosition(
withId(R.id.tableUserData),
0)),
1)));
editText.perform(scrollTo(), click());
ViewInteraction editText2 = onView(
allOf(withId(R.id.txtUserName),
childAtPosition(
allOf(withId(R.id.rowUserName),
childAtPosition(
withId(R.id.tableUserData),
0)),
1)));
editText2.perform(scrollTo(), replaceText("test"), closeSoftKeyboard());
ViewInteraction editText3 = onView(
allOf(withId(R.id.txtBodyHeight),
childAtPosition(
allOf(withId(R.id.rowBodyHeight),
childAtPosition(
withId(R.id.tableUserData),
6)),
1)));
editText3.perform(scrollTo(), replaceText("180"), closeSoftKeyboard());
onView(withId(R.id.txtBirthday)).perform(click());
onView(withClassName(Matchers.equalTo(DatePicker.class.getName()))).perform(PickerActions.setDate(1990, 1, 19));
onView(withId(android.R.id.button1)).perform(click());
ViewInteraction editText5 = onView(
allOf(withId(R.id.txtInitialWeight),
childAtPosition(
allOf(withId(R.id.tableRowInitialWeight),
childAtPosition(
withId(R.id.tableUserData),
7)),
1)));
editText5.perform(scrollTo(), replaceText("80"), closeSoftKeyboard());
ViewInteraction editText6 = onView(
allOf(withId(R.id.txtGoalWeight),
childAtPosition(
allOf(withId(R.id.rowGoalWeight),
childAtPosition(
withId(R.id.tableUserData),
8)),
1)));
editText6.perform(scrollTo(), replaceText("60"), closeSoftKeyboard());
onView(withId(R.id.txtGoalDate)).perform(click());
onView(withClassName(Matchers.equalTo(DatePicker.class.getName()))).perform(PickerActions.setDate(2018, 1, 31));
onView(withId(android.R.id.button1)).perform(click());
onView(withId(R.id.saveButton)).perform(click());
}
private static Matcher<View> childAtPosition(
final Matcher<View> parentMatcher, final int position) {
return new TypeSafeMatcher<View>() {
@Override
public void describeTo(Description description) {
description.appendText("Child at position " + position + " in parent ");
parentMatcher.describeTo(description);
}
@Override
public boolean matchesSafely(View view) {
ViewParent parent = view.getParent();
return parent instanceof ViewGroup && parentMatcher.matches(parent)
&& view.equals(((ViewGroup) parent).getChildAt(position));
}
};
}
}

View File

@@ -1,319 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.gui;
import static android.os.Environment.DIRECTORY_PICTURES;
import static android.os.Environment.getExternalStoragePublicDirectory;
import static androidx.test.espresso.Espresso.onView;
import static androidx.test.espresso.Espresso.pressBack;
import static androidx.test.espresso.action.ViewActions.click;
import static androidx.test.espresso.assertion.ViewAssertions.matches;
import static androidx.test.espresso.contrib.DrawerActions.close;
import static androidx.test.espresso.contrib.DrawerActions.open;
import static androidx.test.espresso.contrib.DrawerMatchers.isClosed;
import static androidx.test.espresso.contrib.NavigationViewActions.navigateTo;
import static androidx.test.espresso.matcher.ViewMatchers.withId;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.preference.PreferenceManager;
import android.view.Gravity;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.LargeTest;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.rule.ActivityTestRule;
import androidx.test.runner.screenshot.BasicScreenCaptureProcessor;
import androidx.test.runner.screenshot.ScreenCapture;
import androidx.test.runner.screenshot.Screenshot;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.CsvHelper;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.List;
import java.util.Locale;
import timber.log.Timber;
@LargeTest
@RunWith(AndroidJUnit4.class)
public class ScreenshotRecorder {
private Context context;
private OpenScale openScale;
private final int WAIT_MS = 500;
@Rule
public ActivityTestRule<MainActivity> mActivityTestRule = new ActivityTestRule<>(MainActivity.class, false , false);
@Before
public void initRecorder() {
context = InstrumentationRegistry.getInstrumentation().getTargetContext();
openScale = OpenScale.getInstance();
// Set first start to true to get the user add dialog
PreferenceManager.getDefaultSharedPreferences(context).edit()
.putBoolean("firstStart", false)
.putBoolean("waistEnable", true)
.putBoolean("hipEnable", true)
.putBoolean("boneEnable", true)
.commit();
}
@Test
public void captureScreenshots() {
try {
mActivityTestRule.runOnUiThread(new Runnable() {
public void run() {
prepareData();
}
});
} catch (Throwable throwable) {
throwable.printStackTrace();
}
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String language = prefs.getString(MainActivity.PREFERENCE_LANGUAGE, "default");
prefs.edit()
.remove("lastFragmentId")
.putString(MainActivity.PREFERENCE_LANGUAGE, "en")
.commit();
screenshotRecorder();
prefs.edit()
.remove("lastFragmentId")
.putString(MainActivity.PREFERENCE_LANGUAGE, "de")
.commit();
screenshotRecorder();
// Restore language setting
prefs.edit()
.putString(MainActivity.PREFERENCE_LANGUAGE, language)
.commit();
}
private ScaleUser getTestUser() {
ScaleUser user = new ScaleUser();
user.setUserName("Test");
user.setBodyHeight(180);
user.setInitialWeight(80.0f);
user.setGoalWeight(60.0f);
Calendar birthday = Calendar.getInstance();
birthday.add(Calendar.YEAR, -28);
birthday.set(birthday.get(Calendar.YEAR), Calendar.JANUARY, 19, 0, 0, 0);
birthday.set(Calendar.MILLISECOND, 0);
user.setBirthday(birthday.getTime());
Calendar goalDate = Calendar.getInstance();
goalDate.add(Calendar.YEAR, 1);
goalDate.set(goalDate.get(Calendar.YEAR), Calendar.JANUARY, 31, 0, 0, 0);
goalDate.set(Calendar.MILLISECOND, 0);
user.setGoalDate(goalDate.getTime());
return user;
}
private List<ScaleMeasurement> getTestMeasurements() {
List<ScaleMeasurement> scaleMeasurementList = new ArrayList<>();
String data = "\"dateTime\",\"weight\",\"fat\",\"water\",\"muscle\",\"lbm\",\"bone\",\"waist\",\"hip\",\"comment\"\n" +
"04.08.2023 08:08,89.7,21.2,58.0,41.5\n" +
"03.08.2023 05:17,89.0,26.4,54.6,41.6\n" +
"02.08.2023 07:32,88.8,25.0,55.6,41.7\n" +
"31.07.2023 04:39,89.1,29.2,52.8,41.6\n" +
"18.07.2023 07:54,91.3,22.1,57.4,41.2\n" +
"12.07.2023 07:14,91.1,21.9,57.6,41.3\n" +
"16.06.2023 05:16,89.5,25.3,55.4,41.5\n" +
"15.06.2023 05:34,90.1,26.3,54.7,41.4\n" +
"12.06.2023 05:36,90.3,26.4,54.6,41.4\n" +
"10.06.2023 04:22,90.8,22.3,57.3,41.3\n" +
"07.06.2023 10:17,90.0,22.6,57.1,41.4\n" +
"06.06.2023 06:36,91.0,21.6,57.8,41.3\n" +
"05.06.2023 06:57,91.6,21.7,57.7,41.2\n" +
"04.06.2023 06:35,90.4,23.5,56.5,41.4\n" +
"25.05.2023 10:25,89.5,21.6,57.8,41.5\n" +
"17.05.2023 09:55,92.5,21.9,57.6,41.0\n" +
"09.05.2023 09:30,89.0,21.6,57.8,41.6\n" +
"29.04.2023 08:25,89.2,21.0,58.2,41.4\n" +
"13.04.2023 04:54,87.6,32.7,50.6,41.9\n" +
"11.04.2023 07:41,86.8,20.9,58.3,42.0\n" +
"10.04.2023 05:27,86.4,24.0,56.3,42.1\n" +
"06.04.2023 06:45,87.6,24.4,56.0,41.9\n" +
"01.04.2023 05:03,88.6,25.6,55.2,41.7\n" +
"28.03.2023 07:06,87.1,23.5,56.6,42.2\n" +
"21.03.2023 18:21,88.1,20.7,58.5,42.0\n" +
"15.03.2023 20:56,90.3,22.6,57.1,41.6\n" +
"14.03.2023 07:37,87.2,25.3,55.5,42.1\n" +
"13.03.2023 06:11,85.6,27.4,54.1,42.4\n" +
"17.02.2023 10:32,86.6,20.6,58.5,42.2\n" +
"16.02.2023 07:59,87.5,27.6,53.9,42.1\n" +
"15.02.2023 10:38,86.4,23.4,56.7,42.3\n" +
"14.02.2023 09:18,87.5,20.5,58.6,42.1\n" +
"08.02.2023 07:05,85.5,26.6,54.6,42.4\n" +
"06.02.2023 06:09,85.8,30.3,52.2,42.4\n" +
"05.02.2023 06:16,86.5,31.2,51.6,42.3\n" +
"04.02.2023 06:10,86.7,28.3,53.5,42.2\n" +
"01.02.2023 08:59,87.4,22.2,57.5,42.1\n" +
"24.01.2023 09:55,85.1,24.1,56.2,42.5\n" +
"18.01.2023 11:11,86.1,20.1,58.9,42.3\n" +
"14.01.2023 06:11,86.9,26.3,54.8,42.2\n" +
"07.01.2023 07:08,85.6,20.3,58.7,42.4\n" +
"06.01.2023 10:34,85.5,19.7,59.1,42.4\n" +
"05.01.2023 08:25,85.6,26.1,54.9,42.4\n" +
"02.01.2023 18:06,86.3,19.8,59.1,42.3\n" +
"13.12.2022 13:16,85.2,19.3,59.4,42.5\n" +
"09.12.2022 19:36,86.9,20.3,58.7,42.2\n" +
"08.12.2022 20:28,86.8,19.9,59.0,42.2\n" +
"05.12.2022 18:21,86.7,20.3,58.7,42.2\n";
try {
scaleMeasurementList = CsvHelper.importFrom(new BufferedReader(new StringReader(data)));
} catch (IOException | ParseException e) {
Timber.e(e);
}
// set current year to the measurement data
Calendar measurementDate = Calendar.getInstance();
int year = measurementDate.get(Calendar.YEAR);
for (ScaleMeasurement measurement : scaleMeasurementList) {
measurementDate.setTime(measurement.getDateTime());
measurementDate.set(Calendar.YEAR, year);
measurement.setDateTime(measurementDate.getTime());
}
return scaleMeasurementList;
}
private void prepareData() {
int userId = openScale.addScaleUser(getTestUser());
openScale.selectScaleUser(userId);
List<ScaleMeasurement> scaleMeasurementList = getTestMeasurements();
for (ScaleMeasurement measurement : scaleMeasurementList) {
openScale.addScaleMeasurement(measurement, true);
}
}
private void screenshotRecorder() {
try {
mActivityTestRule.launchActivity(null);
Thread.sleep(WAIT_MS);
captureScreenshot("overview");
onView(withId(R.id.action_add_measurement)).perform(click());
Thread.sleep(WAIT_MS);
captureScreenshot("dataentry");
pressBack();
onView(withId(R.id.drawer_layout))
.perform(open()); // Open Drawer
onView(withId(R.id.navigation_view))
.perform(navigateTo(R.id.nav_graph));
onView(withId(R.id.drawer_layout))
.perform(close()); // Close Drawer
onView(withId(R.id.drawer_layout))
.check(matches(isClosed(Gravity.LEFT)));
Thread.sleep(WAIT_MS);
captureScreenshot("graph");
onView(withId(R.id.drawer_layout))
.perform(open()); // Open Drawer
onView(withId(R.id.navigation_view))
.perform(navigateTo(R.id.nav_table));
onView(withId(R.id.drawer_layout))
.perform(close()); // Close Drawer
onView(withId(R.id.drawer_layout))
.check(matches(isClosed(Gravity.LEFT)));
Thread.sleep(WAIT_MS);
captureScreenshot("table");
onView(withId(R.id.drawer_layout))
.perform(open()); // Open Drawer
onView(withId(R.id.navigation_view))
.perform(navigateTo(R.id.nav_statistic));
onView(withId(R.id.drawer_layout))
.perform(close()); // Close Drawer
onView(withId(R.id.drawer_layout))
.check(matches(isClosed(Gravity.LEFT)));
Thread.sleep(WAIT_MS);
captureScreenshot("statistics");
mActivityTestRule.finishActivity();
} catch (InterruptedException e) {
Timber.e(e);
}
}
private void captureScreenshot(String name) {
BasicScreenCaptureProcessor processor = new BasicScreenCaptureProcessor();
ScreenCapture capture = Screenshot.capture();
capture.setFormat(Bitmap.CompressFormat.PNG);
capture.setName(name);
try {
String filename = processor.process(capture);
// rename file to remove UUID suffix
File folder = new File(getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/screenshots/openScale_" + Locale.getDefault().getLanguage());
if (!folder.exists()) {
folder.mkdir();
}
Timber.d("Saved to " + getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/screenshots/" + filename);
File from = new File(getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/screenshots/" + filename);
File to = new File(getExternalStoragePublicDirectory(DIRECTORY_PICTURES) + "/screenshots/openScale_" + Locale.getDefault().getLanguage() + "/screen_" + name + ".png");
from.renameTo(to);
} catch (IOException ex) {
throw new IllegalStateException(ex);
}
}
}

View File

@@ -1,141 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.gui;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.Calendar;
import java.util.Date;
import java.util.Random;
import static org.junit.Assert.assertEquals;
public class TestData {
private static Random rand = new Random();
private static final double DELTA = 1e-4;
public static ScaleUser getMaleUser() {
ScaleUser male = new ScaleUser();
male.setUserName("Bob");
male.setGender(Converters.Gender.MALE);
male.setInitialWeight(80.0f);
male.setScaleUnit(Converters.WeightUnit.KG);
male.setActivityLevel(Converters.ActivityLevel.MILD);
male.setBodyHeight(180.0f);
male.setGoalWeight(60.0f);
male.setMeasureUnit(Converters.MeasureUnit.CM);
male.setBirthday(getDateFromYears(-20));
male.setGoalDate(getDateFromYears(2));
return male;
}
public static ScaleUser getFemaleUser() {
ScaleUser female = new ScaleUser();
female.setUserName("Alice");
female.setGender(Converters.Gender.FEMALE);
female.setInitialWeight(70.0f);
female.setScaleUnit(Converters.WeightUnit.LB);
female.setActivityLevel(Converters.ActivityLevel.EXTREME);
female.setBodyHeight(160.0f);
female.setGoalWeight(50.0f);
female.setMeasureUnit(Converters.MeasureUnit.INCH);
female.setBirthday(getDateFromYears(-25));
female.setGoalDate(getDateFromYears(1));
return female;
}
public static ScaleMeasurement getMeasurement(int nr) {
ScaleMeasurement measurement = new ScaleMeasurement();
rand.setSeed(nr);
measurement.setDateTime(getDateFromDays(nr));
measurement.setWeight(100.0f + getRandNumberInRange(0,50));
measurement.setFat(30.0f + getRandNumberInRange(0,30));
measurement.setWater(50.0f + getRandNumberInRange(0,20));
measurement.setMuscle(40.0f + getRandNumberInRange(0,15));
measurement.setLbm(20.0f + getRandNumberInRange(0,10));
measurement.setBone(8.0f + getRandNumberInRange(0,50));
measurement.setWaist(50.0f + getRandNumberInRange(0,50));
measurement.setHip(60.0f + getRandNumberInRange(0,50));
measurement.setChest(80.0f + getRandNumberInRange(0,50));
measurement.setThigh(40.0f + getRandNumberInRange(0,50));
measurement.setVisceralFat(10 + getRandNumberInRange(0,5));
measurement.setBiceps(30.0f + getRandNumberInRange(0,50));
measurement.setNeck(15.0f + getRandNumberInRange(0,50));
measurement.setCaliper1(5.0f + getRandNumberInRange(0,10));
measurement.setCaliper2(10.0f + getRandNumberInRange(0,10));
measurement.setCaliper3(7.0f + getRandNumberInRange(0,10));
measurement.setComment("my comment " + nr);
return measurement;
}
public static void compareMeasurements(ScaleMeasurement measurementA, ScaleMeasurement measurementB) {
assertEquals(measurementA.getDateTime().getTime(), measurementB.getDateTime().getTime(), DELTA);
assertEquals(measurementA.getWeight(), measurementB.getWeight(), DELTA);
assertEquals(measurementA.getFat(), measurementB.getFat(), DELTA);
assertEquals(measurementA.getWater(), measurementB.getWater(), DELTA);
assertEquals(measurementA.getMuscle(), measurementB.getMuscle(), DELTA);
assertEquals(measurementA.getLbm(), measurementB.getLbm(), DELTA);
assertEquals(measurementA.getBone(), measurementB.getBone(), DELTA);
assertEquals(measurementA.getWaist(), measurementB.getWaist(), DELTA);
assertEquals(measurementA.getHip(), measurementB.getHip(), DELTA);
assertEquals(measurementA.getChest(), measurementB.getChest(), DELTA);
assertEquals(measurementA.getThigh(), measurementB.getThigh(), DELTA);
assertEquals(measurementA.getVisceralFat(), measurementB.getVisceralFat(), DELTA);
assertEquals(measurementA.getBiceps(), measurementB.getBiceps(), DELTA);
assertEquals(measurementA.getNeck(), measurementB.getNeck(), DELTA);
assertEquals(measurementA.getCaliper1(), measurementB.getCaliper1(), DELTA);
assertEquals(measurementA.getCaliper2(), measurementB.getCaliper2(), DELTA);
assertEquals(measurementA.getCaliper3(), measurementB.getCaliper3(), DELTA);
assertEquals(measurementA.getComment(), measurementB.getComment());
}
private static Date getDateFromYears(int years) {
Calendar currentTime = Calendar.getInstance();
currentTime.add(Calendar.YEAR, years);
currentTime.set(Calendar.HOUR_OF_DAY, 8);
currentTime.set(Calendar.MINUTE, 0);
currentTime.set(Calendar.MILLISECOND, 0);
currentTime.set(Calendar.SECOND, 0);
return currentTime.getTime();
}
private static Date getDateFromDays(int days) {
Calendar currentTime = Calendar.getInstance();
currentTime.add(Calendar.DAY_OF_YEAR, days);
currentTime.set(Calendar.HOUR_OF_DAY, 8);
currentTime.set(Calendar.MINUTE, 0);
currentTime.set(Calendar.MILLISECOND, 0);
currentTime.set(Calendar.SECOND, 0);
return currentTime.getTime();
}
private static float getRandNumberInRange(int min, int max) {
return (float)(rand.nextInt(max*10 - min*10) + min*10) / 10.0f;
}
}

View File

@@ -1,84 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- Permission to allow read of data from the database through a ContentProvider.
Marked "dangerous" so that explicit user approval is required to read this data, not
just the permission implied from installing the app from the Play Store. -->
<permission
android:name="${applicationId}.READ_WRITE_DATA"
android:description="@string/permission_read_write_data_description"
android:label="@string/permission_read_write_data_label"
android:protectionLevel="dangerous" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s"/>
<application
android:allowBackup="true"
android:requestLegacyExternalStorage="true"
android:icon="${appIcon}"
android:roundIcon="${appIconRound}"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:name=".core.Application"
android:theme="@style/AppTheme" >
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.OpenScale">
<activity
android:name=".gui.MainActivity"
android:theme="@style/AppTheme.NoActionBar"
android:exported="true">
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.OpenScale">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".gui.slides.AppIntroActivity" android:theme="@style/AppTheme.NoActionBar" android:exported="true"/>
<activity android:name=".gui.slides.SlideToNavigationAdapter" android:theme="@style/AppTheme.NoActionBar" android:exported="true"/>
<receiver android:name=".core.alarm.ReminderBootReceiver" android:enabled="false" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<receiver android:name=".gui.widget.WidgetProvider" android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
<activity android:name=".gui.widget.WidgetConfigure" android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE"/>
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_provider_paths" />
</provider>
<provider
android:name=".core.database.ScaleDatabaseProvider"
android:authorities="${applicationId}.provider"
android:enabled="true"
android:exported="true"
android:permission="${applicationId}.READ_WRITE_DATA">
</provider>
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
</manifest>

View File

@@ -0,0 +1,207 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.health.openscale.core.data.InputFieldType
import com.health.openscale.core.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeKey
import com.health.openscale.core.data.SupportedLanguage
import com.health.openscale.core.data.UnitType
import com.health.openscale.core.database.AppDatabase
import com.health.openscale.core.database.DatabaseRepository
import com.health.openscale.core.utils.LanguageUtil
import com.health.openscale.core.utils.LogManager
import com.health.openscale.core.database.UserSettingsRepository
import com.health.openscale.core.database.provideUserSettingsRepository
import com.health.openscale.ui.navigation.AppNavigation
import com.health.openscale.ui.screen.SharedViewModel
import com.health.openscale.ui.theme.OpenScaleTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* Generates a default list of measurement types available in the application,
* resolving names from string resources.
* These types are intended for insertion into the database on the first app start.
*
* @param context The context used to access string resources.
* @return A list of [MeasurementType] objects.
*/
fun getDefaultMeasurementTypes(context: Context): List<MeasurementType> {
return listOf(
MeasurementType(key = MeasurementTypeKey.WEIGHT, unit = UnitType.KG, color = 0xFFEF2929.toInt(), icon = "ic_weight", isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BMI, color = 0xFFF57900.toInt(), icon = "ic_bmi", isDerived = true, isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BODY_FAT, color = 0xFFFFCE44.toInt(), icon = "ic_fat", isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WATER, color = 0xFF8AE234.toInt(), icon = "ic_water", isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.MUSCLE, color = 0xFF729FCF.toInt(), icon = "ic_muscle", isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.LBM, color = 0xFFAD7FA8.toInt(), icon = "ic_lbm", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BONE, color = 0xFFE9B96E.toInt(), icon = "ic_bone", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WAIST, color = 0xFF888A85.toInt(), icon = "ic_waist", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WHR, color = 0xFF204A87.toInt(), icon = "ic_whr", isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.WHTR, color = 0xFF204A87.toInt(), icon = "ic_whtr", isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.HIPS, color = 0xFF3465A4.toInt(), icon = "ic_hip", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.VISCERAL_FAT, color = 0xFF4E9A06.toInt(), icon = "ic_visceral_fat", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CHEST, color = 0xFF5C3566.toInt(), icon = "ic_chest", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.THIGH, color = 0xFFC17D11.toInt(), icon = "ic_thigh", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BICEPS, color = 0xFFA40000.toInt(), icon = "ic_biceps", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.NECK, color = 0xFFCE5C00.toInt(), icon = "ic_neck", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_1, color = 0xFFEDD400.toInt(), icon = "ic_caliper1", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_2, color = 0xFF73D216.toInt(), icon = "ic_caliper2", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER_3, color = 0xFF11A879.toInt(), icon = "ic_caliper3", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALIPER, color = 0xFF555753.toInt(), icon = "ic_fat_caliper", isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.BMR, color = 0xFFBABDB6.toInt(), icon = "ic_bmr", isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.TDEE, color = 0xFFD3D7CF.toInt(), icon = "ic_tdee", isDerived = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.CALORIES, color = 0xFF2E3436.toInt(), icon = "ic_calories", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.COMMENT, inputType = InputFieldType.TEXT, unit = UnitType.NONE, color = 0xFF729FCF.toInt(), icon = "ic_comment", isPinned = true, isEnabled = true),
MeasurementType(key = MeasurementTypeKey.DATE, inputType = InputFieldType.DATE, unit = UnitType.NONE, color = 0xFFA40000.toInt(), icon = "ic_date", isEnabled = true),
MeasurementType(key = MeasurementTypeKey.TIME, inputType = InputFieldType.TIME, unit = UnitType.NONE, color = 0xFF73D216.toInt(), icon = "ic_time", isEnabled = true)
)
}
/**
* The main entry point of the application.
* This activity hosts the Jetpack Compose UI and initializes essential components
* like the database, repositories, and ViewModels.
*/
class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
}
private lateinit var userSettingsRepository: UserSettingsRepository // Machen Sie es zur Property
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
userSettingsRepository = provideUserSettingsRepository(applicationContext)
// --- LogManager initializing ---
lifecycleScope.launch {
val isFileLoggingEnabled = userSettingsRepository.isFileLoggingEnabled.first()
LogManager.init(applicationContext, isFileLoggingEnabled)
LogManager.d(TAG, "LogManager initialized. File logging enabled: $isFileLoggingEnabled")
}
// --- Language initializing ---
lifecycleScope.launch {
userSettingsRepository.appLanguageCode.collectLatest { languageCode ->
val currentActivityLocale = resources.configuration.locales.get(0).language
val targetLanguage = languageCode ?: SupportedLanguage.getDefault().code
LogManager.d(TAG, "Observed language code: $languageCode, Current activity locale: $currentActivityLocale, Target: $targetLanguage")
if (currentActivityLocale != targetLanguage) {
LogManager.i(TAG, "Language changed or first load. Applying locale: $targetLanguage and recreating activity.")
LanguageUtil.updateAppLocale(this@MainActivity, targetLanguage)
if (!isFinishing) {
recreate()
}
} else {
if (!isFinishing && !isChangingConfigurations) {
initializeAndSetContent()
}
}
}
}
}
private fun initializeAndSetContent() {
val db = AppDatabase.getInstance(applicationContext)
val databaseRepository = DatabaseRepository(
database = db,
userDao = db.userDao(),
measurementDao = db.measurementDao(),
measurementValueDao = db.measurementValueDao(),
measurementTypeDao = db.measurementTypeDao()
)
// --- Measurement Types initializing ---
CoroutineScope(Dispatchers.IO).launch {
val isActuallyFirstStart = userSettingsRepository.isFirstAppStart.first()
LogManager.d(TAG, "Checking for first app start. isFirstAppStart: $isActuallyFirstStart")
if (isActuallyFirstStart) {
LogManager.i(TAG, "First app start detected. Inserting default measurement types...")
val defaultTypesToInsert = getDefaultMeasurementTypes(this@MainActivity)
db.measurementTypeDao().insertAll(defaultTypesToInsert)
userSettingsRepository.setFirstAppStartCompleted(false)
LogManager.i(TAG, "Default measurement types inserted and first start marked as completed.")
} else {
LogManager.d(TAG, "Not the first app start. Default data should already exist.")
}
}
enableEdgeToEdge()
setContent {
OpenScaleTheme {
val sharedViewModel: SharedViewModel = viewModel(
factory = provideSharedViewModelFactory(databaseRepository, userSettingsRepository)
)
val view = LocalView.current
if (!view.isInEditMode) {
DisposableEffect(Unit) {
val window = this@MainActivity.window
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = false
insetsController.isAppearanceLightNavigationBars = false
onDispose { }
}
}
AppNavigation(sharedViewModel)
}
}
}
}
/**
* Provides a [ViewModelProvider.Factory] for creating [SharedViewModel] instances.
* This allows for dependency injection into the ViewModel.
*
* @param databaseRepository The repository for accessing database operations.
* @param userSettingsRepository The repository for accessing user preferences.
* @return A [ViewModelProvider.Factory] for [SharedViewModel].
*/
private fun provideSharedViewModelFactory(
databaseRepository: DatabaseRepository,
userSettingsRepository: UserSettingsRepository
): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(SharedViewModel::class.java)) {
@Suppress("UNCHECKED_CAST")
return SharedViewModel(databaseRepository, userSettingsRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}
}

View File

@@ -1,48 +0,0 @@
/* Copyright (C) 2018 Erik Johansson <erik@ejohansson.se>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core;
import com.health.openscale.BuildConfig;
import timber.log.Timber;
public class Application extends android.app.Application {
OpenScale openScale;
private class TimberLogAdapter extends Timber.DebugTree {
@Override
protected boolean isLoggable(String tag, int priority) {
if (BuildConfig.DEBUG || OpenScale.DEBUG_MODE) {
return super.isLoggable(tag, priority);
}
return false;
}
}
@Override
public void onCreate() {
super.onCreate();
Timber.plant(new TimberLogAdapter());
// Create OpenScale instance
OpenScale.createInstance(getApplicationContext());
// Hold on to the instance for as long as the application exists
openScale = OpenScale.getInstance();
}
}

View File

@@ -1,774 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabaseCorruptException;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.preference.PreferenceManager;
import android.provider.OpenableColumns;
import android.text.format.DateFormat;
import android.widget.Toast;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LiveData;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import androidx.sqlite.db.SupportSQLiteDatabase;
import com.health.openscale.R;
import com.health.openscale.core.alarm.AlarmHandler;
import com.health.openscale.core.bluetooth.BluetoothCommunication;
import com.health.openscale.core.bluetooth.BluetoothFactory;
import com.health.openscale.core.bodymetric.EstimatedFatMetric;
import com.health.openscale.core.bodymetric.EstimatedLBMMetric;
import com.health.openscale.core.bodymetric.EstimatedWaterMetric;
import com.health.openscale.core.database.AppDatabase;
import com.health.openscale.core.database.ScaleMeasurementDAO;
import com.health.openscale.core.database.ScaleUserDAO;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import com.health.openscale.core.utils.CsvHelper;
import com.health.openscale.gui.measurement.FatMeasurementView;
import com.health.openscale.gui.measurement.LBMMeasurementView;
import com.health.openscale.gui.measurement.MeasurementViewSettings;
import com.health.openscale.gui.measurement.WaterMeasurementView;
import com.health.openscale.gui.widget.WidgetProvider;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.text.ParseException;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import timber.log.Timber;
public class OpenScale {
public static boolean DEBUG_MODE = false;
public static final String DATABASE_NAME = "openScale.db";
public static final float SMART_USER_ASSIGN_DEFAULT_RANGE = 15.0F;
private static OpenScale instance;
private AppDatabase appDB;
private ScaleMeasurementDAO measurementDAO;
private ScaleUserDAO userDAO;
private ScaleUser selectedScaleUser;
private BluetoothCommunication btDeviceDriver;
private AlarmHandler alarmHandler;
private Context context;
private OpenScale(Context context) {
this.context = context;
alarmHandler = new AlarmHandler();
btDeviceDriver = null;
reopenDatabase(false);
}
public static void createInstance(Context context) {
if (instance != null) {
return;
}
instance = new OpenScale(context);
}
public static OpenScale getInstance() {
if (instance == null) {
throw new RuntimeException("No OpenScale instance created");
}
return instance;
}
public void reopenDatabase(boolean truncate) throws SQLiteDatabaseCorruptException {
if (appDB != null) {
appDB.close();
}
appDB = Room.databaseBuilder(context, AppDatabase.class, DATABASE_NAME)
.allowMainThreadQueries()
.setJournalMode(truncate == true ? RoomDatabase.JournalMode.TRUNCATE : RoomDatabase.JournalMode.AUTOMATIC) // in truncate mode no sql cache files (-shm, -wal) are generated
.addCallback(new RoomDatabase.Callback() {
@Override
public void onOpen(SupportSQLiteDatabase db) {
super.onOpen(db);
db.setForeignKeyConstraintsEnabled(true);
}
})
.addMigrations(AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6)
.build();
measurementDAO = appDB.measurementDAO();
userDAO = appDB.userDAO();
}
public void triggerWidgetUpdate() {
int[] ids = AppWidgetManager.getInstance(context).getAppWidgetIds(
new ComponentName(context, WidgetProvider.class));
if (ids.length > 0) {
Intent intent = new Intent(context, WidgetProvider.class);
intent.setAction(AppWidgetManager.ACTION_APPWIDGET_UPDATE);
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids);
context.sendBroadcast(intent);
}
}
public int addScaleUser(final ScaleUser user) {
return (int)userDAO.insert(user);
}
public void selectScaleUser(int userId) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefs.edit().putInt("selectedUserId", userId).apply();
selectedScaleUser = getScaleUser(userId);
}
public int getSelectedScaleUserId() {
if (selectedScaleUser != null) {
return selectedScaleUser.getId();
}
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
return prefs.getInt("selectedUserId", -1);
}
public List<ScaleUser> getScaleUserList() {
return userDAO.getAll();
}
public ScaleUser getScaleUser(int userId) {
if (selectedScaleUser != null && selectedScaleUser.getId() == userId) {
return selectedScaleUser;
}
return userDAO.get(userId);
}
public ScaleUser getSelectedScaleUser() {
if (selectedScaleUser != null) {
return selectedScaleUser;
}
try {
final int selectedUserId = getSelectedScaleUserId();
if (selectedUserId != -1) {
selectedScaleUser = userDAO.get(selectedUserId);
if (selectedScaleUser == null) {
selectScaleUser(-1);
throw new Exception("could not find the selected user");
}
return selectedScaleUser;
}
} catch (Exception e) {
Timber.e(e);
runUiToastMsg("Error: " + e.getMessage());
}
return new ScaleUser();
}
public void deleteScaleUser(int id) {
Timber.d("Delete user " + getScaleUser(id));
userDAO.delete(userDAO.get(id));
selectedScaleUser = null;
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
// Remove user specific settings
SharedPreferences.Editor editor = prefs.edit();
final String prefix = ScaleUser.getPreferenceKey(id, "");
for (String key : prefs.getAll().keySet()) {
if (key.startsWith(prefix)) {
editor.remove(key);
}
}
editor.apply();
}
public void updateScaleUser(ScaleUser user) {
userDAO.update(user);
selectedScaleUser = null;
}
public boolean isScaleMeasurementListEmpty() {
if (measurementDAO.getCount(getSelectedScaleUserId()) == 0) {
return true;
}
return false;
}
public ScaleMeasurement getLastScaleMeasurement() {
return measurementDAO.getLatest(getSelectedScaleUserId());
}
public ScaleMeasurement getLastScaleMeasurement(int userId) {
return measurementDAO.getLatest(userId);
}
public ScaleMeasurement getFirstScaleMeasurement() {
return measurementDAO.getFirst(getSelectedScaleUserId());
}
public List<ScaleMeasurement> getScaleMeasurementList() {
return measurementDAO.getAll(getSelectedScaleUserId());
}
public ScaleMeasurement[] getTupleOfScaleMeasurement(int id)
{
ScaleMeasurement[] tupleScaleMeasurement = new ScaleMeasurement[3];
tupleScaleMeasurement[0] = null;
tupleScaleMeasurement[1] = measurementDAO.get(id);
tupleScaleMeasurement[2] = null;
if (tupleScaleMeasurement[1] != null) {
tupleScaleMeasurement[0] = measurementDAO.getPrevious(id, tupleScaleMeasurement[1].getUserId());
tupleScaleMeasurement[2] = measurementDAO.getNext(id, tupleScaleMeasurement[1].getUserId());
}
return tupleScaleMeasurement;
}
public int addScaleMeasurement(final ScaleMeasurement scaleMeasurement) {
return addScaleMeasurement(scaleMeasurement, false);
}
public int addScaleMeasurement(final ScaleMeasurement scaleMeasurement, boolean silent) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
// Check user id and do a smart user assign if option is enabled
if (scaleMeasurement.getUserId() == -1) {
scaleMeasurement.setUserId(getAssignableUser(scaleMeasurement.getWeight()));
// don't add scale data if no user is selected
if (scaleMeasurement.getUserId() == -1) {
Timber.e("to be added measurement are thrown away because no user is selected");
return -1;
}
}
// Assisted weighing
if (getScaleUser(scaleMeasurement.getUserId()).isAssistedWeighing()) {
int assistedWeighingRefUserId = prefs.getInt("assistedWeighingRefUserId", -1);
if (assistedWeighingRefUserId != -1) {
ScaleMeasurement lastRefScaleMeasurement = getLastScaleMeasurement(assistedWeighingRefUserId);
if (lastRefScaleMeasurement != null) {
float refWeight = lastRefScaleMeasurement.getWeight();
float diffToRef = scaleMeasurement.getWeight() - refWeight;
scaleMeasurement.setWeight(diffToRef);
}
} else {
Timber.e("assisted weighing reference user id is -1");
}
}
// Calculate the amputation correction factor for the weight, if available
scaleMeasurement.setWeight((scaleMeasurement.getWeight() * 100.0f) / getScaleUser(scaleMeasurement.getUserId()).getAmputationCorrectionFactor());
// If option is enabled then calculate body measurements from generic formulas
MeasurementViewSettings settings = new MeasurementViewSettings(prefs, WaterMeasurementView.KEY);
if (settings.isEnabled() && settings.isEstimationEnabled()) {
EstimatedWaterMetric waterMetric = EstimatedWaterMetric.getEstimatedMetric(
EstimatedWaterMetric.FORMULA.valueOf(settings.getEstimationFormula()));
scaleMeasurement.setWater(waterMetric.getWater(getScaleUser(scaleMeasurement.getUserId()), scaleMeasurement));
}
settings = new MeasurementViewSettings(prefs, FatMeasurementView.KEY);
if (settings.isEnabled() && settings.isEstimationEnabled()) {
EstimatedFatMetric fatMetric = EstimatedFatMetric.getEstimatedMetric(
EstimatedFatMetric.FORMULA.valueOf(settings.getEstimationFormula()));
scaleMeasurement.setFat(fatMetric.getFat(getScaleUser(scaleMeasurement.getUserId()), scaleMeasurement));
}
// Must be after fat estimation as one formula is based on fat
settings = new MeasurementViewSettings(prefs, LBMMeasurementView.KEY);
if (settings.isEnabled() && settings.isEstimationEnabled()) {
EstimatedLBMMetric lbmMetric = EstimatedLBMMetric.getEstimatedMetric(
EstimatedLBMMetric.FORMULA.valueOf(settings.getEstimationFormula()));
scaleMeasurement.setLbm(lbmMetric.getLBM(getScaleUser(scaleMeasurement.getUserId()), scaleMeasurement));
}
// Insert measurement into the database, check return if it was successful inserted
if (measurementDAO.insert(scaleMeasurement) != -1) {
Timber.d("Added measurement: %s", scaleMeasurement);
if (!silent) {
ScaleUser scaleUser = getScaleUser(scaleMeasurement.getUserId());
final java.text.DateFormat dateFormat = DateFormat.getDateFormat(context);
final java.text.DateFormat timeFormat = DateFormat.getTimeFormat(context);
final Date dateTime = scaleMeasurement.getDateTime();
final Converters.WeightUnit unit = scaleUser.getScaleUnit();
String infoText = String.format(context.getString(R.string.info_new_data_added),
Converters.fromKilogram(scaleMeasurement.getWeight(), unit), unit.toString(),
dateFormat.format(dateTime) + " " + timeFormat.format(dateTime),
scaleUser.getUserName());
runUiToastMsg(infoText);
}
syncInsertMeasurement(scaleMeasurement, "com.health.openscale.sync");
syncInsertMeasurement(scaleMeasurement, "com.health.openscale.sync.oss");
alarmHandler.entryChanged(context, scaleMeasurement);
triggerWidgetUpdate();
} else {
Timber.d("to be added measurement is thrown away because measurement with the same date and time already exist");
if (!silent) {
runUiToastMsg(context.getString(R.string.info_new_data_duplicated));
}
}
return scaleMeasurement.getUserId();
}
public int getAssignableUser(float weight){
// Not the best function name
// Returns smart user assignment, if options allow it
// Otherwise it returns the selected user
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
if (prefs.getBoolean("smartUserAssign", false)) {
return getSmartUserAssignment(weight, SMART_USER_ASSIGN_DEFAULT_RANGE);
} else {
return getSelectedScaleUser().getId();
}
}
private int getSmartUserAssignment(float weight, float range) {
List<ScaleUser> scaleUsers = getScaleUserList();
Map<Float, Integer> inRangeWeights = new TreeMap<>();
for (int i = 0; i < scaleUsers.size(); i++) {
List<ScaleMeasurement> scaleUserData = measurementDAO.getAll(scaleUsers.get(i).getId());
float lastWeight;
if (scaleUserData.size() > 0) {
lastWeight = scaleUserData.get(0).getWeight();
} else {
lastWeight = scaleUsers.get(i).getInitialWeight();
}
if ((lastWeight - range) <= weight && (lastWeight + range) >= weight) {
inRangeWeights.put(Math.abs(lastWeight - weight), scaleUsers.get(i).getId());
}
}
if (inRangeWeights.size() > 0) {
// return the user id which is nearest to the weight (first element of the tree map)
int userId = inRangeWeights.entrySet().iterator().next().getValue();
Timber.d("assign measurement to the nearest measurement with the user " + getScaleUser(userId).getUserName() + " (smartUserAssignment=on)");
return userId;
}
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
// if ignore out of range preference is true don't add this data
if (prefs.getBoolean("ignoreOutOfRange", false)) {
Timber.d("to be added measurement is thrown away because measurement is out of range (smartUserAssignment=on;ignoreOutOfRange=on)");
return -1;
}
// return selected scale user id if not out of range preference is checked and weight is out of range of any user
Timber.d("assign measurement to the selected user (smartUserAssignment=on;ignoreOutOfRange=off)");
return getSelectedScaleUser().getId();
}
public void updateScaleMeasurement(ScaleMeasurement scaleMeasurement) {
Timber.d("Update measurement: %s", scaleMeasurement);
measurementDAO.update(scaleMeasurement);
alarmHandler.entryChanged(context, scaleMeasurement);
syncUpdateMeasurement(scaleMeasurement, "com.health.openscale.sync");
syncUpdateMeasurement(scaleMeasurement, "com.health.openscale.sync.oss");
triggerWidgetUpdate();
}
public void deleteScaleMeasurement(int id) {
syncDeleteMeasurement(measurementDAO.get(id).getDateTime(), "com.health.openscale.sync");
syncDeleteMeasurement(measurementDAO.get(id).getDateTime(), "com.health.openscale.sync.oss");
measurementDAO.delete(id);
}
public String getFilenameFromUriMayThrow(Uri uri) {
Cursor cursor = context.getContentResolver().query(
uri, null, null, null, null);
try {
cursor.moveToFirst();
return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
}
finally {
if (cursor != null) {
cursor.close();
}
}
}
public String getFilenameFromUri(Uri uri) {
try {
return getFilenameFromUriMayThrow(uri);
}
catch (Exception e) {
String name = uri.getLastPathSegment();
if (name != null) {
return name;
}
name = uri.getPath();
if (name != null) {
return name;
}
return uri.toString();
}
}
public void importDatabase(Uri importFile) throws IOException {
File exportFile = context.getApplicationContext().getDatabasePath("openScale.db");
File tmpExportFile = context.getApplicationContext().getDatabasePath("openScale_tmp.db");
try {
if (appDB != null) {
appDB.close();
}
copyFile(Uri.fromFile(exportFile), Uri.fromFile(tmpExportFile));
copyFile(importFile, Uri.fromFile(exportFile));
reopenDatabase(false);
getScaleUserList().get(0); // call it to test if the imported database works otherwise a runtime exception is thrown
if (!getScaleUserList().isEmpty()) {
selectScaleUser(getScaleUserList().get(0).getId());
}
} catch (RuntimeException e) {
Timber.d("import database corrupted, restore old database");
File restoreExportFile = context.getApplicationContext().getDatabasePath("openScale_restore.db");
copyFile(Uri.fromFile(tmpExportFile), Uri.fromFile(restoreExportFile));
importDatabase(Uri.fromFile(restoreExportFile));
restoreExportFile.delete();
throw new IOException(e.getMessage());
} finally {
tmpExportFile.delete();
}
}
public void exportDatabase(Uri exportFile) throws IOException {
File dbFile = context.getApplicationContext().getDatabasePath("openScale.db");
reopenDatabase(true); // re-open database without caching sql -shm, -wal files
copyFile(Uri.fromFile(dbFile), exportFile);
}
private void copyFile(Uri src, Uri dst) throws IOException {
InputStream input = context.getContentResolver().openInputStream(src);
OutputStream output = context.getContentResolver().openOutputStream(dst);
try {
byte[] bytes = new byte[4096];
int count;
while ((count = input.read(bytes)) != -1){
output.write(bytes, 0, count);
}
} finally {
if (input != null) {
input.close();
}
if (output != null) {
output.flush();
output.close();
}
}
}
public void importData(Uri uri) {
try {
final String filename = getFilenameFromUri(uri);
InputStream input = context.getContentResolver().openInputStream(uri);
List<ScaleMeasurement> csvScaleMeasurementList =
CsvHelper.importFrom(new BufferedReader(new InputStreamReader(input)));
final int userId = getSelectedScaleUser().getId();
for (ScaleMeasurement measurement : csvScaleMeasurementList) {
measurement.setUserId(userId);
}
measurementDAO.insertAll(csvScaleMeasurementList);
runUiToastMsg(context.getString(R.string.info_data_imported) + " " + filename);
} catch (IOException | ParseException e) {
runUiToastMsg(context.getString(R.string.error_importing) + ": " + e.getMessage());
}
}
public boolean exportData(Uri uri) {
try {
List<ScaleMeasurement> scaleMeasurementList = getScaleMeasurementList();
OutputStream output = context.getContentResolver().openOutputStream(uri);
CsvHelper.exportTo(new OutputStreamWriter(output), scaleMeasurementList);
return true;
} catch (IOException e) {
runUiToastMsg(context.getResources().getString(R.string.error_exporting) + " " + e.getMessage());
}
return false;
}
public void clearScaleMeasurements(int userId) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefs.edit().putInt("uniqueNumber", 0x00).apply();
syncClearMeasurements("com.health.openscale.sync");
syncClearMeasurements("com.health.openscale.sync.oss");
measurementDAO.deleteAll(userId);
}
public int[] getCountsOfMonth(int year) {
int selectedUserId = getSelectedScaleUserId();
int [] numOfMonth = new int[12];
Calendar startCalender = Calendar.getInstance();
Calendar endCalender = Calendar.getInstance();
for (int i=0; i<12; i++) {
startCalender.set(year, i, 1, 0, 0, 0);
endCalender.set(year, i, 1, 0, 0, 0);
endCalender.add(Calendar.MONTH, 1);
numOfMonth[i] = measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId).size();
}
return numOfMonth;
}
public List<ScaleMeasurement> getScaleMeasurementOfStartDate(int year, int month, int day) {
int selectedUserId = getSelectedScaleUserId();
Calendar startCalender = Calendar.getInstance();
Calendar endCalender = Calendar.getInstance();
startCalender.set(year, month, day, 0, 0, 0);
return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId);
}
public List<ScaleMeasurement> getScaleMeasurementOfRangeDates(int startYear, int startMonth, int startDay, int endYear, int endMonth, int endDay) {
int selectedUserId = getSelectedScaleUserId();
Calendar startCalender = Calendar.getInstance();
Calendar endCalender = Calendar.getInstance();
startCalender.set(startYear, startMonth, startDay, 0, 0, 0);
endCalender.set(endYear, endMonth, endDay, 0, 0, 0);
endCalender.add(Calendar.DAY_OF_MONTH, 1);
return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId);
}
public List<ScaleMeasurement> getScaleMeasurementOfDay(int year, int month, int day) {
int selectedUserId = getSelectedScaleUserId();
Calendar startCalender = Calendar.getInstance();
Calendar endCalender = Calendar.getInstance();
startCalender.set(year, month, day, 0, 0, 0);
endCalender.set(year, month, day, 0, 0, 0);
endCalender.add(Calendar.DAY_OF_MONTH, 1);
return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId);
}
public List<ScaleMeasurement> getScaleMeasurementOfMonth(int year, int month) {
int selectedUserId = getSelectedScaleUserId();
Calendar startCalender = Calendar.getInstance();
Calendar endCalender = Calendar.getInstance();
startCalender.set(year, month, 1, 0, 0, 0);
endCalender.set(year, month, 1, 0, 0, 0);
endCalender.add(Calendar.MONTH, 1);
return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId);
}
public List<ScaleMeasurement> getScaleMeasurementOfYear(int year) {
int selectedUserId = getSelectedScaleUserId();
Calendar startCalender = Calendar.getInstance();
Calendar endCalender = Calendar.getInstance();
startCalender.set(year, Calendar.JANUARY, 1, 0, 0, 0);
endCalender.set(year+1, Calendar.JANUARY, 1, 0, 0, 0);
return measurementDAO.getAllInRange(startCalender.getTime(), endCalender.getTime(), selectedUserId);
}
public void connectToBluetoothDeviceDebugMode(String hwAddress, Handler callbackBtHandler) {
Timber.d("Trying to connect to bluetooth device [%s] in debug mode", hwAddress);
disconnectFromBluetoothDevice();
btDeviceDriver = BluetoothFactory.createDebugDriver(context);
btDeviceDriver.registerCallbackHandler(callbackBtHandler);
btDeviceDriver.connect(hwAddress);
}
public boolean connectToBluetoothDevice(String deviceName, String hwAddress, Handler callbackBtHandler) {
Timber.d("Trying to connect to bluetooth device [%s] (%s)", hwAddress, deviceName);
disconnectFromBluetoothDevice();
btDeviceDriver = BluetoothFactory.createDeviceDriver(context, deviceName);
if (btDeviceDriver == null) {
return false;
}
btDeviceDriver.registerCallbackHandler(callbackBtHandler);
btDeviceDriver.connect(hwAddress);
return true;
}
public boolean disconnectFromBluetoothDevice() {
if (btDeviceDriver == null) {
return false;
}
Timber.d("Disconnecting from bluetooth device");
btDeviceDriver.disconnect();
btDeviceDriver = null;
return true;
}
public boolean setBluetoothDeviceUserIndex(int appUserId, int scaleUserIndex, Handler uiHandler) {
if (btDeviceDriver == null) {
return false;
}
btDeviceDriver.selectScaleUserIndexForAppUserId(appUserId, scaleUserIndex, uiHandler);
return true;
}
public boolean setBluetoothDeviceUserConsent(int appUserId, int scaleUserConsent, Handler uiHandler) {
if (btDeviceDriver == null) {
return false;
}
btDeviceDriver.setScaleUserConsent(appUserId, scaleUserConsent, uiHandler);
return true;
}
public LiveData<List<ScaleMeasurement>> getScaleMeasurementsLiveData() {
int selectedUserId = getSelectedScaleUserId();
return measurementDAO.getAllAsLiveData(selectedUserId);
}
// As getScaleUserList(), but as a Cursor for export via a Content Provider.
public Cursor getScaleUserListCursor() {
return userDAO.selectAll();
}
// As getScaleMeasurementList(), but as a Cursor for export via a Content Provider.
public Cursor getScaleMeasurementListCursor(long userId) {
return measurementDAO.selectAll(userId);
}
private void runUiToastMsg(String text) {
Handler handler = new Handler(Looper.getMainLooper());
handler.post(new Runnable() {
@Override
public void run() {
Toast.makeText(context, text, Toast.LENGTH_SHORT).show();
}
});
}
private void syncInsertMeasurement(ScaleMeasurement scaleMeasurement, String pkgName) {
Intent intent = new Intent();
intent.setComponent(new ComponentName(pkgName, pkgName + ".core.service.SyncService"));
intent.putExtra("mode", "insert");
intent.putExtra("userId", scaleMeasurement.getUserId());
intent.putExtra("weight", scaleMeasurement.getWeight());
intent.putExtra("fat", scaleMeasurement.getFat());
intent.putExtra("water", scaleMeasurement.getWater());
intent.putExtra("muscle", scaleMeasurement.getMuscle());
intent.putExtra("date", scaleMeasurement.getDateTime().getTime());
ContextCompat.startForegroundService(context, intent);
}
private void syncUpdateMeasurement(ScaleMeasurement scaleMeasurement, String pkgName) {
Intent intent = new Intent();
intent.setComponent(new ComponentName(pkgName, pkgName + ".core.service.SyncService"));
intent.putExtra("mode", "update");
intent.putExtra("userId", scaleMeasurement.getUserId());
intent.putExtra("weight", scaleMeasurement.getWeight());
intent.putExtra("fat", scaleMeasurement.getFat());
intent.putExtra("water", scaleMeasurement.getWater());
intent.putExtra("muscle", scaleMeasurement.getMuscle());
intent.putExtra("date", scaleMeasurement.getDateTime().getTime());
ContextCompat.startForegroundService(context, intent);
}
private void syncDeleteMeasurement(Date date, String pkgName) {
Intent intent = new Intent();
intent.setComponent(new ComponentName(pkgName, pkgName + ".core.service.SyncService"));
intent.putExtra("mode", "delete");
intent.putExtra("date", date.getTime());
ContextCompat.startForegroundService(context, intent);
}
private void syncClearMeasurements(String pkgName) {
Intent intent = new Intent();
intent.setComponent(new ComponentName(pkgName, pkgName + ".core.service.SyncService"));
intent.putExtra("mode", "clear");
ContextCompat.startForegroundService(context, intent);
}
public ScaleMeasurementDAO getScaleMeasurementDAO() {
return measurementDAO;
}
public ScaleUserDAO getScaleUserDAO() {
return userDAO;
}
}

View File

@@ -1,127 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.alarm;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.preference.PreferenceManager;
import androidx.documentfile.provider.DocumentFile;
import com.health.openscale.core.OpenScale;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import timber.log.Timber;
public class AlarmBackupHandler
{
public static final String INTENT_EXTRA_BACKUP_ALARM = "alarmBackupIntent";
private static final int ALARM_NOTIFICATION_ID = 0x02;
public void scheduleAlarms(Context context)
{
disableAlarm(context);
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
if (prefs.getBoolean("autoBackup", true)) {
String backupSchedule = prefs.getString("autoBackup_Schedule", "Monthly");
long intervalDayMultiplicator = 0;
switch (backupSchedule) {
case "Daily":
intervalDayMultiplicator = 1;
break;
case "Weekly":
intervalDayMultiplicator = 7;
break;
case "Monthly":
intervalDayMultiplicator = 30;
break;
}
PendingIntent alarmPendingIntent = getPendingAlarmIntent(context);
AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
alarmMgr.setInexactRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis(),
AlarmManager.INTERVAL_DAY * intervalDayMultiplicator, alarmPendingIntent);
}
}
private PendingIntent getPendingAlarmIntent(Context context)
{
Intent alarmIntent = new Intent(context, ReminderBootReceiver.class);
alarmIntent.putExtra(INTENT_EXTRA_BACKUP_ALARM, true);
return PendingIntent.getBroadcast(context, ALARM_NOTIFICATION_ID, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
public void disableAlarm(Context context) {
AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
alarmMgr.cancel(getPendingAlarmIntent(context));
}
public void executeBackup(Context context) {
OpenScale openScale = OpenScale.getInstance();
String databaseName = "openScale.db";
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
// Extra check that there is a backupDir saved in settings
String backupDirString = prefs.getString("backupDir", null);
if (backupDirString == null) {
return;
}
DocumentFile backupDir = DocumentFile.fromTreeUri(context, Uri.parse(backupDirString));
// Check if it is possible to read and write to auto export dir
// If it is not possible auto backup function will be disabled
if (!backupDir.canRead() || !backupDir.canWrite()) {
SharedPreferences.Editor editor = prefs.edit();
editor.putBoolean("autoBackup", false);
editor.apply();
return;
}
if (!prefs.getBoolean("overwriteBackup", false)) {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
databaseName = dateFormat.format(new Date()) + "_" + databaseName;
}
DocumentFile exportFile = backupDir.findFile(databaseName);
if (exportFile == null) {
exportFile = backupDir.createFile("application/x-sqlite3", databaseName);
}
try {
openScale.exportDatabase(exportFile.getUri());
Timber.d("openScale Auto Backup to %s", exportFile);
} catch (IOException e) {
Timber.e(e, "Error while exporting database");
}
}
}

View File

@@ -1,90 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.alarm;
import java.util.Calendar;
public class AlarmEntry implements Comparable<AlarmEntry>
{
private final int dayOfWeek;
private final long timeInMillis;
public AlarmEntry(int dayOfWeek, long timeInMillis)
{
this.dayOfWeek = dayOfWeek;
this.timeInMillis = timeInMillis;
}
public int getDayOfWeek()
{
return dayOfWeek;
}
private long getTimeInMillis()
{
return timeInMillis;
}
public Calendar getNextTimestamp()
{
// We just want the time *not* the date
Calendar nextAlarmTimestamp = Calendar.getInstance();
nextAlarmTimestamp.setTimeInMillis(getTimeInMillis());
Calendar alarmCal = Calendar.getInstance();
alarmCal.set(Calendar.HOUR_OF_DAY, nextAlarmTimestamp.get(Calendar.HOUR_OF_DAY));
alarmCal.set(Calendar.MINUTE, nextAlarmTimestamp.get(Calendar.MINUTE));
alarmCal.set(Calendar.SECOND, 0);
alarmCal.set(Calendar.DAY_OF_WEEK, getDayOfWeek());
// Check we aren't setting it in the past which would trigger it to fire instantly
if (alarmCal.before(Calendar.getInstance())) alarmCal.add(Calendar.DAY_OF_YEAR, 7);
return alarmCal;
}
@Override
public boolean equals(Object o)
{
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AlarmEntry that = (AlarmEntry) o;
if (dayOfWeek != that.dayOfWeek) return false;
return timeInMillis == that.timeInMillis;
}
@Override
public int hashCode()
{
int result = dayOfWeek;
result = 31 * result + (int) (timeInMillis ^ (timeInMillis >>> 32));
return result;
}
@Override
public int compareTo(AlarmEntry o)
{
int rc = compare(dayOfWeek, o.dayOfWeek);
if (rc == 0) rc = compare(timeInMillis, o.timeInMillis);
return rc;
}
private int compare(long x, long y)
{
return (x < y) ? -1 : ((x == y) ? 0 : 1);
}
}

View File

@@ -1,106 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.alarm;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import com.health.openscale.R;
import java.util.Calendar;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import static com.health.openscale.gui.preferences.ReminderPreferences.PREFERENCE_KEY_REMINDER_NOTIFY_TEXT;
import static com.health.openscale.gui.preferences.ReminderPreferences.PREFERENCE_KEY_REMINDER_TIME;
import static com.health.openscale.gui.preferences.ReminderPreferences.PREFERENCE_KEY_REMINDER_WEEKDAYS;
public class AlarmEntryReader
{
private Set<AlarmEntry> alarmEntries;
private String alarmNotificationText;
private AlarmEntryReader(Set<AlarmEntry> alarmEntries, String alarmNotificationText)
{
this.alarmEntries = alarmEntries;
this.alarmNotificationText = alarmNotificationText;
}
public Set<AlarmEntry> getEntries()
{
return alarmEntries;
}
public String getNotificationText()
{
return alarmNotificationText;
}
public static AlarmEntryReader construct(Context context)
{
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
Set<String> reminderWeekdays = prefs.getStringSet(PREFERENCE_KEY_REMINDER_WEEKDAYS, new HashSet<String>());
Long reminderTimeInMillis = prefs.getLong(PREFERENCE_KEY_REMINDER_TIME, System.currentTimeMillis());
String notifyText = prefs.getString(PREFERENCE_KEY_REMINDER_NOTIFY_TEXT,
context.getResources().getString(R.string.default_value_reminder_notify_text));
Set<AlarmEntry> alarms = new TreeSet<>();
for (String dayOfWeek : reminderWeekdays)
{
AlarmEntry alarm = getAlarmEntry(dayOfWeek, reminderTimeInMillis);
alarms.add(alarm);
}
return new AlarmEntryReader(alarms, notifyText);
}
private static AlarmEntry getAlarmEntry(String dayOfWeek, Long reminderTimeInMillis)
{
AlarmEntry alarmEntry;
switch (dayOfWeek)
{
case "Monday":
alarmEntry = new AlarmEntry(Calendar.MONDAY, reminderTimeInMillis);
break;
case "Tuesday":
alarmEntry = new AlarmEntry(Calendar.TUESDAY, reminderTimeInMillis);
break;
case "Wednesday":
alarmEntry = new AlarmEntry(Calendar.WEDNESDAY, reminderTimeInMillis);
break;
case "Thursday":
alarmEntry = new AlarmEntry(Calendar.THURSDAY, reminderTimeInMillis);
break;
case "Friday":
alarmEntry = new AlarmEntry(Calendar.FRIDAY, reminderTimeInMillis);
break;
case "Saturday":
alarmEntry = new AlarmEntry(Calendar.SATURDAY, reminderTimeInMillis);
break;
default:
case "Sunday":
alarmEntry = new AlarmEntry(Calendar.SUNDAY, reminderTimeInMillis);
break;
}
return alarmEntry;
}
}

View File

@@ -1,195 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.alarm;
import static android.content.Context.NOTIFICATION_SERVICE;
import android.app.AlarmManager;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.service.notification.StatusBarNotification;
import androidx.core.app.NotificationCompat;
import com.health.openscale.R;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.gui.MainActivity;
import java.util.Calendar;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import timber.log.Timber;
public class AlarmHandler
{
public static final String INTENT_EXTRA_ALARM = "alarmIntent";
private static final int ALARM_NOTIFICATION_ID = 0x01;
public void scheduleAlarms(Context context)
{
AlarmEntryReader reader = AlarmEntryReader.construct(context);
Set<AlarmEntry> alarmEntries = reader.getEntries();
disableAllAlarms(context);
enableAlarms(context, alarmEntries);
}
public void entryChanged(Context context, ScaleMeasurement data)
{
long dataMillis = data.getDateTime().getTime();
Calendar dataTimestamp = Calendar.getInstance();
dataTimestamp.setTimeInMillis(dataMillis);
if (AlarmHandler.isSameDate(dataTimestamp, Calendar.getInstance()))
{
cancelAlarmNotification(context);
cancelAndRescheduleAlarmForNextWeek(context, dataTimestamp);
}
}
private static boolean isSameDate(Calendar c1, Calendar c2)
{
int[] dateFields = {Calendar.YEAR, Calendar.MONTH, Calendar.DAY_OF_MONTH};
for (int dateField : dateFields)
{
if (c1.get(dateField) != c2.get(dateField)) return false;
}
return true;
}
private void enableAlarms(Context context, Set<AlarmEntry> alarmEntries)
{
for (AlarmEntry alarmEntry : alarmEntries)
enableAlarm(context, alarmEntry);
}
private void enableAlarm(Context context, AlarmEntry alarmEntry)
{
int dayOfWeek = alarmEntry.getDayOfWeek();
Calendar nextAlarmTimestamp = alarmEntry.getNextTimestamp();
setRepeatingAlarm(context, dayOfWeek, nextAlarmTimestamp);
}
private void setRepeatingAlarm(Context context, int dayOfWeek, Calendar nextAlarmTimestamp)
{
Timber.d("Set repeating alarm for %s", nextAlarmTimestamp.getTime());
PendingIntent alarmPendingIntent = getPendingAlarmIntent(context, dayOfWeek);
AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
alarmMgr.setInexactRepeating(AlarmManager.RTC_WAKEUP, nextAlarmTimestamp.getTimeInMillis(),
AlarmManager.INTERVAL_DAY * 7, alarmPendingIntent);
}
private List<PendingIntent> getWeekdaysPendingAlarmIntent(Context context)
{
final int[] dayOfWeeks =
{Calendar.MONDAY, Calendar.TUESDAY, Calendar.WEDNESDAY, Calendar.THURSDAY, Calendar.FRIDAY,
Calendar.SATURDAY, Calendar.SUNDAY};
List<PendingIntent> pendingIntents = new LinkedList<>();
for (int dayOfWeek : dayOfWeeks)
pendingIntents.add(getPendingAlarmIntent(context, dayOfWeek));
return pendingIntents;
}
private PendingIntent getPendingAlarmIntent(Context context, int dayOfWeek)
{
Intent alarmIntent = new Intent(context, ReminderBootReceiver.class);
alarmIntent.putExtra(INTENT_EXTRA_ALARM, true);
return PendingIntent.getBroadcast(context, dayOfWeek, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
public void disableAllAlarms(Context context)
{
AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
List<PendingIntent> pendingIntents = getWeekdaysPendingAlarmIntent(context);
for (PendingIntent pendingIntent : pendingIntents)
alarmMgr.cancel(pendingIntent);
}
private void cancelAndRescheduleAlarmForNextWeek(Context context, Calendar timestamp)
{
AlarmEntryReader reader = AlarmEntryReader.construct(context);
Set<AlarmEntry> alarmEntries = reader.getEntries();
for (AlarmEntry entry : alarmEntries)
{
Calendar nextAlarmTimestamp = entry.getNextTimestamp();
if (isSameDate(timestamp, nextAlarmTimestamp))
{
int dayOfWeek = entry.getDayOfWeek();
PendingIntent alarmPendingIntent = getPendingAlarmIntent(context, dayOfWeek);
AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
alarmMgr.cancel(alarmPendingIntent);
nextAlarmTimestamp.add(Calendar.DATE, 7);
setRepeatingAlarm(context, dayOfWeek, nextAlarmTimestamp);
}
}
}
public void showAlarmNotification(Context context)
{
AlarmEntryReader reader = AlarmEntryReader.construct(context);
String notifyText = reader.getNotificationText();
Intent notifyIntent = new Intent(context, MainActivity.class);
PendingIntent notifyPendingIntent =
PendingIntent.getActivity(context, 0, notifyIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "openScale_notify");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = new NotificationChannel(
"openScale_notify",
"openScale weight notification",
NotificationManager.IMPORTANCE_DEFAULT);
notificationManager.createNotificationChannel(channel);
}
Notification notification = mBuilder.setSmallIcon(R.drawable.ic_notification_openscale_monochrome)
.setLargeIcon(BitmapFactory.decodeResource(context.getResources(), R.drawable.ic_launcher_openscale))
.setContentTitle(context.getString(R.string.app_name))
.setContentText(notifyText)
.setAutoCancel(true)
.setContentIntent(notifyPendingIntent)
.build();
NotificationManager mNotifyMgr = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE);
mNotifyMgr.notify(ALARM_NOTIFICATION_ID, notification);
}
private void cancelAlarmNotification(Context context)
{
NotificationManager mNotifyMgr = (NotificationManager) context.getSystemService(NOTIFICATION_SERVICE);
StatusBarNotification[] activeNotifications = mNotifyMgr.getActiveNotifications();
for (StatusBarNotification notification : activeNotifications) {
if (notification.getId() == ALARM_NOTIFICATION_ID) mNotifyMgr.cancel(ALARM_NOTIFICATION_ID);
}
}
}

View File

@@ -1,55 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.alarm;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public class ReminderBootReceiver extends BroadcastReceiver
{
@Override
public void onReceive(Context context, Intent intent)
{
if (intent.hasExtra(AlarmHandler.INTENT_EXTRA_ALARM)) handleAlarm(context);
if (intent.hasExtra(AlarmBackupHandler.INTENT_EXTRA_BACKUP_ALARM)) handleBackupAlarm(context);
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) scheduleAlarms(context);
}
private void handleAlarm(Context context)
{
AlarmHandler alarmHandler = new AlarmHandler();
alarmHandler.showAlarmNotification(context);
}
private void handleBackupAlarm(Context context)
{
AlarmBackupHandler alarmBackupHandler = new AlarmBackupHandler();
alarmBackupHandler.executeBackup(context);
}
private void scheduleAlarms(Context context)
{
AlarmHandler alarmHandler = new AlarmHandler();
AlarmBackupHandler alarmBackupHandler = new AlarmBackupHandler();
alarmHandler.scheduleAlarms(context);
alarmBackupHandler.scheduleAlarms(context);
}
}

View File

@@ -1,326 +0,0 @@
/* Copyright (C) 2024 olie.xdev <olie.xdev@googlemail.com>
* 2024 Duncan Overbruck <mail@duncano.de>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.nio.ByteBuffer;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import timber.log.Timber;
/**
* Support for Active Era BS-06 scales
*
* based on reverse-engineered BLE protocol known as `ICBleProtocolVerScaleNew2` from the vendor APP
*/
public class BluetoothActiveEraBF06 extends BluetoothCommunication {
private static final byte MAGIC_BYTE = (byte) 0xAC;
private static final byte DEVICE_TYPE = (byte) 0x27;
private final UUID MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffb0);
private final UUID WRITE_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffb1);
private final UUID NOTIFICATION_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffb2);
private boolean weightStabilized = false;
private float stableWeightKg = 0.0f;
private boolean isSupportPH = false;
private boolean isSupportHR = false;
private boolean balanceStabilized = false;
private float stableBalanceL = 0.0f;
private double impedance = 0.0f;
private ScaleMeasurement scaleData;
public BluetoothActiveEraBF06(Context context) {
super(context);
}
private byte[] getConfigurationPacket() {
// current time
long now = Instant.now().toEpochMilli() / 1000;
byte[] time = Converters.toInt32Be(now);
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
int height = (int) Math.ceil(selectedUser.getBodyHeight());
int age = selectedUser.getAge();
int gender = selectedUser.getGender() == Converters.Gender.FEMALE ? 0x02 : 0x01;
int units = 0; // KG
switch(selectedUser.getScaleUnit()) {
case LB:
units = 1;
break;
case ST:
units = 2;
break;
};
int initialWeight = (int) Math.ceil(selectedUser.getInitialWeight() * 100);
byte[] initialWeightBytes = Converters.toInt16Be(initialWeight);
byte[] targetWeightBytes;
float goalWeight = selectedUser.getGoalWeight();
if (goalWeight > -1) {
int targetWeight = (int) Math.ceil(goalWeight * 100);
targetWeightBytes = Converters.toInt16Be(targetWeight);
} else {
targetWeightBytes = initialWeightBytes;
}
byte[] configBytes = new byte[]{
/* 0x00 */ MAGIC_BYTE,
/* 0x01 */ DEVICE_TYPE,
/* 0x02 */ time[0],
/* 0x03 */ time[1],
/* 0x04 */ time[2],
/* 0x05 */ time[3],
/* 0x06 */ 0x04,
/* 0x07 */ (byte)units,
/* 0x08 */ 0x01, // user id ?
/* 0x09 */ (byte)(height & 0xFF),
/* 0x0a */ initialWeightBytes[0],
/* 0x0b */ initialWeightBytes[1],
/* 0x0c */ (byte)(age & 0xFF),
/* 0x0d */ (byte)gender,
/* 0x0e */ targetWeightBytes[0],
/* 0x0f */ targetWeightBytes[1],
/* 0x10 */ 0x03,
/* 0x11 */ 0x00,
/* 0x12 */ (byte)0xd0,
/* 0x13 */ (byte)0x00 // checksum
};
return withCorrectCS(configBytes);
}
private void sendConfigurationPacket() {
byte[] packet = getConfigurationPacket();
Timber.d("sending configuration packet: %s", byteInHex(packet));
writeBytes(MEASUREMENT_SERVICE, WRITE_CHARACTERISTIC, packet);
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
decodePacket(value);
}
@Override
public String driverName() {
return "Active Era BF-06";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
//Tell device to send us measurements
setNotificationOn(MEASUREMENT_SERVICE, NOTIFICATION_CHARACTERISTIC);
// reset old values
stableWeightKg = 0.0f;
stableBalanceL = 0.0f;
impedance = 0;
weightStabilized = false;
balanceStabilized = false;
scaleData = new ScaleMeasurement();
break;
case 1:
sendConfigurationPacket();
break;
case 2: // weighting ...
sendMessage(R.string.info_step_on_scale, 0);
stopMachineState();
break;
case 3: // weighted ! measuring balance ...
stopMachineState();
break;
case 4: // balanced ! reporting ADC and measuring HR ...
stopMachineState();
break;
case 5: // HR measured! Maybe some historical will follow
Timber.i("Measuring all done!");
scaleData.setDateTime(Calendar.getInstance().getTime());
addScaleMeasurement(scaleData);
default:
return false;
}
return true;
}
private void decodePacket(byte[] pkt) {
if (pkt == null) {
return;
} else if (pkt[0] != MAGIC_BYTE) {
Timber.w("Wrong packet MAGIC");
return;
} else if (pkt.length != 20) {
Timber.w("Wrong packet length %s expected 20", pkt.length);
return;
}
int packetType = pkt[0x12] & 0xFF;
switch (packetType) {
case 0xD5: // weight measurement
byte flags = pkt[0x02];
boolean stabilized = isBitSet(flags, 8);
isSupportHR = isBitSet(flags, 2);
isSupportPH = isBitSet(flags, 3);
float weightKg = (Converters.fromUnsignedInt24Be(pkt, 3) & 0x3FFFF) / 1000.0f;
// TODO: test if it's always in grams ?
if (stabilized && !weightStabilized) {
weightStabilized = true;
stableWeightKg = weightKg;
Timber.i("Measured weight (stable): %.3f", stableWeightKg);
scaleData.setWeight(weightKg);
resumeMachineState();
}
break;
case 0xD0: // balance measuring
byte state = pkt[0x02];
boolean isFinal = state == 0x01;
int weightLRaw = Converters.fromUnsignedInt16Be(pkt, 3);
int percentLRaw = Converters.fromUnsignedInt16Be(pkt, 5);
float weightL = (float)weightLRaw / 100.0f;
float percentL = (float)percentLRaw / 10.0f;
if (isFinal && !balanceStabilized) {
balanceStabilized = true;
stableBalanceL = percentL;
Timber.i("Measured balance (stable): L %.1f R: %.1f [%.2f]", percentL, 100.0f - percentL, weightL);
resumeMachineState();
}
break;
case 0xD6: // reporting ADCs
byte number = pkt[0x02];
if (number == 1) {
double imp = Converters.fromUnsignedInt16Be(pkt, 4);
if (imp >= 1500.0d) {
imp = (((imp - 1000.0d) + ((stableWeightKg * 10.0d) * (-0.4d))) / 0.6d) / 10.0d;
}
impedance = imp;
Timber.i("Measured impedance: %.1f", impedance);
// calculate BIA using measure weight and impedance
if (impedance > 0.0) {
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
int height = (int) Math.ceil(selectedUser.getBodyHeight());
int age = selectedUser.getAge();
int gender = selectedUser.getGender() == Converters.Gender.FEMALE ? 0 : 1;
calculateBIA(height, impedance, stableWeightKg, age, gender);
// TODO: report results
}
} else {
Timber.w("Unsupported number of ADCs: %s", number);
}
stopMachineState();
break;
case 0xD7: // HR measured
int hr = pkt[0x03] & 0xff;
Timber.i("Measured heart rate: %d", hr);
resumeMachineState();
break;
case 0xD8: // historical measurement
parseHistoricalPacket(pkt);
default:
Timber.w("Unsupported packet [%d]: %s", packetType, byteInHex(pkt));
}
}
private byte[] withCorrectCS(byte[] pkt) {
byte[] fixed = Arrays.copyOf(pkt, pkt.length);
fixed[fixed.length - 1] = sumChecksum(fixed, 2, fixed.length - 3);
return fixed;
}
/**
* Calculate BIA parameters
* for now, using forumlas from
* <a href="https://isn.ucsd.edu/courses/beng186b/project/2021/Raj_Sunku_Tsujimoto_Measuring_body_composition_via_body_impedance.pdf">paper</a>
*
* TODO: replace with reverse-engineered library version
*
* @param heightCm
* @param impedanceOhm
* @param weightKg
* @param age - in years
* @param gender - 0 - female, 1 - male
*/
private void calculateBIA(int heightCm, double impedanceOhm, float weightKg, int age, int gender) {
// FFM = 0.36(H2/Z) + 0.162H + 0.289W 0.134A + 4.83G 6.83
double fatFreeMass = (0.36d * (Math.pow(heightCm, 2) / impedanceOhm))
+ (0.162d * heightCm)
+ (0.289d * weightKg)
- (0.134 * age)
+ (4.83 * gender)
- 6.83;
double fatMass = weightKg - fatFreeMass;
double bodyFat = fatMass / weightKg * 100.0;
Timber.i("FFM: %.2f, FM: %.2f, BF: %.1f%%", fatFreeMass, fatMass, bodyFat);
}
private void parseHistoricalPacket(byte[] pkt) {
Instant time = Instant.ofEpochSecond(Converters.fromUnsignedInt24Be(pkt, 3));
float weight = (Converters.fromUnsignedInt24Be(pkt, 0x08) & 0x03FFFF) / 1000.0f;
float weightLeft = Converters.fromUnsignedInt16Be(pkt, 0x0b) / 100.0f;
int hr = pkt[0x0d] & 0xff;
int adc = Converters.fromUnsignedInt16Be(pkt, 0x0f);
Timber.i("Historical measurement: %.3f kg, Weight Left: %.2f kg, HR: %d, ADC: %d", weight, weightLeft, hr, adc);
// TODO: store historical results
}
}

View File

@@ -1,153 +0,0 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
/*
* Based on source-code by weliem/blessed-android
*/
package com.health.openscale.core.bluetooth;
import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT16;
import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8;
import android.content.Context;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.welie.blessed.BluetoothBytesParser;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothBeurerBF105 extends BluetoothStandardWeightProfile {
private static final UUID SERVICE_BF105_CUSTOM = BluetoothGattUuid.fromShortCode(0xffff);
private static final UUID SERVICE_BF105_IMG = BluetoothGattUuid.fromShortCode(0xffc0);
private static final UUID CHARACTERISTIC_SCALE_SETTINGS = BluetoothGattUuid.fromShortCode(0x0000);
private static final UUID CHARACTERISTIC_USER_LIST = BluetoothGattUuid.fromShortCode(0x0001);
private static final UUID CHARACTERISTIC_INITIALS = BluetoothGattUuid.fromShortCode(0x0002);
private static final UUID CHARACTERISTIC_TARGET_WEIGHT = BluetoothGattUuid.fromShortCode(0x0003);
private static final UUID CHARACTERISTIC_ACTIVITY_LEVEL = BluetoothGattUuid.fromShortCode(0x0004);
private static final UUID CHARACTERISTIC_REFER_WEIGHT_BF = BluetoothGattUuid.fromShortCode(0x000b);
private static final UUID CHARACTERISTIC_BT_MODULE = BluetoothGattUuid.fromShortCode(0x0005);
private static final UUID CHARACTERISTIC_TAKE_MEASUREMENT = BluetoothGattUuid.fromShortCode(0x0006);
private static final UUID CHARACTERISTIC_TAKE_GUEST_MEASUREMENT = BluetoothGattUuid.fromShortCode(0x0007);
private static final UUID CHARACTERISTIC_BEURER_I = BluetoothGattUuid.fromShortCode(0x0008);
private static final UUID CHARACTERISTIC_UPPER_LOWER_BODY = CHARACTERISTIC_BEURER_I;
private static final UUID CHARACTERISTIC_BEURER_II = BluetoothGattUuid.fromShortCode(0x0009);
private static final UUID CHARACTERISTIC_BEURER_III = BluetoothGattUuid.fromShortCode(0x000a);
private static final UUID CHARACTERISTIC_IMG_IDENTIFY = BluetoothGattUuid.fromShortCode(0xffc1);
private static final UUID CHARACTERISTIC_IMG_BLOCK = BluetoothGattUuid.fromShortCode(0xffc2);
public BluetoothBeurerBF105(Context context) {
super(context);
}
@Override
public String driverName() {
return "Beurer BF105/720";
}
@Override
protected int getVendorSpecificMaxUserCount() {
return 10;
}
@Override
protected void writeUserDataToScale() {
writeTargetWeight();
super.writeUserDataToScale();
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
if (characteristic.equals(CHARACTERISTIC_USER_LIST)) {
handleVendorSpecificUserList(value);
}
else {
super.onBluetoothNotify(characteristic, value);
}
}
@Override
protected ScaleMeasurement bodyCompositionMeasurementToScaleMeasurement(byte[] value) {
ScaleMeasurement measurement = super.bodyCompositionMeasurementToScaleMeasurement(value);
float weight = measurement.getWeight();
if (weight == 0.f && previousMeasurement != null) {
weight = previousMeasurement.getWeight();
}
if (weight != 0.f) {
float water = Math.round(((measurement.getWater() / weight) * 10000.f))/100.f;
measurement.setWater(water);
}
return measurement;
}
@Override
protected void setNotifyVendorSpecificUserList() {
if (setNotificationOn(SERVICE_BF105_CUSTOM, CHARACTERISTIC_USER_LIST)) {
Timber.d("setNotifyVendorSpecificUserList() OK");
}
else {
Timber.d("setNotifyVendorSpecificUserList() FAILED");
}
}
@Override
protected synchronized void requestVendorSpecificUserList() {
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setIntValue(0, FORMAT_UINT8);
writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_USER_LIST,
parser.getValue());
}
@Override
protected void writeActivityLevel() {
BluetoothBytesParser parser = new BluetoothBytesParser();
int activityLevel = this.selectedUser.getActivityLevel().toInt() + 1;
Timber.d(String.format("activityLevel: %d", activityLevel));
parser.setIntValue(activityLevel, FORMAT_UINT8);
writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_ACTIVITY_LEVEL,
parser.getValue());
}
protected void writeTargetWeight() {
BluetoothBytesParser parser = new BluetoothBytesParser();
int targetWeight = (int) this.selectedUser.getGoalWeight();
parser.setIntValue(targetWeight, FORMAT_UINT16);
writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_TARGET_WEIGHT,
parser.getValue());
}
@Override
protected void writeInitials() {
BluetoothBytesParser parser = new BluetoothBytesParser();
String initials = getInitials(this.selectedUser.getUserName());
Timber.d("Initials: " + initials);
parser.setString(initials);
writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_INITIALS,
parser.getValue());
}
@Override
protected synchronized void requestMeasurement() {
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setIntValue(0, FORMAT_UINT8);
writeBytes(SERVICE_BF105_CUSTOM, CHARACTERISTIC_TAKE_MEASUREMENT,
parser.getValue());
}
}

View File

@@ -1,121 +0,0 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
/*
* Based on source-code by weliem/blessed-android
*/
package com.health.openscale.core.bluetooth;
import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8;
import android.content.Context;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.utils.Converters;
import com.welie.blessed.BluetoothBytesParser;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothBeurerBF500 extends BluetoothStandardWeightProfile {
private static final UUID SERVICE_BEURER_CUSTOM_BF500 = BluetoothGattUuid.fromShortCode(0xffff);
private static final UUID CHARACTERISTIC_BEURER_BF500_SCALE_SETTING = BluetoothGattUuid.fromShortCode(0xfff0);
private static final UUID CHARACTERISTIC_BEURER_BF500_USER_LIST = BluetoothGattUuid.fromShortCode(0xfff1);
private static final UUID CHARACTERISTIC_BEURER_BF500_ACTIVITY_LEVEL = BluetoothGattUuid.fromShortCode(0xfff2);
private static final UUID CHARACTERISTIC_BEURER_BF500_TAKE_MEASUREMENT = BluetoothGattUuid.fromShortCode(0xfff4);
private static final UUID CHARACTERISTIC_BEURER_BF500_REFER_WEIGHT_BF = BluetoothGattUuid.fromShortCode(0xfff5);
private String deviceName;
public BluetoothBeurerBF500(Context context, String name) {
super(context);
deviceName = name;
}
@Override
public String driverName() {
return "Beurer " + deviceName;
}
@Override
protected int getVendorSpecificMaxUserCount() {
return 8;
}
@Override
protected void writeActivityLevel() {
Converters.ActivityLevel al = selectedUser.getActivityLevel();
BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0});
parser.setIntValue(al.toInt() + 1, FORMAT_UINT8, 0);
Timber.d(String.format("setCurrentUserData Activity level: %d", al.toInt() + 1));
writeBytes(SERVICE_BEURER_CUSTOM_BF500,
CHARACTERISTIC_BEURER_BF500_ACTIVITY_LEVEL, parser.getValue());
}
@Override
protected ScaleMeasurement bodyCompositionMeasurementToScaleMeasurement(byte[] value) {
ScaleMeasurement measurement = super.bodyCompositionMeasurementToScaleMeasurement(value);
float weight = measurement.getWeight();
if (weight == 0.f && previousMeasurement != null) {
weight = previousMeasurement.getWeight();
}
if (weight != 0.f) {
float water = Math.round(((measurement.getWater() / weight) * 10000.f))/100.f;
measurement.setWater(water);
}
return measurement;
}
@Override
protected void requestMeasurement() {
BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0});
parser.setIntValue(0x00, FORMAT_UINT8, 0);
Timber.d(String.format("requestMeasurement BEURER 0xFFF4 magic: 0x00"));
writeBytes(SERVICE_BEURER_CUSTOM_BF500,
CHARACTERISTIC_BEURER_BF500_TAKE_MEASUREMENT, parser.getValue());
}
@Override
protected void setNotifyVendorSpecificUserList() {
if (setNotificationOn(SERVICE_BEURER_CUSTOM_BF500,
CHARACTERISTIC_BEURER_BF500_USER_LIST)) {
Timber.d("setNotifyVendorSpecificUserList() OK");
}
else {
Timber.d("setNotifyVendorSpecificUserList() FAILED");
}
}
@Override
protected synchronized void requestVendorSpecificUserList() {
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setIntValue(0x00, FORMAT_UINT8);
writeBytes(SERVICE_BEURER_CUSTOM_BF500, CHARACTERISTIC_BEURER_BF500_USER_LIST,
parser.getValue());
stopMachineState();
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
if (characteristic.equals(CHARACTERISTIC_BEURER_BF500_USER_LIST)) {
handleVendorSpecificUserList(value);
}
else {
super.onBluetoothNotify(characteristic, value);
}
}
}

View File

@@ -1,119 +0,0 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
/*
* Based on source-code by weliem/blessed-android
*/
package com.health.openscale.core.bluetooth;
import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8;
import android.content.Context;
import com.health.openscale.core.utils.Converters;
import com.welie.blessed.BluetoothBytesParser;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothBeurerBF600 extends BluetoothStandardWeightProfile {
private static final UUID SERVICE_BEURER_CUSTOM_BF600 = BluetoothGattUuid.fromShortCode(0xfff0);
private static final UUID CHARACTERISTIC_BEURER_BF600_SCALE_SETTING = BluetoothGattUuid.fromShortCode(0xfff1);
private static final UUID CHARACTERISTIC_BEURER_BF600_USER_LIST = BluetoothGattUuid.fromShortCode(0xfff2);
private static final UUID CHARACTERISTIC_BEURER_BF600_ACTIVITY_LEVEL = BluetoothGattUuid.fromShortCode(0xfff3);
private static final UUID CHARACTERISTIC_BEURER_BF600_TAKE_MEASUREMENT = BluetoothGattUuid.fromShortCode(0xfff4);
private static final UUID CHARACTERISTIC_BEURER_BF600_REFER_WEIGHT_BF = BluetoothGattUuid.fromShortCode(0xfff5);
private static final UUID CHARACTERISTIC_BEURER_BF850_INITIALS = BluetoothGattUuid.fromShortCode(0xfff6);
private String deviceName;
public BluetoothBeurerBF600(Context context, String name) {
super(context);
deviceName = name;
}
@Override
public String driverName() {
return "Beurer " + deviceName;
}
@Override
protected int getVendorSpecificMaxUserCount() {
return 8;
}
@Override
protected void writeActivityLevel() {
Converters.ActivityLevel al = selectedUser.getActivityLevel();
BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0});
parser.setIntValue(al.toInt() + 1, FORMAT_UINT8, 0);
Timber.d(String.format("setCurrentUserData Activity level: %d", al.toInt() + 1));
writeBytes(SERVICE_BEURER_CUSTOM_BF600,
CHARACTERISTIC_BEURER_BF600_ACTIVITY_LEVEL, parser.getValue());
}
@Override
protected void writeInitials() {
if (haveCharacteristic(SERVICE_BEURER_CUSTOM_BF600, CHARACTERISTIC_BEURER_BF850_INITIALS)) {
BluetoothBytesParser parser = new BluetoothBytesParser();
String initials = getInitials(this.selectedUser.getUserName());
Timber.d("Initials: " + initials);
parser.setString(initials);
writeBytes(SERVICE_BEURER_CUSTOM_BF600, CHARACTERISTIC_BEURER_BF850_INITIALS,
parser.getValue());
}
}
@Override
protected void requestMeasurement() {
BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0});
parser.setIntValue(0x00, FORMAT_UINT8, 0);
Timber.d(String.format("requestMeasurement BEURER 0xFFF4 magic: 0x00"));
writeBytes(SERVICE_BEURER_CUSTOM_BF600,
CHARACTERISTIC_BEURER_BF600_TAKE_MEASUREMENT, parser.getValue());
}
@Override
protected void setNotifyVendorSpecificUserList() {
if (setNotificationOn(SERVICE_BEURER_CUSTOM_BF600,
CHARACTERISTIC_BEURER_BF600_USER_LIST)) {
Timber.d("setNotifyVendorSpecificUserList() OK");
}
else {
Timber.d("setNotifyVendorSpecificUserList() FAILED");
}
}
@Override
protected synchronized void requestVendorSpecificUserList() {
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setIntValue(0x00, FORMAT_UINT8);
writeBytes(SERVICE_BEURER_CUSTOM_BF600, CHARACTERISTIC_BEURER_BF600_USER_LIST,
parser.getValue());
stopMachineState();
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
if (characteristic.equals(CHARACTERISTIC_BEURER_BF600_USER_LIST)) {
handleVendorSpecificUserList(value);
}
else {
super.onBluetoothNotify(characteristic, value);
}
}
}

View File

@@ -1,48 +0,0 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
/*
* Based on source-code by weliem/blessed-android
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import timber.log.Timber;
public class BluetoothBeurerBF950 extends BluetoothBeurerBF105 {
private String deviceName;
public BluetoothBeurerBF950(Context context, String name) {
super(context);
deviceName = name;
}
@Override
public String driverName() {
return deviceName;
}
@Override
protected int getVendorSpecificMaxUserCount() {
return 8;
}
@Override
protected void writeTargetWeight() {
Timber.d("Target Weight not supported on " + deviceName);
}
}

View File

@@ -1,178 +0,0 @@
/* Copyright (C) 2024 olie.xdev <olie.xdev@googlemail.com>
* 2024 Duncan Overbruck <mail@duncano.de>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import static android.content.Context.LOCATION_SERVICE;
import android.Manifest;
import android.bluetooth.le.ScanRecord;
import android.bluetooth.le.ScanResult;
import android.content.Context;
import android.content.pm.PackageManager;
import android.location.LocationManager;
import android.os.Handler;
import android.os.Looper;
import android.util.SparseArray;
import androidx.core.content.ContextCompat;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.welie.blessed.BluetoothCentralManager;
import com.welie.blessed.BluetoothCentralManagerCallback;
import com.welie.blessed.BluetoothPeripheral;
import java.util.Date;
import timber.log.Timber;
public class BluetoothBroadcastScale extends BluetoothCommunication {
private ScaleMeasurement measurement;
private boolean connected = false;
private final BluetoothCentralManager central;
public BluetoothBroadcastScale(Context context)
{
super(context);
this.context = context;
this.central = new BluetoothCentralManager(context, bluetoothCentralCallback, new Handler(Looper.getMainLooper()));
}
// Callback for central
private final BluetoothCentralManagerCallback bluetoothCentralCallback = new BluetoothCentralManagerCallback() {
@Override
public void onDiscoveredPeripheral(BluetoothPeripheral peripheral, ScanResult scanResult) {
ScanRecord record = scanResult.getScanRecord();
if (record == null)
return;
SparseArray<byte[]> manufacturerData = record.getManufacturerSpecificData();
if (manufacturerData.size() != 1)
return;
int companyId = manufacturerData.keyAt(0);
byte[] data = manufacturerData.get(companyId);
if (data.length < 12) {
Timber.d("Unexpected data length, got %d, expected %d", data.length, 12);
return;
}
// lower byte of the two byte companyId is the xor byte,
// its used on the last 6 bytes of the data, the first 6 bytes
// are just the mac address and can be ignored.
byte xor = (byte) (companyId >> 8);
byte[] buf = new byte[6];
for (int i = 0; i < 6; i++) {
buf[i] = (byte) (data[i + 6] ^ xor);
}
// chk is the sum of the first 5 bytes, its 5 lower bits are compared to the 5 lower
// bites of the last byte in the packet.
int chk = 0;
for (int i = 0; i < 5; i++) {
chk += buf[i];
}
if ((chk & 0x1F) != (buf[5] & 0x1F)) {
Timber.d("Checksum error, got %x, expected %x", chk & 0x1F, buf[5] & 0x1F);
return;
}
if (!connected) {
// "fake" a connection, since we've got valid data.
setBluetoothStatus(BT_STATUS.CONNECTION_ESTABLISHED);
connected = true;
}
switch (buf[4]) {
case (byte) 0xAD:
int value = (((buf[3] & 0xFF) << 0) | ((buf[2] & 0xFF) << 8) |
((buf[1] & 0xFF) << 16) | ((buf[0] & 0xFF) << 24));
byte state = (byte)(value >> 0x1F);
int grams = value & 0x3FFFF;
Timber.d("Got weight measurement weight=%.2f state=%d", (float)grams/1000, state);
if (state != 0 && measurement == null) {
measurement = new ScaleMeasurement();
measurement.setDateTime(new Date());
measurement.setWeight((float)grams / 1000);
// stop now since we don't support any further data.
addScaleMeasurement(measurement);
disconnect();
measurement = null;
}
break;
case (byte) 0xA6:
// this is the impedance package, not yet supported.
break;
default:
StringBuilder sb = new StringBuilder();
for (byte b : buf) {
sb.append(String.format("0x%02X ", b));
}
Timber.d("Unsupported packet type %x, xor key %x data: %s", buf[4], xor, sb.toString());
}
}
};
@Override
public void connect(String macAddress) {
LocationManager locationManager = (LocationManager)context.getSystemService(LOCATION_SERVICE);
if ((ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) ||
(ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED ) &&
(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
(locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)))
) {
Timber.d("Do LE scan before connecting to device");
central.scanForPeripheralsWithAddresses(new String[]{macAddress});
}
else {
Timber.e("No location permission, can't do anything");
}
}
@Override
public void disconnect() {
Timber.d("Bluetooth disconnect");
setBluetoothStatus(BT_STATUS.CONNECTION_DISCONNECT);
try {
central.stopScan();
} catch (Exception ex) {
Timber.e("Error on Bluetooth disconnecting " + ex.getMessage());
}
connected = false;
}
@Override
public String driverName() {
return "BroadcastScale";
}
@Override
protected boolean onNextStep(int stepNr) {
return false;
}
}

View File

@@ -1,161 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Locale;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothCustomOpenScale extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffe0);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffe1); // Bluetooth Modul HM-10
private String string_data = new String();
public BluetoothCustomOpenScale(Context context) {
super(context);
}
@Override
public String driverName() {
return "Custom openScale";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC);
break;
case 1:
Calendar cal = Calendar.getInstance();
String date_time = String.format(Locale.US, "2%1d,%1d,%1d,%1d,%1d,%1d,",
cal.get(Calendar.YEAR)-2000,
cal.get(Calendar.MONTH) + 1,
cal.get(Calendar.DAY_OF_MONTH),
cal.get(Calendar.HOUR_OF_DAY),
cal.get(Calendar.MINUTE),
cal.get(Calendar.SECOND));
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC, date_time.getBytes());
break;
default:
return false;
}
return true;
}
public void clearEEPROM()
{
byte[] cmd = {(byte)'9'};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC, cmd);
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (data != null) {
for (byte character : data) {
string_data += (char) (character & 0xFF);
if (character == '\n') {
parseBtString(string_data);
string_data = new String();
}
}
}
}
private void parseBtString(String btString) {
btString = btString.substring(0, btString.length() - 1); // delete newline '\n' of the string
if (btString.charAt(0) != '$' && btString.charAt(2) != '$') {
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Parse error of bluetooth string. String has not a valid format: " + btString);
}
String btMsg = btString.substring(3, btString.length()); // message string
switch (btString.charAt(1)) {
case 'I':
Timber.d("MCU Information: %s", btMsg);
break;
case 'E':
Timber.e("MCU Error: %s", btMsg);
break;
case 'S':
Timber.d("MCU stored data size: %s", btMsg);
break;
case 'F':
Timber.d("All data sent");
clearEEPROM();
disconnect();
break;
case 'D':
String[] csvField = btMsg.split(",");
try {
int checksum = 0;
checksum ^= Integer.parseInt(csvField[0]);
checksum ^= Integer.parseInt(csvField[1]);
checksum ^= Integer.parseInt(csvField[2]);
checksum ^= Integer.parseInt(csvField[3]);
checksum ^= Integer.parseInt(csvField[4]);
checksum ^= Integer.parseInt(csvField[5]);
checksum ^= (int) Float.parseFloat(csvField[6]);
checksum ^= (int) Float.parseFloat(csvField[7]);
checksum ^= (int) Float.parseFloat(csvField[8]);
checksum ^= (int) Float.parseFloat(csvField[9]);
int btChecksum = Integer.parseInt(csvField[10]);
if (checksum == btChecksum) {
ScaleMeasurement scaleBtData = new ScaleMeasurement();
String date_string = csvField[1] + "/" + csvField[2] + "/" + csvField[3] + "/" + csvField[4] + "/" + csvField[5];
scaleBtData.setDateTime(new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string));
scaleBtData.setWeight(Float.parseFloat(csvField[6]));
scaleBtData.setFat(Float.parseFloat(csvField[7]));
scaleBtData.setWater(Float.parseFloat(csvField[8]));
scaleBtData.setMuscle(Float.parseFloat(csvField[9]));
addScaleMeasurement(scaleBtData);
} else {
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error calculated checksum (" + checksum + ") and received checksum (" + btChecksum + ") is different");
}
} catch (ParseException e) {
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error while decoding bluetooth date string (" + e.getMessage() + ")");
} catch (NumberFormatException e) {
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error while decoding a number of bluetooth string (" + e.getMessage() + ")");
}
break;
default:
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error unknown MCU command : " + btString);
}
}
}

View File

@@ -1,191 +0,0 @@
/* Copyright (C) 2018 Erik Johansson <erik@ejohansson.se>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.content.Context;
import com.welie.blessed.BluetoothPeripheral;
import java.util.HashMap;
import timber.log.Timber;
public class BluetoothDebug extends BluetoothCommunication {
HashMap<Integer, String> propertyString;
BluetoothDebug(Context context) {
super(context);
propertyString = new HashMap<>();
propertyString.put(BluetoothGattCharacteristic.PROPERTY_BROADCAST, "BROADCAST");
propertyString.put(BluetoothGattCharacteristic.PROPERTY_READ, "READ");
propertyString.put(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, "WRITE_NO_RESPONSE");
propertyString.put(BluetoothGattCharacteristic.PROPERTY_WRITE, "WRITE");
propertyString.put(BluetoothGattCharacteristic.PROPERTY_NOTIFY, "NOTIFY");
propertyString.put(BluetoothGattCharacteristic.PROPERTY_INDICATE, "INDICATE");
propertyString.put(BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE, "SIGNED_WRITE");
propertyString.put(BluetoothGattCharacteristic.PROPERTY_EXTENDED_PROPS, "EXTENDED_PROPS");
}
@Override
public String driverName() {
return "Debug";
}
private boolean isBlacklisted(BluetoothGattService service, BluetoothGattCharacteristic characteristic) {
// Reading this triggers a pairing request on Beurer BF710
if (service.getUuid().equals(BluetoothGattUuid.fromShortCode(0xffe0))
&& characteristic.getUuid().equals(BluetoothGattUuid.fromShortCode(0xffe5))) {
return true;
}
return false;
}
private boolean isWriteType(int property, int writeType) {
switch (property) {
case BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE:
return writeType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE;
case BluetoothGattCharacteristic.PROPERTY_WRITE:
return writeType == BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT;
case BluetoothGattCharacteristic.PROPERTY_SIGNED_WRITE:
return writeType == BluetoothGattCharacteristic.WRITE_TYPE_SIGNED;
}
return false;
}
private String propertiesToString(int properties, int writeType) {
StringBuilder names = new StringBuilder();
for (int property : propertyString.keySet()) {
if ((properties & property) != 0) {
names.append(propertyString.get(property));
if (isWriteType(property, writeType)) {
names.append('*');
}
names.append(", ");
}
}
if (names.length() == 0) {
return "<none>";
}
return names.substring(0, names.length() - 2);
}
private String permissionsToString(int permissions) {
if (permissions == 0) {
return "";
}
return String.format(" (permissions=0x%x)", permissions);
}
private String byteToString(byte[] value) {
return new String(value).replaceAll("\\p{Cntrl}", "?");
}
private void logService(BluetoothGattService service, boolean included) {
Timber.d("Service %s%s", BluetoothGattUuid.prettyPrint(service.getUuid()),
included ? " (included)" : "");
for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) {
Timber.d("|- characteristic %s (#%d): %s%s",
BluetoothGattUuid.prettyPrint(characteristic.getUuid()),
characteristic.getInstanceId(),
propertiesToString(characteristic.getProperties(), characteristic.getWriteType()),
permissionsToString(characteristic.getPermissions()));
byte[] value = characteristic.getValue();
if (value != null && value.length > 0) {
Timber.d("|--> value: %s (%s)", byteInHex(value), byteToString(value));
}
for (BluetoothGattDescriptor descriptor : characteristic.getDescriptors()) {
Timber.d("|--- descriptor %s%s",
BluetoothGattUuid.prettyPrint(descriptor.getUuid()),
permissionsToString(descriptor.getPermissions()));
value = descriptor.getValue();
if (value != null && value.length > 0) {
Timber.d("|-----> value: %s (%s)", byteInHex(value), byteToString(value));
}
}
}
for (BluetoothGattService includedService : service.getIncludedServices()) {
logService(includedService, true);
}
}
private int readServiceCharacteristics(BluetoothGattService service, int offset) {
for (BluetoothGattCharacteristic characteristic : service.getCharacteristics()) {
if ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) != 0
&& !isBlacklisted(service, characteristic)) {
if (offset == 0) {
readBytes(service.getUuid(), characteristic.getUuid());
return -1;
}
offset -= 1;
}
for (BluetoothGattDescriptor descriptor : characteristic.getDescriptors()) {
if (offset == 0) {
readBytes(service.getUuid(), characteristic.getUuid());
return -1;
}
offset -= 1;
}
}
for (BluetoothGattService included : service.getIncludedServices()) {
offset = readServiceCharacteristics(included, offset);
if (offset == -1) {
return offset;
}
}
return offset;
}
@Override
protected void onBluetoothDiscovery(BluetoothPeripheral peripheral) {
int offset = 0;
for (BluetoothGattService service : peripheral.getServices()) {
offset = readServiceCharacteristics(service, offset);
}
for (BluetoothGattService service : peripheral.getServices()) {
logService(service, false);
}
setBluetoothStatus(BT_STATUS.CONNECTION_LOST);
disconnect();
}
@Override
protected boolean onNextStep(int stateNr) {
return false;
}
}

View File

@@ -1,133 +0,0 @@
/* Copyright (C) 2017 Murgi <fabian@murgi.de>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothDigooDGSO38H extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1);
private final UUID EXTRA_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff2);
public BluetoothDigooDGSO38H(Context context) {
super(context);
}
@Override
public String driverName() {
return "Digoo DG-SO38H";
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (data != null && data.length > 0) {
if (data.length == 20) {
parseBytes(data);
}
}
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
//Tell device to send us weight measurements
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC);
break;
case 1:
sendMessage(R.string.info_step_on_scale, 0);
break;
default:
return false;
}
return true;
}
private void parseBytes(byte[] weightBytes) {
float weight, fat, water, muscle, boneWeight, visceralFat;
//float subcutaneousFat, metabolicBaseRate, biologicalAge, boneWeight;
final byte ctrlByte = weightBytes[5];
final boolean allValues = isBitSet(ctrlByte, 1);
final boolean weightStabilized = isBitSet(ctrlByte, 0);
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
if (weightStabilized) {
//The weight is stabilized, now we want to measure all available values
byte gender = selectedUser.getGender().isMale() ? (byte)0x00: (byte)0x01;
byte height = (byte) (((int)selectedUser.getBodyHeight()) & 0xFF);
byte age = (byte)(selectedUser.getAge() & 0xff);
byte unit = 0x01; // kg
switch (selectedUser.getScaleUnit()) {
case LB:
unit = 0x02;
break;
case ST:
unit = 0x8;
break;
}
byte configBytes[] = new byte[]{(byte)0x09, (byte)0x10, (byte)0x12, (byte)0x11, (byte)0x0d, (byte)0x01, height, age, gender, unit, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00};
//Write checksum is sum of all bytes % 256
int checksum = 0x00;
for (int i=3; i<configBytes.length-1; i++) {
checksum += configBytes[i];
}
configBytes[15] = (byte)(checksum & 0xFF);
writeBytes(WEIGHT_MEASUREMENT_SERVICE, EXTRA_MEASUREMENT_CHARACTERISTIC, configBytes);
} else if (allValues) {
ScaleMeasurement scaleBtData = new ScaleMeasurement();
weight = (float) (((weightBytes[3] & 0xFF) << 8) | (weightBytes[4] & 0xFF)) / 100.0f;
fat = (float) (((weightBytes[6] & 0xFF) << 8) | (weightBytes[7] & 0xFF)) / 10.0f;
if (Math.abs(fat - 0.0) < 0.00001) {
Timber.d("Scale signaled that measurement of all data " +
"is done, but fat is still zero. Settling for just adding weight.");
} else {
//subcutaneousFat = (float) (((weightBytes[8] & 0xFF) << 8) | (weightBytes[9] & 0xFF)) / 10.0f;
visceralFat = (float) (weightBytes[10] & 0xFF) / 10.0f;
water = (float) (((weightBytes[11] & 0xFF) << 8) | (weightBytes[12] & 0xFF)) / 10.0f;
//metabolicBaseRate = (float) (((weightBytes[13] & 0xFF) << 8) | (weightBytes[14] & 0xFF));
//biologicalAge = (float) (weightBytes[15] & 0xFF) + 1;
muscle = (float) (((weightBytes[16] & 0xFF) << 8) | (weightBytes[17] & 0xFF)) / 10.0f;
boneWeight = (float) (weightBytes[18] & 0xFF) / 10.0f;
scaleBtData.setDateTime(new Date());
scaleBtData.setFat(fat);
scaleBtData.setMuscle(muscle);
scaleBtData.setWater(water);
scaleBtData.setBone(boneWeight);
scaleBtData.setVisceralFat(visceralFat);
}
scaleBtData.setWeight(weight);
addScaleMeasurement(scaleBtData);
}
}
}

View File

@@ -1,246 +0,0 @@
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import org.jetbrains.annotations.Nullable;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothES26BBB extends BluetoothCommunication {
private static final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0x1a10);
/**
* Notify
*/
private static final UUID NOTIFY_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x2a10);
/**
* Write
*/
private static final UUID WRITE_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x2a11);
public BluetoothES26BBB(Context context) {
super(context);
}
@Override
public String driverName() {
// TODO idk what to put here
return "RENPHO ES-26BB-B";
}
@Override
protected boolean onNextStep(int stepNr) {
Timber.i("onNextStep(%d)", stepNr);
switch (stepNr) {
case 0:
// set notification on for custom characteristic 1 (weight, time, and others)
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, NOTIFY_MEASUREMENT_CHARACTERISTIC);
break;
case 1:
// TODO investigate what these mean
byte[] ffe3magicBytes = new byte[]{(byte) 0x55, (byte) 0xaa, (byte) 0x90, (byte) 0x00, (byte) 0x04, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x94};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WRITE_MEASUREMENT_CHARACTERISTIC, ffe3magicBytes);
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
if (characteristic.equals(NOTIFY_MEASUREMENT_CHARACTERISTIC)) {
parseNotifyPacket(value);
}
}
/**
* Parse a packet sent by the scale, through characteristic NOTIFY_MEASUREMENT_CHARACTERISTIC.
*
* @param data The data payload (in bytes)
*/
private void parseNotifyPacket(byte[] data) {
String dataStr = byteInHex(data);
Timber.d("Received measurement packet: %s", dataStr);
if (!isChecksumValid(data)) {
Timber.w("Checksum of packet did not match. Ignoring measurement. Packet: %s", dataStr);
return;
}
// Bytes 0, 1, 3 and 4 seem to be ignored by the original implementation
byte action = data[2];
switch (action) {
case 0x14:
handleMeasurementPayload(data);
break;
case 0x11:
// TODO this seems to be sent at the start and at the end of the measurement (?)
// This sends scale information, such as power status, unit, precision, offline count and battery
byte powerStatus = data[5];
byte unit = data[6];
byte precision = data[7];
byte offlineCount = data[8];
byte battery = data[9];
Timber.d(
"Received scale information. Power status: %d, Unit: %d, Precision: %d, Offline count: %d, Battery: %d",
powerStatus, // 1: turned on; 0: shutting down
unit, // 1: kg
precision, // seems to be 1, not sure when it would not be
offlineCount, // how many offline measurements stored, I think we can ignore
battery // seems to be 0, not sure what would happen when battery is low
);
// TODO
break;
case 0x15:
// This is offline data (one packet per measurement)
handleOfflineMeasurementPayload(data);
break;
case 0x10:
// This is callback for some action I can't figure out
// Original implementation only prints stuff to log, doesn't do anything else
byte success = data[5];
if (success == 1) {
Timber.d("Received success for operation");
} else {
Timber.d("Received failure for operation");
}
default:
Timber.w("Unknown action sent from scale: %x. Full packet: %s", action, dataStr);
break;
}
}
/**
* The last byte of the payload is a checksum.
* It is calculated by summing all the other bytes and AND'ing it with 255 (that is, truncate to byte).
*
* @param data The payload to check, where the last byte is the checksum
* @return True if the checksum matches, false otherwise
*/
private boolean isChecksumValid(byte[] data) {
if (data.length == 0) {
Timber.d("Could not validate checksum because payload is empty");
return false;
}
byte checksum = data[data.length - 1];
byte sum = sumChecksum(data, 0, data.length - 1);
Timber.d("Comparing checksum (%x == %x)", sum, checksum);
return sum == checksum;
}
/**
* Handle a packet of type "measurement" (0x15).
* Offline measurements always have resistance (it wouldn't make sense to store weight-only
* measurements since people can just look at the scale).
* <p>
* This will create and save a measurement.
*
* @param data The data payload (in bytes)
*/
private void handleMeasurementPayload(byte[] data) {
Timber.d("Parsing measurement");
// 0x01 and 0x11 are final measurements, 0x00 and 0x10 are real-time measurements
byte measurementType = data[5];
if (measurementType != 0x01 && measurementType != 0x11) {
// This byte indicates whether the measurement is final or not
// Discard if it isn't, we only want the final value
Timber.d("Discarded measurement since it is not final");
return;
}
Timber.d("Saving measurement");
// Weight (in kg) is stored as big-endian in bytes 6 to 9
long weightKg = Converters.fromUnsignedInt32Be(data, 6);
// Resistance/Impedance is stored as big-endian in bytes 10 to 11
int resistance = Converters.fromUnsignedInt16Be(data, 10);
Timber.d("Got measurement from scale. Weight: %d, Resistance: %d", weightKg, resistance);
// FIXME weight might be in other units, investigate
saveMeasurement(weightKg, resistance, null);
}
/**
* Handle a packet of type "ofline measurement" (0x14).
* There are two types: real time (0x00/0x10) or final (0x01/0x11), indicated by byte 5.
* Real time measurements only have weight, whereas final measurements can also have resistance.
* <p>
* This will create and save a measurement if it is final, discarding real time measurements.
*
* @param data The data payload (in bytes)
*/
private void handleOfflineMeasurementPayload(byte[] data) {
Timber.d("Parsing offline measurement");
// Weight (in kg) is stored as big-endian in bytes 5 to 8
long weightKg = Converters.fromUnsignedInt32Be(data, 5);
// Resistance/Impedance is stored as big-endian in bytes 9 to 10
int resistance = Converters.fromUnsignedInt16Be(data, 9);
// Scale returns the seconds elapsed since the measurement as big-endian in bytes 11 to 14
long secondsSinceMeasurement = Converters.fromUnsignedInt32Be(data, 11);
long measurementTimestamp = System.currentTimeMillis() - secondsSinceMeasurement * 1000;
Timber.d("Got offline measurement from scale. Weight: %d, Resistance: %d, Timestamp: %tc", weightKg, resistance, measurementTimestamp);
saveMeasurement(weightKg, resistance, measurementTimestamp);
acknowledgeOfflineMeasurement();
}
/**
* Send acknowledge to the scale that we received one offline measurement payload,
* so that it can delete it from memory.
* <p>
* For each offline measurement, we have to send one of these.
*/
private void acknowledgeOfflineMeasurement() {
final byte[] payload = {(byte) 0x55, (byte) 0xAA, (byte) 0x95, (byte) 0x0, (byte) 0x1, (byte) 0x1, 0};
payload[payload.length - 1] = sumChecksum(payload, 0, payload.length - 1);
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WRITE_MEASUREMENT_CHARACTERISTIC, payload);
Timber.d("Acknowledge offline measurement");
}
/**
* Save a measurement from the scale to openScale.
*
* @param weightKg The weight, in kilograms, multiplied by 100 (that is, as an integer)
* @param resistance The resistance (impedance) given by the scale. Can be zero if not barefoot
* @param timestamp For offline measurements, provide the timestamp. If null, the current timestamp will be used
*/
private void saveMeasurement(long weightKg, int resistance, @Nullable Long timestamp) {
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
Timber.d("Saving measurement for scale user %s", scaleUser);
final ScaleMeasurement btScaleMeasurement = new ScaleMeasurement();
btScaleMeasurement.setWeight(weightKg / 100f);
if (resistance != 0) {
// TODO add more measurements
// This will require us to revert engineer libnative-lib.so
}
if (timestamp != null) {
btScaleMeasurement.setDateTime(new Date(timestamp));
}
addScaleMeasurement(btScaleMeasurement);
}
}

View File

@@ -1,176 +0,0 @@
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.bluetooth.lib.YunmaiLib;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import timber.log.Timber;
public class BluetoothESCS20M extends BluetoothCommunication {
private static final UUID SERV_CUR_TIME = BluetoothGattUuid.fromShortCode(0x1a10);
private static final UUID CHAR_CUR_TIME = BluetoothGattUuid.fromShortCode(0x2a11);
private static final UUID CHAR_RESULTS = BluetoothGattUuid.fromShortCode(0x2a10);
private static final byte MESSAGE_ID_START_STOP_RESP = 0x11;
private static final byte MESSAGE_ID_WEIGHT_RESP = 0x14;
private static final byte MESSAGE_ID_EXTENDED_RESP = 0x15;
private static final byte MEASUREMENT_TYPE_START_WEIGHT_ONLY = 0x18;
private static final byte MEASUREMENT_TYPE_STOP_WEIGHT_ONLY = 0x17;
private static final byte MEASUREMENT_TYPE_START_ALL = 0x19;
private static final byte MEASUREMENT_TYPE_STOP_ALL = 0x18;
private static final byte[] MAGIC_BYTES_START_MEASUREMENT = new byte[]{
(byte) 0x55, (byte) 0xaa, (byte) 0x90, (byte) 0x00, (byte) 0x04, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x94
};
private static final byte[] MAGIC_BYTES_DELETE_HISTORY_DATA = new byte[]{
(byte)0x55, (byte) 0xaa, (byte) 0x95, (byte)0x00, (byte)0x01, (byte)0x01,(byte) 0x96
};
private List<byte[]> rawMeasurements = new ArrayList<>();
private final ScaleMeasurement scaleMeasurement = new ScaleMeasurement();
public BluetoothESCS20M(Context context) {
super(context);
}
@Override
public String driverName() {
return "ES-CS20M";
}
@Override
protected boolean onNextStep(int stepNr) {
Timber.i("onNextStep(%d)", stepNr);
switch (stepNr) {
case 0:
setNotificationOn(SERV_CUR_TIME, CHAR_CUR_TIME);
break;
case 1:
setNotificationOn(SERV_CUR_TIME, CHAR_RESULTS);
break;
case 2:
writeBytes(SERV_CUR_TIME, CHAR_CUR_TIME, MAGIC_BYTES_START_MEASUREMENT);
writeBytes(SERV_CUR_TIME, CHAR_CUR_TIME, MAGIC_BYTES_DELETE_HISTORY_DATA);
stopMachineState();
break;
case 3:
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
Timber.d("Received notification on UUID = %s", characteristic.toString());
Timber.d("Received in step(%d)", getStepNr());
for (int i = 0; i < value.length; i++) {
Timber.d("Byte %d = 0x%02x", i, value[i]);
}
rawMeasurements.add(value);
final byte msgID = value[2];
if (msgID != MESSAGE_ID_START_STOP_RESP)
return;
final byte measurementType = value[10];
if (getStepNr() == 4 && (measurementType == MEASUREMENT_TYPE_STOP_WEIGHT_ONLY || measurementType == MEASUREMENT_TYPE_STOP_ALL)) {
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
final int sex = scaleUser.getGender() == Converters.Gender.MALE ? 1 : 0;
YunmaiLib yunmaiLib = new YunmaiLib(sex, scaleUser.getBodyHeight(), scaleUser.getActivityLevel());
rawMeasurements = rawMeasurements.stream().sorted(Comparator.comparingInt(a -> a[2])).collect(Collectors.toList());
Timber.d("Parsing measurements");
for (byte[] msg : rawMeasurements) {
parseMsg(msg, yunmaiLib, scaleUser);
}
Timber.d("Saving measurement for scale user %s", scaleUser);
addScaleMeasurement(scaleMeasurement);
}
if (getStepNr() == 3 && (measurementType == MEASUREMENT_TYPE_START_WEIGHT_ONLY || measurementType == MEASUREMENT_TYPE_START_ALL))
resumeMachineState();
}
private void parseMsg(byte[] msg, YunmaiLib calcLib, ScaleUser user) {
final byte msgID = msg[2];
switch (msgID) {
case MESSAGE_ID_WEIGHT_RESP:
Timber.d("Found weight measurement");
final boolean stableValue = Byte.toUnsignedInt(msg[5]) != 0;
if (stableValue) {
Timber.d("Found stable weight measurement");
scaleMeasurement.setWeight(Converters.fromUnsignedInt16Be(msg, 8) / 100.0f);
if (msg[10] != 0x00 && msg[11] != 0x00) {
Timber.d("Found embedded extended measurements in weight message");
if (rawMeasurements.stream().filter(a -> a[2] == 0x15).count() > 0) {
Timber.d("Ignore embedded extended measurements because separate message found");
return;
}
final int resistance = Converters.fromUnsignedInt16Be(msg, 10);
parseExtendedMeasurement(resistance, calcLib, user);
}
}
break;
case MESSAGE_ID_EXTENDED_RESP:
Timber.d("Found extended measurements message");
final int resistance = Converters.fromUnsignedInt16Be(msg, 9);
parseExtendedMeasurement(resistance, calcLib, user);
break;
}
}
private void parseExtendedMeasurement(final int resistance, YunmaiLib calcLib, ScaleUser user) {
Timber.d("Found extended measurements");
final float weight = scaleMeasurement.getWeight();
if (weight == 0.0f) {
Timber.e("Weight is zero, could not process extended measurements");
return;
}
final float bodyFat = calcLib.getFat(user.getAge(), weight, resistance);
final float muscle = calcLib.getMuscle(bodyFat) / weight * 100.0f;
final float water = calcLib.getWater(bodyFat);
final float bone = calcLib.getBoneMass(muscle, weight);
final float lbm = calcLib.getLeanBodyMass(weight, bodyFat);
final float visceralFal = calcLib.getVisceralFat(bodyFat, user.getAge());
scaleMeasurement.setFat(bodyFat);
scaleMeasurement.setMuscle(muscle);
scaleMeasurement.setWater(water);
scaleMeasurement.setBone(bone);
scaleMeasurement.setLbm(lbm);
scaleMeasurement.setVisceralFat(visceralFal);
}
}

View File

@@ -1,145 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.Arrays;
import java.util.UUID;
public class BluetoothExcelvanCF36xBLE extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1);
private final UUID WEIGHT_CUSTOM0_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff4);
private byte[] receivedData = new byte[]{};
public BluetoothExcelvanCF36xBLE(Context context) {
super(context);
}
@Override
public String driverName() {
return "Excelvan CF36xBLE";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
byte userId = (byte) 0x01;
byte sex = selectedUser.getGender().isMale() ? (byte) 0x01 : (byte) 0x00;
// 0x00 = ordinary, 0x01 = amateur, 0x02 = professional
byte exerciseLevel = (byte) 0x01;
switch (selectedUser.getActivityLevel()) {
case SEDENTARY:
case MILD:
exerciseLevel = (byte) 0x00;
break;
case MODERATE:
exerciseLevel = (byte) 0x01;
break;
case HEAVY:
case EXTREME:
exerciseLevel = (byte) 0x02;
break;
}
byte height = (byte) selectedUser.getBodyHeight();
byte age = (byte) selectedUser.getAge();
byte unit = 0x01; // kg
switch (selectedUser.getScaleUnit()) {
case LB:
unit = 0x02;
break;
case ST:
unit = 0x04;
break;
}
byte[] configBytes = {(byte) 0xfe, userId, sex, exerciseLevel, height, age, unit, (byte) 0x00};
configBytes[configBytes.length - 1] =
xorChecksum(configBytes, 1, configBytes.length - 2);
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC, configBytes);
break;
case 1:
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_CUSTOM0_CHARACTERISTIC);
break;
case 2:
sendMessage(R.string.info_step_on_scale, 0);
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (data != null && data.length > 0) {
// if data is body scale type. At least some variants (e.g. CF366BLE) of this scale
// return a 17th byte representing "physiological age". Allow (but ignore) that byte
// to support those variants.
if ((data.length >= 16 && data.length <= 17) && data[0] == (byte)0xcf) {
if (!Arrays.equals(data, receivedData)) { // accepts only one data of the same content
receivedData = data;
parseBytes(data);
}
}
}
}
private void parseBytes(byte[] weightBytes) {
float weight = Converters.fromUnsignedInt16Be(weightBytes, 4) / 10.0f;
float fat = Converters.fromUnsignedInt16Be(weightBytes, 6) / 10.0f;
float bone = (weightBytes[8] & 0xFF) / 10.0f;
float muscle = Converters.fromUnsignedInt16Be(weightBytes, 9) / 10.0f;
float visceralFat = weightBytes[11] & 0xFF;
float water = Converters.fromUnsignedInt16Be(weightBytes, 12) / 10.0f;
float bmr = Converters.fromUnsignedInt16Be(weightBytes, 14);
// weightBytes[16] is an (optional, ignored) "physiological age" in some scale variants.
ScaleMeasurement scaleBtData = new ScaleMeasurement();
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
scaleBtData.setWeight(Converters.toKilogram(weight, selectedUser.getScaleUnit()));
scaleBtData.setFat(fat);
scaleBtData.setMuscle(muscle);
scaleBtData.setWater(water);
scaleBtData.setBone(bone);
scaleBtData.setVisceralFat(visceralFat);
addScaleMeasurement(scaleBtData);
}
}

View File

@@ -1,112 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.Date;
import java.util.UUID;
public class BluetoothExingtechY1 extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = UUID.fromString("f433bd80-75b8-11e2-97d9-0002a5d5c51b");
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = UUID.fromString("1a2ea400-75b9-11e2-be05-0002a5d5c51b"); // read, notify
private final UUID CMD_MEASUREMENT_CHARACTERISTIC = UUID.fromString("29f11080-75b9-11e2-8bf6-0002a5d5c51b"); // write only
public BluetoothExingtechY1(Context context) {
super(context);
}
@Override
public String driverName() {
return "Exingtech Y1";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC);
break;
case 1:
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
byte gender = selectedUser.getGender().isMale() ? (byte)0x00 : (byte)0x01; // 00 - male; 01 - female
byte height = (byte)(((int)selectedUser.getBodyHeight()) & 0xff); // cm
byte age = (byte)(selectedUser.getAge() & 0xff);
int userId = selectedUser.getId();
byte cmdByte[] = {(byte)0x10, (byte)userId, gender, age, height};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, cmdByte);
break;
case 2:
sendMessage(R.string.info_step_on_scale, 0);
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
// The first notification only includes weight and all other fields are
// either 0x00 (user info) or 0xff (fat, water, etc.)
if (data != null && data.length == 20 && data[6] != (byte)0xff) {
parseBytes(data);
}
}
private void parseBytes(byte[] weightBytes) {
int userId = weightBytes[0] & 0xFF;
int gender = weightBytes[1] & 0xFF; // 0x00 male; 0x01 female
int age = weightBytes[2] & 0xFF; // 10 ~ 99
int height = weightBytes[3] & 0xFF; // 0 ~ 255
float weight = Converters.fromUnsignedInt16Be(weightBytes, 4) / 10.0f; // kg
float fat = Converters.fromUnsignedInt16Be(weightBytes, 6) / 10.0f; // %
float water = Converters.fromUnsignedInt16Be(weightBytes, 8) / 10.0f; // %
float bone = Converters.fromUnsignedInt16Be(weightBytes, 10) / 10.0f; // kg
float muscle = Converters.fromUnsignedInt16Be(weightBytes, 12) / 10.0f; // %
float visc_fat = weightBytes[14] & 0xFF; // index
float calorie = Converters.fromUnsignedInt16Be(weightBytes, 15);
float bmi = Converters.fromUnsignedInt16Be(weightBytes, 17) / 10.0f;
ScaleMeasurement scaleBtData = new ScaleMeasurement();
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
scaleBtData.setWeight(weight);
scaleBtData.setFat(fat);
scaleBtData.setMuscle(muscle);
scaleBtData.setWater(water);
scaleBtData.setBone(bone);
scaleBtData.setVisceralFat(visc_fat);
scaleBtData.setDateTime(new Date());
addScaleMeasurement(scaleBtData);
}
}

View File

@@ -1,177 +0,0 @@
/* Copyright (C) 2018 Erik Johansson <erik@ejohansson.se>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import android.util.SparseArray;
import java.util.Locale;
public class BluetoothFactory {
public static BluetoothCommunication createDebugDriver(Context context) {
return new BluetoothDebug(context);
}
public static BluetoothCommunication createDeviceDriver(Context context, String deviceName) {
final String name = deviceName.toLowerCase(Locale.US);
if (name.startsWith("BEURER BF700".toLowerCase(Locale.US))
|| name.startsWith("BEURER BF800".toLowerCase(Locale.US))
|| name.startsWith("BF-800".toLowerCase(Locale.US))
|| name.startsWith("BF-700".toLowerCase(Locale.US))
|| name.startsWith("RT-Libra-B".toLowerCase(Locale.US))
|| name.startsWith("RT-Libra-W".toLowerCase(Locale.US))
|| name.startsWith("Libra-B".toLowerCase(Locale.US))
|| name.startsWith("Libra-W".toLowerCase(Locale.US))) {
return new BluetoothBeurerSanitas(context, BluetoothBeurerSanitas.DeviceType.BEURER_BF700_800_RT_LIBRA);
}
if (name.startsWith("BEURER BF710".toLowerCase(Locale.US))
|| name.equals("BF700".toLowerCase(Locale.US))) {
return new BluetoothBeurerSanitas(context, BluetoothBeurerSanitas.DeviceType.BEURER_BF710);
}
if (name.equals("openScale".toLowerCase(Locale.US))) {
return new BluetoothCustomOpenScale(context);
}
if (name.equals("Mengii".toLowerCase(Locale.US))) {
return new BluetoothDigooDGSO38H(context);
}
if (name.equals("Electronic Scale".toLowerCase(Locale.US))) {
return new BluetoothExcelvanCF36xBLE(context);
}
if (name.equals("VScale".toLowerCase(Locale.US))) {
return new BluetoothExingtechY1(context);
}
if (name.equals("YunChen".toLowerCase(Locale.US))) {
return new BluetoothHesley(context);
}
if (deviceName.startsWith("iHealth HS3")) {
return new BluetoothIhealthHS3(context);
}
// BS444 || BS440
if (deviceName.startsWith("013197") || deviceName.startsWith("013198") || deviceName.startsWith("0202B6")) {
return new BluetoothMedisanaBS44x(context, true);
}
//BS430
if (deviceName.startsWith("0203B")) {
return new BluetoothMedisanaBS44x(context, false);
}
if (deviceName.startsWith("SWAN") || name.equals("icomon".toLowerCase(Locale.US)) || name.equals("YG".toLowerCase(Locale.US))) {
return new BluetoothMGB(context);
}
if (name.equals("MI_SCALE".toLowerCase(Locale.US)) || name.equals("MI SCALE2".toLowerCase(Locale.US))) {
return new BluetoothMiScale(context);
}
if (name.equals("MIBCS".toLowerCase(Locale.US)) || name.equals("MIBFS".toLowerCase(Locale.US))) {
return new BluetoothMiScale2(context);
}
if (name.equals("Health Scale".toLowerCase(Locale.US))) {
return new BluetoothOneByone(context);
}
if(name.equals("1byone scale".toLowerCase(Locale.US))) {
return new BluetoothOneByoneNew(context);
}
if (name.equals("SENSSUN FAT".toLowerCase(Locale.US))) {
return new BluetoothSenssun(context);
}
if (name.startsWith("SANITAS SBF70".toLowerCase(Locale.US)) || name.startsWith("sbf75") || name.startsWith("AICDSCALE1".toLowerCase(Locale.US))) {
return new BluetoothBeurerSanitas(context, BluetoothBeurerSanitas.DeviceType.SANITAS_SBF70_70);
}
if (deviceName.startsWith("YUNMAI-SIGNAL") || deviceName.startsWith("YUNMAI-ISM")) {
return new BluetoothYunmaiSE_Mini(context, true);
}
if (deviceName.startsWith("YUNMAI-ISSE")) {
return new BluetoothYunmaiSE_Mini(context, false);
}
if (deviceName.startsWith("01257B") || deviceName.startsWith("11257B")) {
// Trisa Body Analyze 4.0, aka Transtek GBF-1257-B
return new BluetoothTrisaBodyAnalyze(context);
}
if (deviceName.equals("000FatScale01") || deviceName.equals("000FatScale02")
|| deviceName.equals("042FatScale01")) {
return new BluetoothInlife(context);
}
if (deviceName.startsWith("QN-Scale")) {
return new BluetoothQNScale(context);
}
if (deviceName.startsWith("Shape200") || deviceName.startsWith("Shape100") || deviceName.startsWith("Shape50") || deviceName.startsWith("Style100")) {
return new BluetoothSoehnle(context);
}
if (deviceName.equals("Hoffen BS-8107")) {
return new BluetoothHoffenBBS8107(context);
}
if (deviceName.equals("ADV") || deviceName.equals("Chipsea-BLE")) {
return new BluetoothOKOK(context);
}
if (deviceName.equals("NoName OkOk")) {
return new BluetoothOKOK2(context);
}
if (deviceName.equals("BF105") || deviceName.equals("BF720")) {
return new BluetoothBeurerBF105(context);
}
if (deviceName.equals("BF500")) {
return new BluetoothBeurerBF500(context, deviceName);
}
if (deviceName.equals("BF600") || deviceName.equals("BF850")) {
return new BluetoothBeurerBF600(context, deviceName);
}
if (deviceName.equals("SBF77") || deviceName.equals("SBF76") || deviceName.equals("BF950")) {
return new BluetoothBeurerBF950(context, deviceName);
}
if (deviceName.equals("SBF72") || deviceName.equals("BF915") || deviceName.equals("SBF73")) {
return new BluetoothSanitasSBF72(context, deviceName);
}
if (deviceName.equals("Weight Scale")) {
return new BluetoothSinocare(context);
}
if (deviceName.equals("CH100")) {
return new BluetoothHuaweiAH100(context);
}
if (deviceName.equals("ES-26BB-B")){
return new BluetoothES26BBB(context);
}
if (deviceName.equals("Yoda1")){
return new BluetoothYoda1Scale(context);
}
if (deviceName.equals("AAA002") || deviceName.equals("AAA007") || deviceName.equals("AAA013")) {
return new BluetoothBroadcastScale(context);
}
if (deviceName.equals("AE BS-06")) {
return new BluetoothActiveEraBF06(context);
}
if (deviceName.equals("Renpho-Scale")) {
/* Driver for Renpho ES-WBE28, which has device name of "Renpho-Scale".
"Renpho-Scale" is quite generic, not sure if other Renpho scales with different
protocol match this name.
*/
return new BluetoothRenphoScale(context);
}
if(deviceName.equals("ES-CS20M")){
return new BluetoothESCS20M(context);
}
return null;
}
public static String convertNoNameToDeviceName(SparseArray<byte[]> manufacturerSpecificData) {
String deviceName = null;
deviceName = BluetoothOKOK2.convertNoNameToDeviceName(manufacturerSpecificData);
return deviceName;
}
}

View File

@@ -1,96 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import java.util.Date;
import java.util.UUID;
public class BluetoothHesley extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff4); // read, notify
private final UUID CMD_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1); // write only
public BluetoothHesley(Context context) {
super(context);
}
@Override
public String driverName() {
return "Hesley scale";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC);
break;
case 1:
byte[] magicBytes = {(byte)0xa5, (byte)0x01, (byte)0x2c, (byte)0xab, (byte)0x50, (byte)0x5a, (byte)0x29};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, magicBytes);
break;
case 2:
sendMessage(R.string.info_step_on_scale, 0);
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (data != null && data.length > 0) {
if (data.length == 20) {
parseBytes(data);
}
}
}
private void parseBytes(byte[] weightBytes) {
int bodyage = (int)(weightBytes[17]); // 10 ~ 99
float weight = (float) (((weightBytes[2] & 0xFF) << 8) | (weightBytes[3] & 0xFF)) / 100.0f; // kg
float fat = (float)(((weightBytes[4] & 0xFF) << 8) | (weightBytes[5] & 0xFF)) / 10.0f; // %
float water = (float)(((weightBytes[8] & 0xFF) << 8) | (weightBytes[9] & 0xFF)) / 10.0f; // %
float muscle = (float)(((weightBytes[10] & 0xFF) << 8) | (weightBytes[11] & 0xFF)) / 10.0f; // %
float bone = (float)(((weightBytes[12] & 0xFF) << 8) | (weightBytes[13] & 0xFF)) / 10.0f; // %
float calorie = (float)(((weightBytes[14] & 0xFF) << 8) | (weightBytes[15] & 0xFF)); // kcal
ScaleMeasurement scaleBtData = new ScaleMeasurement();
scaleBtData.setWeight(weight);
scaleBtData.setFat(fat);
scaleBtData.setMuscle(muscle);
scaleBtData.setWater(water);
scaleBtData.setBone(bone);
scaleBtData.setDateTime(new Date());
addScaleMeasurement(scaleBtData);
}
}

View File

@@ -1,217 +0,0 @@
/* Copyright (C) 2021 Karol Werner <karol@ppkt.eu>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.Arrays;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothHoffenBBS8107 extends BluetoothCommunication {
private static final UUID UUID_SERVICE = BluetoothGattUuid.fromShortCode(0xffb0);
private static final UUID UUID_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffb2);
private static final byte MAGIC_BYTE = (byte) 0xFA;
private static final byte RESPONSE_INTERMEDIATE_MEASUREMENT = (byte) 0x01;
private static final byte RESPONSE_FINAL_MEASUREMENT = (byte) 0x02;
private static final byte RESPONSE_ACK = (byte) 0x03;
private static final byte CMD_MEASUREMENT_DONE = (byte) 0x82;
private static final byte CMD_CHANGE_SCALE_UNIT = (byte) 0x83;
private static final byte CMD_SEND_USER_DATA = (byte) 0x85;
private ScaleUser user;
public BluetoothHoffenBBS8107(Context context) {
super(context);
}
@Override
public String driverName() {
return "Hoffen BBS-8107";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
setNotificationOn(UUID_SERVICE, UUID_CHARACTERISTIC);
user = OpenScale.getInstance().getSelectedScaleUser();
break;
case 1:
// Send user data to the scale
byte[] userData = {
(byte) 0x00, // "plan" id?
user.getGender().isMale() ? (byte) 0x01 : (byte) 0x00,
(byte) user.getAge(),
(byte) user.getBodyHeight(),
};
sendPacket(CMD_SEND_USER_DATA, userData);
// Wait for scale response for this packet
stopMachineState();
break;
case 2:
// Send preferred scale unit to the scale
byte[] weightUnitData = {
(byte) (0x01 + user.getScaleUnit().toInt()),
(byte) 0x00, // always empty
};
sendPacket(CMD_CHANGE_SCALE_UNIT, weightUnitData);
// Wait for scale response for this packet
stopMachineState();
break;
case 3:
// Start measurement
sendMessage(R.string.info_step_on_scale, 0);
// Wait until measurement is done
stopMachineState();
break;
case 4:
// Indicate successful measurement to the scale
byte[] terminateData = {
(byte) 0x00, // always empty
};
sendPacket(CMD_MEASUREMENT_DONE, terminateData);
// Wait for scale response for this packet
stopMachineState();
break;
case 5:
// Terminate the connection - scale will turn itself down after couple seconds
disconnect();
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
if (value == null || value.length < 2) {
return;
}
if (!verifyData(value) && ((value[0] != MAGIC_BYTE) || (value[1] != RESPONSE_FINAL_MEASUREMENT))) {
// For packet starting with 0xFA 0x02 checksum will be sent in next notify message so we
// will disable checking checksum for this particular packet
Timber.e("Checksum incorrect");
return;
}
if (value[0] != MAGIC_BYTE) {
Timber.w("Received unexpected, but correct data: %s", Arrays.toString(value));
return;
}
float weight;
switch (value[1]) {
case RESPONSE_INTERMEDIATE_MEASUREMENT:
// Got intermediate result
weight = Converters.fromUnsignedInt16Le(value, 4) / 10.0f;
Timber.d("Got intermediate weight: %.1f %s", weight, user.getScaleUnit().toString());
break;
case RESPONSE_FINAL_MEASUREMENT:
// Got final result
addScaleMeasurement(parseFinalMeasurement(value));
resumeMachineState();
break;
case RESPONSE_ACK:
// Got response from scale
Timber.d("Got ack from scale, can proceed");
resumeMachineState();
break;
default:
Timber.e("Got unexpected response: %x", value[1]);
}
}
private ScaleMeasurement parseFinalMeasurement(byte[] value) {
float weight = Converters.fromUnsignedInt16Le(value, 3) / 10.0f;
Timber.d("Got final weight: %.1f %s", weight, user.getScaleUnit().toString());
sendMessage(R.string.info_measuring, weight);
if (user.getScaleUnit() != Converters.WeightUnit.KG) {
// For lb and st this scale will always return result in lb
weight = Converters.toKilogram(weight, Converters.WeightUnit.LB);
}
ScaleMeasurement measurement = new ScaleMeasurement();
measurement.setDateTime(new Date());
measurement.setWeight(weight);
if (value[5] == (byte) 0x00) {
// If user stands bare foot on weight scale it will report more data
measurement.setFat(Converters.fromUnsignedInt16Le(value, 6) / 10.0f);
measurement.setWater(Converters.fromUnsignedInt16Le(value, 8) / 10.0f);
measurement.setMuscle(Converters.fromUnsignedInt16Le(value, 10) / 10.0f);
// Basal metabolic rate is not stored because it's calculated by app
// Bone weight seems to be always returned in kg
measurement.setBone(value[14] / 10.0f);
// BMI is not stored because it's calculated by app
measurement.setVisceralFat(Converters.fromUnsignedInt16Le(value, 17) / 10.0f);
// Internal body age is not stored in app
} else if (value[5] == (byte) 0x04) {
Timber.w("No more data to store");
} else {
Timber.e("Received unexpected value: %x", value[5]);
}
return measurement;
}
private void sendPacket(byte command, byte[] payload) {
// Add required fields to provided payload and send the packet
byte[] outputArray = new byte[payload.length + 4];
outputArray[0] = MAGIC_BYTE;
outputArray[1] = command;
outputArray[2] = (byte) payload.length;
System.arraycopy(payload, 0, outputArray, 3, payload.length);
// Calculate checksum skipping first element
outputArray[outputArray.length - 1] = xorChecksum(outputArray, 1, outputArray.length - 2);
writeBytes(UUID_SERVICE, UUID_CHARACTERISTIC, outputArray, true);
}
private boolean verifyData(byte[] data) {
// First byte is skipped in calculated checksum
return xorChecksum(data, 1, data.length - 1) == 0;
}
}

View File

@@ -1,805 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
// +++
import android.os.Handler;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
public class BluetoothHuaweiAH100 extends BluetoothCommunication {
private static final UUID SERVICE_AH100_CUSTOM_SERVICE = BluetoothGattUuid.fromShortCode(0xfaa0);
private static final UUID SERVICE_AH100_CUSTOM_SEND = BluetoothGattUuid.fromShortCode(0xfaa1);
private static final UUID SERVICE_AH100_CUSTOM_RECEIVE = BluetoothGattUuid.fromShortCode(0xfaa2);
// +++
private static byte[] user_id = {0, 0, 0, 0, 0, 0, 0};
private enum STEPS {
INIT,
INIT_W,
AUTHORISE,
SCALE_UNIT,
SCALE_TIME,
USER_INFO,
SCALE_VERSION,
WAIT_MEASUREMENT,
READ_HIST,
READ_HIST_NEXT,
EXIT,
BIND,
EXIT2
}
private static final byte AH100_NOTIFICATION_WAKEUP = 0x00;
private static final byte AH100_NOTIFICATION_GO_SLEEP = 0x01;
private static final byte AH100_NOTIFICATION_UNITS_SET = 0x02;
private static final byte AH100_NOTIFICATION_REMINDER_SET = 0x03;
private static final byte AH100_NOTIFICATION_SCALE_CLOCK = 0x08;
private static final byte AH100_NOTIFICATION_SCALE_VERSION = 0x0C;
private static final byte AH100_NOTIFICATION_MEASUREMENT = 0x0E;
private static final byte AH100_NOTIFICATION_MEASUREMENT2 = (byte) 0x8E;
private static final byte AH100_NOTIFICATION_MEASUREMENT_WEIGHT = 0x0F;
private static final byte AH100_NOTIFICATION_HISTORY_RECORD = 0x10;
private static final byte AH100_NOTIFICATION_HISTORY_RECORD2 = (byte) 0x90;
private static final byte AH100_NOTIFICATION_UPGRADE_RESPONSE = 0x11;
private static final byte AH100_NOTIFICATION_UPGRADE_RESULT = 0x12;
private static final byte AH100_NOTIFICATION_WEIGHT_OVERLOAD = 0x13;
private static final byte AH100_NOTIFICATION_LOW_POWER = 0x14;
private static final byte AH100_NOTIFICATION_MEASUREMENT_ERROR = 0x15;
private static final byte AH100_NOTIFICATION_SET_CLOCK_ACK = 0x16;
private static final byte AH100_NOTIFICATION_OTA_UPGRADE_READY = 0x17;
private static final byte AH100_NOTIFICATION_SCALE_MAC_RECEIVED = 0x18;
private static final byte AH100_NOTIFICATION_HISTORY_UPLOAD_DONE = 0x19;
private static final byte AH100_NOTIFICATION_USER_CHANGED = 0x20;
private static final byte AH100_NOTIFICATION_AUTHENTICATION_RESULT = 0x26;
private static final byte AH100_NOTIFICATION_BINDING_SUCCESSFUL = 0x27;
private static final byte AH100_NOTIFICATION_FIRMWARE_UPDATE_RECEIVED = 0x28;
private static final byte AH100_CMD_SET_UNIT = 2;
private static final byte AH100_CMD_DELETE_ALARM_CLOCK = 3;
private static final byte AH100_CMD_SET_ALARM_CLOCK = 4;
private static final byte AH100_CMD_DELETE_ALL_ALARM_CLOCK = 5;
private static final byte AH100_CMD_GET_ALARM_CLOCK_BY_NO = 6;
private static final byte AH100_CMD_SET_SCALE_CLOCK = 8;
private static final byte AH100_CMD_SELECT_USER = 10;
private static final byte AH100_CMD_USER_INFO = 9;
private static final byte AH100_CMD_GET_RECORD = 11;
private static final byte AH100_CMD_GET_VERSION = 12;
private static final byte AH100_CMD_GET_SCALE_CLOCK = 14;
private static final byte AH100_CMD_GET_USER_LIST_MARK = 15;
private static final byte AH100_CMD_UPDATE_SIGN = 16;
private static final byte AH100_CMD_DELETE_ALL_USER = 17;
private static final byte AH100_CMD_SET_BLE_BROADCAST_TIME = 18;
private static final byte AH100_CMD_FAT_RESULT_ACK = 19;
private static final byte AH100_CMD_GET_LAST_RECORD = 20;
private static final byte AH100_CMD_DISCONNECT_BT = 22;
private static final byte AH100_CMD_HEART_BEAT = 32;
private static final byte AH100_CMD_AUTH = 36;
private static final byte AH100_CMD_BIND_USER = 37;
private static final byte AH100_CMD_OTA_PACKAGE = (byte) 0xDD;
private Context context;
private byte[] authCode;
private byte[] initialKey ;
private byte[] initialValue ;
private byte[] magicKey ;
private int triesToAuth = 0;
private int triesToBind = 0;
private int lastMeasuredWeight = -1;
private boolean authorised = false;
private boolean scaleWakedUp = false;
private boolean scaleBinded = false;
private byte receivedPacketType = 0x00;
private byte[] receivedPacket1;
private Handler beatHandler;
public BluetoothHuaweiAH100(Context context) {
super(context);
this.context = context;
this.beatHandler = new Handler();
authCode = getUserID();
initialKey = hexToByteArray("3D A2 78 4A FB 87 B1 2A 98 0F DE 34 56 73 21 56");
initialValue = hexToByteArray("4E F7 64 32 2F DA 76 32 12 3D EB 87 90 FE A2 19");
}
@Override
public String driverName() {
return "Huawei AH100 Body Fat Scale";
}
///////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////
@Override
protected boolean onNextStep(int stepNr) {
STEPS step;
try {
step = STEPS.values()[stepNr];
} catch (Exception e) {
// stepNr is bigger then we have in STEPS
return false;
}
switch (step) {
case INIT:
// wait scale wake up
Timber.d("AH100::onNextStep step 0 = set notification");
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
// Setup notification
setNotificationOn(SERVICE_AH100_CUSTOM_SERVICE, SERVICE_AH100_CUSTOM_RECEIVE);
triesToAuth = 0;
authorised = false;
stopMachineState();
break;
case INIT_W:
stopMachineState();
break;
case AUTHORISE:
if ( scaleWakedUp == false ) {
jumpNextToStepNr( STEPS.INIT.ordinal() );
break;
}
// authorize in scale
Timber.d("AH100::onNextStep = authorize on scale");
triesToAuth++;
AHcmdAutorise();
stopMachineState();
break;
case SCALE_UNIT:
Timber.d("AH100::onNextStep = set scale unit");
AHcmdSetUnit();
stopMachineState();
break;
case SCALE_TIME:
Timber.d("AH100::onNextStep = set scale time");
AHcmdDate();
stopMachineState();
break;
case USER_INFO:
Timber.d("AH100::onNextStep = send user info to scale");
if ( !authorised ) {
jumpNextToStepNr( STEPS.AUTHORISE.ordinal() );
break;
}
// set user data
AHcmdUserInfo();
stopMachineState();
break;
case SCALE_VERSION:
Timber.d("AH100::onNextStep = request scale version");
if ( !authorised ) {
jumpNextToStepNr( STEPS.AUTHORISE.ordinal() );
break;
}
AHcmdGetVersion();
stopMachineState();
break;
case WAIT_MEASUREMENT:
AHcmdGetUserList();
Timber.d("AH100::onNextStep = Do nothing, wait while scale tries disconnect");
sendMessage(R.string.info_step_on_scale, 0);
stopMachineState();
break;
case READ_HIST:
Timber.d("AH100::onNextStep = read history record from scale");
if ( !authorised ) {
jumpNextToStepNr( STEPS.AUTHORISE.ordinal() );
break;
}
AHcmdReadHistory();
stopMachineState();
break;
case READ_HIST_NEXT:
Timber.d("AH100::onNextStep = read NEXT history record from scale");
if ( !authorised ) {
jumpNextToStepNr( STEPS.AUTHORISE.ordinal() );
break;
}
AHcmdReadHistoryNext();
stopMachineState();
break;
case EXIT:
Timber.d("AH100::onNextStep = Exit");
authorised = false;
scaleWakedUp = false;
stopHeartBeat();
disconnect();
return false;
case BIND:
Timber.d("AH100::onNextStep = BIND scale to OpenScale");
// Start measurement
sendMessage(R.string.info_step_on_scale, 0);
triesToBind++;
AHcmdBind();
AHcmdBind();
stopMachineState();
break;
case EXIT2:
authorised = false;
scaleWakedUp = false;
stopHeartBeat();
disconnect();
Timber.d("AH100::onNextStep = BIND Exit");
default:
return false;
}
return true;
}
///////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
byte cmdlength = 0;
Timber.d("AH100::onBluetoothNotify uuid: %s", characteristic.toString());
if (data != null && data.length > 2) {
Timber.d("===> New NOTIFY hex data: %s", byteInHex(data));
cmdlength = data[1];
// responce from scale received
switch (data[2]) {
case AH100_NOTIFICATION_WAKEUP:
scaleWakedUp = true;
if (getStepNr() - 1 == STEPS.INIT_W.ordinal() ) {
Timber.d("AH100::onNotify = Scale is waked up in Init-stage");
startHeartBeat();
resumeMachineState();
break;
}
// if (getStepNr() - 1 == STEPS.BIND.ordinal() ) {
// Timber.d("AH100::onNotify = Scale is waked up in Init-stage");
// jumpBackOneStep();
// resumeMachineState();
// break;
// }
Timber.d("AH100::onNotify = Scale is waked up");
// authorised = false;
// jumpNextToStepNr(STEPS.AUTHORISE.ordinal());
// resumeMachineState();
break;
case AH100_NOTIFICATION_GO_SLEEP:
resumeMachineState();
break;
case AH100_NOTIFICATION_UNITS_SET:
resumeMachineState();
break;
case AH100_NOTIFICATION_REMINDER_SET:
break;
case AH100_NOTIFICATION_SCALE_CLOCK:
resumeMachineState();
break;
case AH100_NOTIFICATION_SCALE_VERSION:
byte[] VERpayload = getPayload(data);
Timber.d("Get Scale Version: input data: %s", byteInHex(VERpayload));
resumeMachineState();
break;
case AH100_NOTIFICATION_MEASUREMENT:
if (data[0] == (byte) 0xBD) {
Timber.d("Scale plain response received");
}
if (data[0] == (byte) 0xBC) {
Timber.d("Scale encoded response received");
receivedPacket1 = Arrays.copyOfRange(data, 0, data.length );
receivedPacketType = AH100_NOTIFICATION_MEASUREMENT;
}
break;
case AH100_NOTIFICATION_MEASUREMENT2:
if (data[0] == (byte) 0xBC) { /// normal packet
Timber.d("Scale encoded response received");
if (receivedPacketType == AH100_NOTIFICATION_MEASUREMENT) {
AHrcvEncodedMeasurement(receivedPacket1, data, AH100_NOTIFICATION_MEASUREMENT);
receivedPacketType = 0x00;
if (scaleBinded == true) {
AHcmdMeasurementAck();
} else {
if (lastMeasuredWeight > 0) {
AHcmdUserInfo(lastMeasuredWeight);
}
}
jumpNextToStepNr( STEPS.READ_HIST.ordinal() );
resumeMachineState();
}
break;
}
if (data[0] == (byte) 0xBD) {
Timber.d("Scale plain response received");
}
jumpNextToStepNr( STEPS.INIT.ordinal() );
resumeMachineState();
break;
case AH100_NOTIFICATION_MEASUREMENT_WEIGHT:
break;
case AH100_NOTIFICATION_HISTORY_RECORD:
if (data[0] == (byte) 0xBD) {
Timber.d("Scale plain response received");
}
if (data[0] == (byte) 0xBC) {
Timber.d("Scale encoded response received");
receivedPacket1 = Arrays.copyOfRange(data, 0, data.length );
receivedPacketType = AH100_NOTIFICATION_HISTORY_RECORD;
}
break;
case AH100_NOTIFICATION_HISTORY_RECORD2:
if (data[0] == (byte) 0xBC) { /// normal packet
Timber.d("Scale encoded response received");
if (receivedPacketType == AH100_NOTIFICATION_HISTORY_RECORD) {
AHrcvEncodedMeasurement(receivedPacket1, data, AH100_NOTIFICATION_HISTORY_RECORD);
receivedPacketType = 0x00;
// todo: jumpback only in ReadHistoryNext
jumpNextToStepNr(STEPS.READ_HIST_NEXT.ordinal(),
STEPS.READ_HIST_NEXT.ordinal());
resumeMachineState();
}
break;
}
if (data[0] == (byte) 0xBD) {
Timber.d("Scale plain response received");
}
jumpNextToStepNr( STEPS.INIT.ordinal() );
resumeMachineState();
break;
case AH100_NOTIFICATION_UPGRADE_RESPONSE:
break;
case AH100_NOTIFICATION_UPGRADE_RESULT:
break;
case AH100_NOTIFICATION_WEIGHT_OVERLOAD:
break;
case AH100_NOTIFICATION_LOW_POWER:
break;
case AH100_NOTIFICATION_MEASUREMENT_ERROR:
break;
case AH100_NOTIFICATION_SET_CLOCK_ACK:
break;
case AH100_NOTIFICATION_OTA_UPGRADE_READY:
break;
case AH100_NOTIFICATION_SCALE_MAC_RECEIVED:
break;
case AH100_NOTIFICATION_HISTORY_UPLOAD_DONE:
resumeMachineState();
break;
case AH100_NOTIFICATION_USER_CHANGED:
resumeMachineState(STEPS.USER_INFO.ordinal()); // waiting wake up in state 4
break;
case AH100_NOTIFICATION_AUTHENTICATION_RESULT:
byte[] ARpayload = getPayload(data);
if ( 1 == ARpayload[0] ){
authorised = true;
magicKey = hexConcatenate(obfuscate(authCode) , Arrays.copyOfRange(initialKey, 7, initialKey.length ) );
resumeMachineState(STEPS.AUTHORISE.ordinal()); // waiting wake up in state 4
} else {
if (triesToAuth < 3){ // try again
jumpNextToStepNr(STEPS.AUTHORISE.ordinal());
} else { // bind scale to own code
jumpNextToStepNr(STEPS.BIND.ordinal());
}
resumeMachineState();
}
// acknowledge that you received the last history data
break;
case AH100_NOTIFICATION_BINDING_SUCCESSFUL:
// jump to authorise again
jumpNextToStepNr(STEPS.SCALE_TIME.ordinal());
scaleBinded = true;
// TODO: count binding tries
break;
case AH100_NOTIFICATION_FIRMWARE_UPDATE_RECEIVED:
break;
default:
break;
} // switch command
}
}
private void AHcmdHeartBeat() {
AHsendCommand(AH100_CMD_HEART_BEAT, new byte[0] );
}
private void AHcmdAutorise() {
AHsendCommand(AH100_CMD_AUTH, authCode);
}
private void AHcmdBind() {
AHsendCommand(AH100_CMD_BIND_USER, authCode);
}
private void AHcmdDate() {
/*
payload[0]: lowerByte(year)
payload[1]: upperByte(year)
payload[2]: month (1..12)
payload[3]: dayOfMonth
payload[4]: hourOfDay (0-23)
payload[5]: minute
payload[6]: second
payload[7]: day of week (Monday=1, Sunday=7)
*/
Calendar currentDateTime = Calendar.getInstance();
int year = currentDateTime.get(Calendar.YEAR);
byte month = (byte)(currentDateTime.get(Calendar.MONTH)+1);
byte day = (byte)currentDateTime.get(Calendar.DAY_OF_MONTH);
byte hour = (byte)currentDateTime.get(Calendar.HOUR_OF_DAY);
byte min = (byte)currentDateTime.get(Calendar.MINUTE);
byte sec = (byte)currentDateTime.get(Calendar.SECOND);
byte dow = (byte)currentDateTime.get(Calendar.DAY_OF_WEEK);
byte[] date = new byte[]{
0x00, 0x00, // year, fill later
month,
day,
hour,
min,
sec,
dow
};
Converters.toInt16Le(date, 0, year);
Timber.d("AH100::AHcmdDate: data to send: %s", byteInHex(date) );
AHsendCommand(AH100_CMD_SET_SCALE_CLOCK, date);
}
private void AHcmdUserInfo() {
///String user example = "27 af 00 2a 03 ff ff";
ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser();
int weight = (int) currentUser.getInitialWeight() * 10;
AHcmdUserInfo(weight);
}
private void AHcmdUserInfo(int weight) {
///String user example = "27 af 00 2a 03 ff ff";
/*
payload[7] = sex == 1 ? age | 0x80 : age
payload[8] = height of the user
payload[9] = 0
payload[10] = lowerByte(weight)
payload[11] = upperByte(weight)
payload[12] = lowerByte(impedance)
payload[13] = upperByte(impedance)
*/
ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser();
byte height = (byte) currentUser.getBodyHeight();
byte sex = currentUser.getGender().isMale() ? 0 : (byte) 0x80;
byte age = (byte) ( sex | ((byte) currentUser.getAge()) );
byte[] user = new byte[]{
age,
height,
0,
0x00, 0x00, // weight, fill later
(byte) 0xFF, (byte) 0xFF, // resistance, wkwtfdim
(byte) 0x1C, (byte) 0xE2,
};
Converters.toInt16Le(user, 3, weight);
byte[] userinfo = hexConcatenate( authCode, user );
AHsendCommand(AH100_CMD_USER_INFO, userinfo, 14);
}
private void AHcmdReadHistory() {
byte[] pl;
byte[] xp = {xorChecksum(authCode, 0, authCode.length)};
pl = hexConcatenate( authCode, xp );
AHsendCommand(AH100_CMD_GET_RECORD, pl, 0x07 - 1);
}
private void AHcmdReadHistoryNext() {
byte[] pl = {0x01};
AHsendCommand(AH100_CMD_GET_RECORD, pl);
}
private void AHcmdSetUnit() {
// TODO: set correct units
byte[] pl = new byte[]{0x01}; // 1 = kg; 2 = pounds. set kg only
AHsendCommand(AH100_CMD_SET_UNIT, pl);
}
private void AHcmdGetUserList() {
//byte[] pl = new byte[]{};
// byte[] pl = authCode;
// AHsendCommand(AH100_CMD_SELECT_USER, pl);
}
private void AHcmdGetVersion() {
byte[] pl = new byte[]{};
AHsendCommand(AH100_CMD_GET_VERSION, pl);
}
private void AHcmdMeasurementAck() {
byte[] pl = new byte[]{0x00};
AHsendCommand(AH100_CMD_FAT_RESULT_ACK, pl);
}
private void AHrcvEncodedMeasurement(byte[] encdata, byte[] encdata2, byte type) {
byte[] payload = getPayload(encdata);
byte[] data;
try{
data = decryptAES(payload, magicKey, initialValue);
Timber.d("Decrypted measurement: hex data: %s", byteInHex(data));
if ( (type == AH100_NOTIFICATION_MEASUREMENT) ||
(type == AH100_NOTIFICATION_HISTORY_RECORD) ) {
AHaddFatMeasurement(data);
}
} catch (Exception e) {
Timber.d("Decrypting FAIL!!!");
}
}
private void AHaddFatMeasurement(byte[] data) {
if (data.length < 14) {
Timber.d(":: AHaddFatMeasurement : data is too short. Expected at least 14 bytes of data." );
return ;
}
byte userid = data[0]; ///// Arrays.copyOfRange(data, 0, 0 );
lastMeasuredWeight = Converters.fromUnsignedInt16Le(data, 1);
float weight = lastMeasuredWeight / 10.0f;
float fat = Converters.fromUnsignedInt16Le(data, 3) / 10.0f;
int year = Converters.fromUnsignedInt16Le(data, 5) ;
int resistance = Converters.fromUnsignedInt16Le(data, 13) ;
byte month = (byte) (data[7] - 1); // 1..12 to zero-based month
byte dayInMonth = data[8];
byte hour = data[9];
byte minute = data[10];
byte second = data[11];
byte weekNumber = data[12];
Timber.d("---- measured userid %d",userid );
Timber.d("---- measured weight %f",weight );
Timber.d("---- measured fat %f",fat );
Timber.d("---- measured resistance %d",resistance );
Timber.d("---- measured year %d",year );
Timber.d("---- measured month %d",month );
Timber.d("---- measured dayInMonth %d",dayInMonth );
Timber.d("---- measured hour %d",hour );
Timber.d("---- measured minute %d",minute );
Timber.d("---- measured second %d",second );
Timber.d("---- measured week day %d",weekNumber );
///////////////////////////
Calendar calendar = Calendar.getInstance();
calendar.set( year, month, dayInMonth, hour, minute, second);
Date date = calendar.getTime();
ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser();
ScaleMeasurement receivedMeasurement = new ScaleMeasurement();
receivedMeasurement.setUserId(currentUser.getId());
receivedMeasurement.setDateTime( date );
receivedMeasurement.setWeight(weight);
receivedMeasurement.setFat(fat);
// receivedMeasurement.setWater(water);
// receivedMeasurement.setMuscle(muscle);
// receivedMeasurement.setBone(bone);
// todo: calculate water, muscle, bones
addScaleMeasurement(receivedMeasurement);
}
private void startHeartBeat() {
Timber.d("*** Heart beat started");
beatHandler.postDelayed(new Runnable() {
@Override
public void run() {
Timber.d("*** heart beat.");
AHcmdHeartBeat();
}
}, 2000); // 2 s
}
private void resetHeartBeat() {
Timber.d("*** 0 heart beat reset");
beatHandler.removeCallbacksAndMessages(null);
startHeartBeat();
}
private void stopHeartBeat() {
Timber.d("*** ! heart beat stopped");
beatHandler.removeCallbacksAndMessages(null);
}
private void AHsendCommand(byte cmd, byte[] payload ) {
AHsendCommand(cmd, payload, payload.length );
}
private void AHsendCommand(byte cmd, byte[] payload, int len ) {
resetHeartBeat();
if ( (cmd == AH100_CMD_USER_INFO) ) {
AHsendEncryptedCommand(cmd, payload, len);
return;
}
byte[] packet ;
byte[] header;
header = new byte[]{(byte) (0xDB),
(byte) (len + 1),
cmd};
packet = hexConcatenate( header, obfuscate(payload) );
try {
writeBytes(SERVICE_AH100_CUSTOM_SERVICE,
SERVICE_AH100_CUSTOM_SEND,
packet);
} catch (Exception e) {
Timber.d("AHsendCommand: CANNOT WRITE COMMAND");
stopHeartBeat();
}
}
private void AHsendEncryptedCommand(byte cmd, byte[] payload , int len ) {
byte[] packet ;
byte[] header;
byte[] encrypted;
Timber.d("AHsendEncryptedCommand: input data: %s", byteInHex(payload));
encrypted = encryptAES(payload, magicKey, initialValue); //encryptAES
header = new byte[]{(byte) (0xDC),
(byte) (len + 0 ),
cmd};
packet = hexConcatenate( header, obfuscate(encrypted) );
try {
writeBytes(SERVICE_AH100_CUSTOM_SERVICE,
SERVICE_AH100_CUSTOM_SEND,
packet);
} catch (Exception e) {
Timber.d("AHsendEncryptedCommand: CANNOT WRITE COMMAND");
stopHeartBeat();
}
}
public byte[] getUserID() {
ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser();
byte id = (byte) currentUser.getId();
byte[] auth = new byte[] {0x11, 0x22, 0x33, 0x44, 0x55, 0x00, id};
auth[5] = xorChecksum(auth, 0, auth.length); // set xor of authorise code to 0x00
return auth;
///// return getfakeUserID();
}
public byte[] getfakeUserID() {
String fid = "0f 00 43 06 7b 4e 7f"; // "c7b25de6bed0b7";
byte[] auth = hexToByteArray(fid) ;
return auth;
}
public byte[] encryptAES(byte[] data, byte[] key, byte[] ivs) {
Timber.d("Encoding : input hex data: %s", byteInHex(data));
Timber.d("Encoding : encoding key : %s", byteInHex(key));
Timber.d("Encoding : initial value : %s", byteInHex(ivs));
try {
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES");
byte[] finalIvs = new byte[16];
int len = ivs.length > 16 ? 16 : ivs.length;
System.arraycopy(ivs, 0, finalIvs, 0, len);
IvParameterSpec ivps = new IvParameterSpec(finalIvs);
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivps);
return cipher.doFinal(data);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
public byte[] decryptAES(byte[] data, byte[] key, byte[] ivs) {
Timber.d("Decoding : input hex data: %s", byteInHex(data));
Timber.d("Decoding : encoding key : %s", byteInHex(key));
Timber.d("Decoding : initial value : %s", byteInHex(ivs));
try {
Cipher cipher = Cipher.getInstance("AES/CTR/NoPadding");
SecretKeySpec secretKeySpec = new SecretKeySpec(key, "AES");
byte[] finalIvs = new byte[16];
int len = ivs.length > 16 ? 16 : ivs.length;
System.arraycopy(ivs, 0, finalIvs, 0, len);
IvParameterSpec ivps = new IvParameterSpec(finalIvs);
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivps);
byte[] ret = cipher.doFinal(data);
Timber.d("### decryptAES : hex data: %s", byteInHex(ret));
return ret;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
public byte[] hexToByteArray(String hexStr) {
String hex = hexStr.replaceAll (" ","").replaceAll (":","");
hex = hex.length()%2 != 0?"0"+hex:hex;
byte[] b = new byte[hex.length() / 2];
for (int i = 0; i < b.length; i++) {
int index = i * 2;
int v = Integer.parseInt(hex.substring(index, index + 2), 16);
b[i] = (byte) v;
}
return b;
}
public byte[] hexConcatenate(byte[] A, byte[] B) {
byte[] C = new byte[A.length + B.length];
ByteArrayOutputStream outputStream = new ByteArrayOutputStream( );
try {
outputStream.write( A );
outputStream.write( B );
C = outputStream.toByteArray( );
} catch (IOException e) {
e.printStackTrace();
}
return C;
}
public byte[] getPayload(byte[] data) {
byte[] obfpayload = Arrays.copyOfRange(data, 3, data.length );
byte[] payload = obfuscate(obfpayload);
Timber.d("Deobfuscated payload: %s", byteInHex(payload));
return payload;
}
private byte[] obfuscate(byte[] rawdata) {
final byte[] data = Arrays.copyOfRange(rawdata, 0, rawdata.length );
final byte[] MAC;
MAC = getScaleMacAddress();
Timber.d("Obfuscation: input hex data: %s", byteInHex(data));
//Timber.d("Obfuscation: MAC hex data: %s", byteInHex(MAC));
byte m = 0 ;
for(int l=0; l< data.length; l++,m++){
if (MAC.length <= m) { m = 0; }
data[l] ^= MAC[m];
}
//Timber.d("Obfuscation: out hex data: %s", byteInHex(data));
return data;
}
}

View File

@@ -1,264 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
* Copyright (C) 2018 John Lines <john+openscale@paladyn.org>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.content.Context;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothIhealthHS3 extends BluetoothCommunication {
private final UUID uuid = BluetoothGattUuid.fromShortCode(0x1101); // Standard SerialPortService ID
private BluetoothSocket btSocket = null;
private BluetoothDevice btDevice = null;
private BluetoothConnectedThread btConnectThread = null;
private byte[] lastWeight = new byte[2];
private Date lastWeighed = new Date();
private final long maxTimeDiff = 60000; // maximum time interval we will consider two identical
// weight readings to be the same and hence ignored - 60 seconds in milliseconds
public BluetoothIhealthHS3(Context context) {
super(context);
}
@Override
public String driverName() {
return "iHealth HS33FA4A";
}
@Override
protected boolean onNextStep(int stepNr) {
Timber.w("ihealthHS3 - onNextStep - returning false");
return false;
}
@Override
public void connect(String hwAddress) {
BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
if (btAdapter == null) {
setBluetoothStatus(BT_STATUS.NO_DEVICE_FOUND);
return;
}
btDevice = btAdapter.getRemoteDevice(hwAddress);
try {
// Get a BluetoothSocket to connect with the given BluetoothDevice
btSocket = btDevice.createRfcommSocketToServiceRecord(uuid);
} catch (IOException e) {
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Can't get a bluetooth socket");
btDevice = null;
return;
}
Thread socketThread = new Thread() {
@Override
public void run() {
try {
if (!btSocket.isConnected()) {
// Connect the device through the socket. This will block
// until it succeeds or throws an exception
btSocket.connect();
// Bluetooth connection was successful
setBluetoothStatus(BT_STATUS.CONNECTION_ESTABLISHED);
btConnectThread = new BluetoothConnectedThread();
btConnectThread.start();
}
} catch (IOException connectException) {
// Unable to connect; close the socket and get out
disconnect();
setBluetoothStatus(BT_STATUS.NO_DEVICE_FOUND);
}
}
};
socketThread.start();
}
@Override
public void disconnect() {
Timber.w("HS3 - disconnect");
if (btSocket != null) {
if (btSocket.isConnected()) {
try {
btSocket.close();
btSocket = null;
} catch (IOException closeException) {
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Can't close bluetooth socket");
}
}
}
if (btConnectThread != null) {
btConnectThread.cancel();
btConnectThread = null;
}
btDevice = null;
}
private boolean sendBtData(String data) {
Timber.w("ihealthHS3 - sendBtData %s", data);
if (btSocket.isConnected()) {
btConnectThread = new BluetoothConnectedThread();
btConnectThread.write(data.getBytes());
btConnectThread.cancel();
return true;
}
Timber.w("ihealthHS3 - sendBtData - socket is not connected");
return false;
}
private class BluetoothConnectedThread extends Thread {
private InputStream btInStream;
private OutputStream btOutStream;
private volatile boolean isCancel;
public BluetoothConnectedThread() {
// Timber.w("ihealthHS3 - BluetoothConnectedThread");
isCancel = false;
// Get the input and output bluetooth streams
try {
btInStream = btSocket.getInputStream();
btOutStream = btSocket.getOutputStream();
} catch (IOException e) {
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Can't get bluetooth input or output stream " + e.getMessage());
}
}
public void run() {
byte btByte;
byte[] weightBytes = new byte[2];
// Timber.w("ihealthHS3 - run");
// Keep listening to the InputStream until an exception occurs (e.g. device partner goes offline)
while (!isCancel) {
try {
// stream read is a blocking method
btByte = (byte) btInStream.read();
// Timber.w("iheathHS3 - seen a byte "+String.format("%02X",btByte));
if ( btByte == (byte) 0xA0 ) {
btByte = (byte) btInStream.read();
if ( btByte == (byte) 0x09 ) {
btByte = (byte) btInStream.read();
if ( btByte == (byte) 0xa6 ) {
btByte = (byte) btInStream.read();
if ( btByte == (byte) 0x28 ) {
// Timber.w("seen 0xa009a628 - Weight packet");
// deal with a weight packet - read 5 bytes we dont care about
btByte = (byte) btInStream.read();
btByte = (byte) btInStream.read();
btByte = (byte) btInStream.read();
btByte = (byte) btInStream.read();
btByte = (byte) btInStream.read();
// and the weight - which should follow
weightBytes[0] = (byte) btInStream.read();
weightBytes[1] = (byte) btInStream.read();
ScaleMeasurement scaleMeasurement = parseWeightArray(weightBytes);
if (scaleMeasurement != null) {
addScaleMeasurement(scaleMeasurement);
}
}
else if (btByte == (byte) 0x33 ) {
Timber.w("seen 0xa009a633 - time packet");
// deal with a time packet, if needed
} else {
Timber.w("iHealthHS3 - seen byte after control leader %02X", btByte);
}
}
}
}
} catch (IOException e) {
cancel();
setBluetoothStatus(BT_STATUS.CONNECTION_LOST);
}
}
}
private ScaleMeasurement parseWeightArray(byte[] weightBytes ) throws IOException {
ScaleMeasurement scaleBtData = new ScaleMeasurement();
// Timber.w("iHealthHS3 - ScaleMeasurement "+String.format("%02X",weightBytes[0])+String.format("%02X",weightBytes[1]));
String ws = String.format("%02X",weightBytes[0])+String.format("%02X",weightBytes[1]);
StringBuilder ws1 = new StringBuilder (ws);
ws1.insert(ws.length()-1,".");
float weight = Float.parseFloat(ws1.toString());
// Timber.w("iHealthHS3 - ScaleMeasurement "+String.format("%f",weight));
Date now = new Date();
// If the weight is the same as the lastWeight, and the time since the last reading is less than maxTimeDiff then return null
if (Arrays.equals(weightBytes,lastWeight) && (now.getTime() - lastWeighed.getTime() < maxTimeDiff)) {
// Timber.w("iHealthHS3 - parseWeightArray returning null");
return null;
}
scaleBtData.setDateTime(now);
scaleBtData.setWeight(weight);
lastWeighed = now;
System.arraycopy(weightBytes,0,lastWeight,0,lastWeight.length);
return scaleBtData;
}
public void write(byte[] bytes) {
try {
btOutStream.write(bytes);
} catch (IOException e) {
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Error while writing to bluetooth socket " + e.getMessage());
}
}
public void cancel() {
isCancel = true;
}
}
}

View File

@@ -1,235 +0,0 @@
/* Copyright (C) 2018 Erik Johansson <erik@ejohansson.se>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.Arrays;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothInlife extends BluetoothCommunication {
private final UUID WEIGHT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1);
private final UUID WEIGHT_CMD_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff2);
private final byte START_BYTE = 0x02;
private final byte END_BYTE = (byte)0xaa;
private byte[] lastData = null;
private int getAthleteLevel(ScaleUser scaleUser) {
switch (scaleUser.getActivityLevel()) {
case SEDENTARY:
case MILD:
return 0; // General
case MODERATE:
return 1; // Amateur
case HEAVY:
case EXTREME:
return 2; // Profession
}
return 0;
}
private void sendCommand(int command, byte... parameters) {
byte[] data = {START_BYTE, (byte)command, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, END_BYTE};
int i = 2;
for (byte parameter : parameters) {
data[i++] = parameter;
}
data[data.length - 2] = xorChecksum(data, 1, data.length - 3);
writeBytes(WEIGHT_SERVICE, WEIGHT_CMD_CHARACTERISTIC, data);
}
public BluetoothInlife(Context context) {
super(context);
}
@Override
public String driverName() {
return "Inlinfe";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
setNotificationOn(WEIGHT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC);
break;
case 1:
ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
byte level = (byte)(getAthleteLevel(scaleUser) + 1);
byte sex = (byte)scaleUser.getGender().toInt();
byte userId = (byte)scaleUser.getId();
byte age = (byte)scaleUser.getAge();
byte height = (byte)scaleUser.getBodyHeight();
sendCommand(0xd2, level, sex, userId, age, height);
break;
case 2:
sendMessage(R.string.info_step_on_scale, 0);
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (data == null || data.length != 14) {
return;
}
if (data[0] != START_BYTE || data[data.length - 1] != END_BYTE) {
Timber.e("Wrong start or end byte");
return;
}
if (xorChecksum(data, 1, data.length - 2) != 0) {
Timber.e("Invalid checksum");
return;
}
if (Arrays.equals(data, lastData)) {
Timber.d("Ignoring duplicate data");
return;
}
lastData = data;
switch (data[1]) {
case (byte) 0x0f:
Timber.d("Scale disconnecting");
break;
case (byte) 0xd8:
float weight = Converters.fromUnsignedInt16Be(data, 2) / 10.0f;
Timber.d("Current weight %.2f kg", weight);
sendMessage(R.string.info_measuring, weight);
break;
case (byte) 0xdd:
if (data[11] == (byte) 0x80 || data[11] == (byte) 0x81) {
processMeasurementDataNewVersion(data);
}
else {
processMeasurementData(data);
}
break;
case (byte) 0xdf:
Timber.d("User data acked by scale: %s", data[2] == 0 ? "OK" : "error");
break;
default:
Timber.d("Unknown command 0x%02x", data[1]);
break;
}
}
void processMeasurementData(byte[] data) {
float weight = Converters.fromUnsignedInt16Be(data, 2) / 10.0f;
float lbm = Converters.fromUnsignedInt24Be(data, 4) / 1000.0f;
float visceralFactor = Converters.fromUnsignedInt16Be(data, 7) / 10.0f;
float bmr = Converters.fromUnsignedInt16Be(data, 9) / 10.0f;
if (lbm == 0xffffff / 1000.0f) {
Timber.e("Measurement failed; feet not correctly placed on scale?");
return;
}
Timber.d("weight=%.1f, LBM=%.3f, visceral factor=%.1f, BMR=%.1f",
weight, lbm, visceralFactor, bmr);
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
switch (getAthleteLevel(selectedUser)) {
case 0:
break;
case 1:
lbm *= 1.0427f;
break;
case 2:
lbm *= 1.0958f;
break;
}
final float fatKg = weight - lbm;
final double fat = (fatKg / weight) * 100.0;
final double water = (0.73f * (weight - fatKg) / weight) * 100.0;
final double muscle = (0.548 * lbm / weight) * 100.0;
final double bone = 0.05158 * lbm;
final float height = selectedUser.getBodyHeight();
double visceral = visceralFactor - 50;
if (selectedUser.getGender().isMale()) {
if (height >= 1.6 * weight + 63) {
visceral += (0.765 - 0.002 * height) * weight;
}
else {
visceral += 380 * weight / (((0.0826 * height * height) - 0.4 * height) + 48);
}
}
else {
if (weight <= height / 2 - 13) {
visceral += (0.691 - 0.0024 * height) * weight;
}
else {
visceral += 500 * weight / (((0.1158 * height * height) + 1.45 * height) - 120);
}
}
if (getAthleteLevel(selectedUser) != 0) {
if (visceral >= 21) {
visceral *= 0.85;
}
if (visceral >= 10) {
visceral *= 0.8;
}
visceral -= getAthleteLevel(selectedUser) * 2;
}
ScaleMeasurement measurement = new ScaleMeasurement();
measurement.setWeight(weight);
measurement.setFat(clamp(fat, 5, 80));
measurement.setWater(clamp(water, 5, 80));
measurement.setMuscle(clamp(muscle, 5, 80));
measurement.setBone(clamp(bone, 0.5, 8));
measurement.setLbm(lbm);
measurement.setVisceralFat(clamp(visceral, 1, 50));
addScaleMeasurement(measurement);
sendCommand(0xd4);
}
void processMeasurementDataNewVersion(byte[] data) {
float weight = Converters.fromUnsignedInt16Be(data, 2) / 10.0f;
long impedance = Converters.fromUnsignedInt32Be(data, 4);
Timber.d("weight=%.2f, impedance=%d", weight, impedance);
// Uses the same library as 1byone, but we need someone that has the scale to be able to
// test if it works the same way.
}
}

View File

@@ -1,201 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
* 2017 DreamNik <dreamnik@mail.ru>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;
public class BluetoothMGB extends BluetoothCommunication {
private static final UUID uuid_service = BluetoothGattUuid.fromShortCode(0xffb0);
private static final UUID uuid_char_cfg = BluetoothGattUuid.fromShortCode(0xffb1);
private static final UUID uuid_char_ctrl = BluetoothGattUuid.fromShortCode(0xffb2);
private Calendar now;
private ScaleUser user;
private ScaleMeasurement measurement;
private byte[] packet_buf;
private int packet_pos;
private int popInt() {
return packet_buf[packet_pos++] & 0xFF;
}
private float popFloat() {
int r = popInt();
r = popInt() | (r<<8);
return r * 0.1f;
}
private void writeCfg(int b2, int b3, int b4, int b5) {
byte[] buf = new byte[8];
buf[0] = (byte)0xAC;
buf[1] = (byte)0x02;
buf[2] = (byte)b2;
buf[3] = (byte)b3;
buf[4] = (byte)b4;
buf[5] = (byte)b5;
buf[6] = (byte)0xCC;
buf[7] = (byte)((buf[2] + buf[3] + buf[4] + buf[5] + buf[6]) & 0xFF);
writeBytes(uuid_service, uuid_char_cfg, buf, true);
}
public BluetoothMGB(Context context) {
super(context);
}
@Override
public String driverName() {
return "SWAN";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
setNotificationOn(uuid_service, uuid_char_ctrl);
now = Calendar.getInstance();
user = OpenScale.getInstance().getSelectedScaleUser();
break;
case 1:
writeCfg(0xF7, 0, 0, 0);
break;
case 2:
writeCfg(0xFA, 0, 0, 0);
break;
case 3:
writeCfg(0xFB, (user.getGender().isMale() ? 1 : 2), user.getAge(), (int)user.getBodyHeight());
break;
case 4:
writeCfg(0xFD, now.get(Calendar.YEAR) - 2000, now.get(Calendar.MONTH) - Calendar.JANUARY + 1, now.get(Calendar.DAY_OF_MONTH));
break;
case 5:
writeCfg(0xFC, now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND));
break;
case 6:
writeCfg(0xFE, 6, user.getScaleUnit().toInt(), 0);
break;
case 7:
sendMessage(R.string.info_step_on_scale, 0);
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
packet_buf = value;
packet_pos = 0;
if (packet_buf == null || packet_buf.length <= 0) {
return;
}
if (packet_buf.length != 20) {
return;
}
int hdr_1 = popInt();
int hdr_2 = popInt();
int hdr_3 = popInt();
if (hdr_1 == 0xAC && (hdr_2 == 0x02 || hdr_2 == 0x03) && hdr_3 == 0xFF) {
measurement = new ScaleMeasurement();
popInt(); //unknown =00
popInt(); //unknown =02
popInt(); //unknown =21
popInt(); //Year
popInt(); //Month
popInt(); //Day
popInt(); //Hour
popInt(); //Minute
popInt(); //Second
measurement.setDateTime(new Date());
measurement.setWeight(popFloat());
popFloat(); //BMI
measurement.setFat(popFloat());
popInt(); //unknown =00
popInt(); //unknown =00
}
else if (measurement != null && hdr_1 == 0x01 && hdr_2 == 0x00) {
measurement.setMuscle(popFloat());
popFloat(); //BMR
measurement.setBone(popFloat());
measurement.setWater(popFloat());
popInt(); // Age
popFloat();// protein rate
popInt(); // unknown =00
popInt(); // unknown =01
popInt(); // unknown =1b
popInt(); // unknown =a5
popInt(); // unknown =02
popInt(); // unknown =47;48;4e;4b;42
addScaleMeasurement(measurement);
// Visceral fat?
// Standard weight?
// WeightControl?
// Body fat?
// Muscle weight?
}
}
}

View File

@@ -1,127 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.utils.Converters;
import java.util.Date;
import java.util.UUID;
public class BluetoothMedisanaBS44x extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0x78b2);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x8a21); // indication, read-only
private final UUID FEATURE_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x8a22); // indication, read-only
private final UUID CMD_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x8a81); // write-only
private final UUID CUSTOM5_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0x8a82); // indication, read-only
private ScaleMeasurement btScaleMeasurement;
private boolean applyOffset;
// Scale time is in seconds since 2010-01-01
private static final long SCALE_UNIX_TIMESTAMP_OFFSET = 1262304000;
public BluetoothMedisanaBS44x(Context context, boolean applyOffset) {
super(context);
btScaleMeasurement = new ScaleMeasurement();
this.applyOffset = applyOffset;
}
@Override
public String driverName() {
return "Medisana BS44x";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
// set indication on for feature characteristic
setIndicationOn(WEIGHT_MEASUREMENT_SERVICE, FEATURE_MEASUREMENT_CHARACTERISTIC);
break;
case 1:
// set indication on for weight measurement
setIndicationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC);
break;
case 2:
// set indication on for custom5 measurement
setIndicationOn(WEIGHT_MEASUREMENT_SERVICE, CUSTOM5_MEASUREMENT_CHARACTERISTIC);
break;
case 3:
// send magic number to receive weight data
long timestamp = new Date().getTime() / 1000;
if(applyOffset){
timestamp -= SCALE_UNIX_TIMESTAMP_OFFSET;
}
byte[] date = Converters.toInt32Le(timestamp);
byte[] magicBytes = new byte[] {(byte)0x02, date[0], date[1], date[2], date[3]};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, magicBytes);
break;
case 4:
sendMessage(R.string.info_step_on_scale, 0);
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (characteristic.equals(WEIGHT_MEASUREMENT_CHARACTERISTIC)) {
parseWeightData(data);
}
if (characteristic.equals(FEATURE_MEASUREMENT_CHARACTERISTIC)) {
parseFeatureData(data);
addScaleMeasurement(btScaleMeasurement);
}
}
private void parseWeightData(byte[] weightData) {
float weight = Converters.fromUnsignedInt16Le(weightData, 1) / 100.0f;
long timestamp = Converters.fromUnsignedInt32Le(weightData, 5);
if (applyOffset) {
timestamp += SCALE_UNIX_TIMESTAMP_OFFSET;
}
btScaleMeasurement.setDateTime(new Date(timestamp * 1000));
btScaleMeasurement.setWeight(weight);
}
private void parseFeatureData(byte[] featureData) {
//btScaleData.setKCal(Converters.fromUnsignedInt16Le(featureData, 6));
btScaleMeasurement.setFat(decodeFeature(featureData, 8));
btScaleMeasurement.setWater(decodeFeature(featureData, 10));
btScaleMeasurement.setMuscle(decodeFeature(featureData, 12));
btScaleMeasurement.setBone(decodeFeature(featureData, 14));
}
private float decodeFeature(byte[] featureData, int offset) {
return (Converters.fromUnsignedInt16Le(featureData, offset) & 0x0FFF) / 10.0f;
}
}

View File

@@ -1,246 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.Random;
import java.util.UUID;
import timber.log.Timber;
import static com.health.openscale.core.bluetooth.BluetoothCommunication.BT_STATUS.UNEXPECTED_ERROR;
public class BluetoothMiScale extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = UUID.fromString("0000181d-0000-1000-8000-00805f9b34fb");
private final UUID WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC = UUID.fromString("00002a2f-0000-3512-2118-0009af100700");
public BluetoothMiScale(Context context) {
super(context);
}
@Override
public String driverName() {
return "Xiaomi Mi Scale v1";
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME)) {
byte[] data = value;
int currentYear = Calendar.getInstance().get(Calendar.YEAR);
int currentMonth = Calendar.getInstance().get(Calendar.MONTH) + 1;
int currentDay = Calendar.getInstance().get(Calendar.DAY_OF_MONTH);
int scaleYear = ((data[1] & 0xFF) << 8) | (data[0] & 0xFF);
int scaleMonth = (int) data[2];
int scaleDay = (int) data[3];
if (!(currentYear == scaleYear && currentMonth == scaleMonth && currentDay == scaleDay)) {
Timber.d("Current year and scale year is different");
// set current time
Calendar currentDateTime = Calendar.getInstance();
int year = currentDateTime.get(Calendar.YEAR);
byte month = (byte) (currentDateTime.get(Calendar.MONTH) + 1);
byte day = (byte) currentDateTime.get(Calendar.DAY_OF_MONTH);
byte hour = (byte) currentDateTime.get(Calendar.HOUR_OF_DAY);
byte min = (byte) currentDateTime.get(Calendar.MINUTE);
byte sec = (byte) currentDateTime.get(Calendar.SECOND);
byte[] dateTimeByte = {(byte) (year), (byte) (year >> 8), month, day, hour, min, sec, 0x03, 0x00, 0x00};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, dateTimeByte);
}
} else {
final byte[] data = value;
if (data != null && data.length > 0) {
// Stop command from mi scale received
if (data[0] == 0x03) {
// send stop command to mi scale
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x03});
// acknowledge that you received the last history data
int uniqueNumber = getUniqueNumber();
byte[] userIdentifier = new byte[]{(byte) 0x04, (byte) 0xFF, (byte) 0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier);
resumeMachineState();
}
if (data.length == 20) {
final byte[] firstWeight = Arrays.copyOfRange(data, 0, 10);
final byte[] secondWeight = Arrays.copyOfRange(data, 10, 20);
parseBytes(firstWeight);
parseBytes(secondWeight);
}
if (data.length == 10) {
parseBytes(data);
}
}
}
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
// read device time
readBytes(WEIGHT_MEASUREMENT_SERVICE, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME);
break;
case 1:
// Set on history weight measurement
byte[] magicBytes = new byte[]{(byte)0x01, (byte)0x96, (byte)0x8a, (byte)0xbd, (byte)0x62};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, magicBytes);
break;
case 2:
// set notification on for weight measurement history
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC);
break;
case 3:
// set notification on for weight measurement
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, BluetoothGattUuid.CHARACTERISTIC_WEIGHT_MEASUREMENT);
break;
case 4:
// configure scale to get only last measurements
int uniqueNumber = getUniqueNumber();
byte[] userIdentifier = new byte[]{(byte)0x01, (byte)0xFF, (byte)0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier);
break;
case 5:
// invoke receiving history data
writeBytes(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x02});
stopMachineState();
break;
default:
return false;
}
return true;
}
private void parseBytes(byte[] weightBytes) {
try {
final byte ctrlByte = weightBytes[0];
final boolean isWeightRemoved = isBitSet(ctrlByte, 7);
final boolean isStabilized = isBitSet(ctrlByte, 5);
final boolean isLBSUnit = isBitSet(ctrlByte, 0);
final boolean isCattyUnit = isBitSet(ctrlByte, 4);
/*Timber.d("IsWeightRemoved: " + isBitSet(ctrlByte, 7));
Timber.d("6 LSB Unknown: " + isBitSet(ctrlByte, 6));
Timber.d("IsStabilized: " + isBitSet(ctrlByte, 5));
Timber.d("IsCattyOrKg: " + isBitSet(ctrlByte, 4));
Timber.d("3 LSB Unknown: " + isBitSet(ctrlByte, 3));
Timber.d("2 LSB Unknown: " + isBitSet(ctrlByte, 2));
Timber.d("1 LSB Unknown: " + isBitSet(ctrlByte, 1));
Timber.d("IsLBS: " + isBitSet(ctrlByte, 0));*/
// Only if the value is stabilized and the weight is *not* removed, the date is valid
if (isStabilized && !isWeightRemoved) {
final int year = ((weightBytes[4] & 0xFF) << 8) | (weightBytes[3] & 0xFF);
final int month = (int) weightBytes[5];
final int day = (int) weightBytes[6];
final int hours = (int) weightBytes[7];
final int min = (int) weightBytes[8];
final int sec = (int) weightBytes[9];
float weight;
if (isLBSUnit || isCattyUnit) {
weight = (float) (((weightBytes[2] & 0xFF) << 8) | (weightBytes[1] & 0xFF)) / 100.0f;
} else {
weight = (float) (((weightBytes[2] & 0xFF) << 8) | (weightBytes[1] & 0xFF)) / 200.0f;
}
String date_string = year + "/" + month + "/" + day + "/" + hours + "/" + min;
Date date_time = new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string);
// Is the year plausible? Check if the year is in the range of 20 years...
if (validateDate(date_time, 20)) {
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
ScaleMeasurement scaleBtData = new ScaleMeasurement();
scaleBtData.setWeight(Converters.toKilogram(weight, selectedUser.getScaleUnit()));
scaleBtData.setDateTime(date_time);
addScaleMeasurement(scaleBtData);
} else {
Timber.e("Invalid Mi scale weight year %d", year);
}
}
} catch (ParseException e) {
setBluetoothStatus(UNEXPECTED_ERROR, "Error while decoding bluetooth date string (" + e.getMessage() + ")");
}
}
private boolean validateDate(Date weightDate, int range) {
Calendar currentDatePos = Calendar.getInstance();
currentDatePos.add(Calendar.YEAR, range);
Calendar currentDateNeg = Calendar.getInstance();
currentDateNeg.add(Calendar.YEAR, -range);
if (weightDate.before(currentDatePos.getTime()) && weightDate.after(currentDateNeg.getTime())) {
return true;
}
return false;
}
private int getUniqueNumber() {
int uniqueNumber;
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
uniqueNumber = prefs.getInt("uniqueNumber", 0x00);
if (uniqueNumber == 0x00) {
Random r = new Random();
uniqueNumber = r.nextInt(65535 - 100 + 1) + 100;
prefs.edit().putInt("uniqueNumber", uniqueNumber).apply();
}
int userId = OpenScale.getInstance().getSelectedScaleUserId();
return uniqueNumber + userId;
}
}

View File

@@ -1,239 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import static com.health.openscale.core.bluetooth.BluetoothCommunication.BT_STATUS.UNEXPECTED_ERROR;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.bluetooth.lib.MiScaleLib;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Random;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothMiScale2 extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC = UUID.fromString("00002a2f-0000-3512-2118-0009af100700");
private final UUID WEIGHT_CUSTOM_SERVICE = UUID.fromString("00001530-0000-3512-2118-0009af100700");
private final UUID WEIGHT_CUSTOM_CONFIG = UUID.fromString("00001542-0000-3512-2118-0009af100700");
public BluetoothMiScale2(Context context) {
super(context);
}
@Override
public String driverName() {
return "Xiaomi Mi Scale v2";
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (data != null && data.length > 0) {
Timber.d("DataChange hex data: %s", byteInHex(data));
// Stop command from mi scale received
if (data[0] == 0x03) {
Timber.d("Scale stop byte received");
// send stop command to mi scale
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x03});
// acknowledge that you received the last history data
int uniqueNumber = getUniqueNumber();
byte[] userIdentifier = new byte[]{(byte)0x04, (byte)0xFF, (byte)0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier);
resumeMachineState();
}
if (data.length == 13) {
parseBytes(data);
}
}
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
// set scale units
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
byte[] setUnitCmd = new byte[]{(byte)0x06, (byte)0x04, (byte)0x00, (byte) selectedUser.getScaleUnit().toInt()};
writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CONFIG, setUnitCmd);
break;
case 1:
// set current time
Calendar currentDateTime = Calendar.getInstance();
int year = currentDateTime.get(Calendar.YEAR);
byte month = (byte)(currentDateTime.get(Calendar.MONTH)+1);
byte day = (byte)currentDateTime.get(Calendar.DAY_OF_MONTH);
byte hour = (byte)currentDateTime.get(Calendar.HOUR_OF_DAY);
byte min = (byte)currentDateTime.get(Calendar.MINUTE);
byte sec = (byte)currentDateTime.get(Calendar.SECOND);
byte[] dateTimeByte = {(byte)(year), (byte)(year >> 8), month, day, hour, min, sec, 0x03, 0x00, 0x00};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, dateTimeByte);
break;
case 2:
// set notification on for weight measurement history
setNotificationOn(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC);
break;
case 3:
// configure scale to get only last measurements
int uniqueNumber = getUniqueNumber();
byte[] userIdentifier = new byte[]{(byte)0x01, (byte)0xFF, (byte)0xFF, (byte) ((uniqueNumber & 0xFF00) >> 8), (byte) ((uniqueNumber & 0xFF) >> 0)};
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, userIdentifier);
break;
case 4:
// invoke receiving history data
writeBytes(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, WEIGHT_MEASUREMENT_HISTORY_CHARACTERISTIC, new byte[]{0x02});
stopMachineState();
break;
default:
return false;
}
return true;
}
private void parseBytes(byte[] data) {
try {
final byte ctrlByte0 = data[0];
final byte ctrlByte1 = data[1];
final boolean isWeightRemoved = isBitSet(ctrlByte1, 7);
final boolean isStabilized = isBitSet(ctrlByte1, 5);
final boolean isLBSUnit = isBitSet(ctrlByte0, 0);
final boolean isCattyUnit = isBitSet(ctrlByte1, 6);
final boolean isImpedance = isBitSet(ctrlByte1, 1);
if (isStabilized && !isWeightRemoved) {
final int year = ((data[3] & 0xFF) << 8) | (data[2] & 0xFF);
final int month = (int) data[4];
final int day = (int) data[5];
final int hours = (int) data[6];
final int min = (int) data[7];
final int sec = (int) data[8];
float weight;
float impedance = 0.0f;
if (isLBSUnit || isCattyUnit) {
weight = (float) (((data[12] & 0xFF) << 8) | (data[11] & 0xFF)) / 100.0f;
} else {
weight = (float) (((data[12] & 0xFF) << 8) | (data[11] & 0xFF)) / 200.0f;
}
if (isImpedance) {
impedance = ((data[10] & 0xFF) << 8) | (data[9] & 0xFF);
Timber.d("impedance value is " + impedance);
}
String date_string = year + "/" + month + "/" + day + "/" + hours + "/" + min;
Date date_time = new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string);
// Is the year plausible? Check if the year is in the range of 20 years...
if (validateDate(date_time, 20)) {
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
ScaleMeasurement scaleBtData = new ScaleMeasurement();
scaleBtData.setWeight(Converters.toKilogram(weight, scaleUser.getScaleUnit()));
scaleBtData.setDateTime(date_time);
int sex;
if (scaleUser.getGender() == Converters.Gender.MALE) {
sex = 1;
} else {
sex = 0;
}
if (impedance != 0.0f) {
MiScaleLib miScaleLib = new MiScaleLib(sex, scaleUser.getAge(), scaleUser.getBodyHeight());
scaleBtData.setWater(miScaleLib.getWater(weight, impedance));
scaleBtData.setVisceralFat(miScaleLib.getVisceralFat(weight));
scaleBtData.setFat(miScaleLib.getBodyFat(weight, impedance));
scaleBtData.setMuscle((100.0f / weight) * miScaleLib.getMuscle(weight, impedance)); // convert muscle in kg to percent
scaleBtData.setLbm(miScaleLib.getLBM(weight, impedance));
scaleBtData.setBone(miScaleLib.getBoneMass(weight, impedance));
} else {
Timber.d("Impedance value is zero");
}
addScaleMeasurement(scaleBtData);
} else {
Timber.e("Invalid Mi scale weight year %d", year);
}
}
} catch (ParseException e) {
setBluetoothStatus(UNEXPECTED_ERROR, "Error while decoding bluetooth date string (" + e.getMessage() + ")");
}
}
private boolean validateDate(Date weightDate, int range) {
Calendar currentDatePos = Calendar.getInstance();
currentDatePos.add(Calendar.YEAR, range);
Calendar currentDateNeg = Calendar.getInstance();
currentDateNeg.add(Calendar.YEAR, -range);
if (weightDate.before(currentDatePos.getTime()) && weightDate.after(currentDateNeg.getTime())) {
return true;
}
return false;
}
private int getUniqueNumber() {
int uniqueNumber;
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
uniqueNumber = prefs.getInt("uniqueNumber", 0x00);
if (uniqueNumber == 0x00) {
Random r = new Random();
uniqueNumber = r.nextInt(65535 - 100 + 1) + 100;
prefs.edit().putInt("uniqueNumber", uniqueNumber).apply();
}
int userId = OpenScale.getInstance().getSelectedScaleUserId();
return uniqueNumber + userId;
}
}

View File

@@ -1,178 +0,0 @@
package com.health.openscale.core.bluetooth;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.SparseArray;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.welie.blessed.BluetoothCentralManager;
import com.welie.blessed.BluetoothCentralManagerCallback;
import com.welie.blessed.BluetoothPeripheral;
import org.jetbrains.annotations.NotNull;
import java.util.LinkedList;
import java.util.List;
import timber.log.Timber;
public class BluetoothOKOK extends BluetoothCommunication {
private static final int MANUFACTURER_DATA_ID_V20 = 0x20ca; // 16-bit little endian "header" 0xca 0x20
private static final int MANUFACTURER_DATA_ID_V11 = 0x11ca; // 16-bit little endian "header" 0xca 0x11
private static final int MANUFACTURER_DATA_ID_VF0 = 0xf0ff; // 16-bit little endian "header" 0xff 0xf0
private static final int IDX_V20_FINAL = 6;
private static final int IDX_V20_WEIGHT_MSB = 8;
private static final int IDX_V20_WEIGHT_LSB = 9;
private static final int IDX_V20_IMPEDANCE_MSB = 10;
private static final int IDX_V20_IMPEDANCE_LSB = 11;
private static final int IDX_V20_CHECKSUM = 12;
private static final int IDX_V11_WEIGHT_MSB = 3;
private static final int IDX_V11_WEIGHT_LSB = 4;
private static final int IDX_V11_BODY_PROPERTIES = 9;
private static final int IDX_V11_CHECKSUM = 16;
private static final int IDX_VF0_WEIGHT_MSB = 3;
private static final int IDX_VF0_WEIGHT_LSB = 2;
private BluetoothCentralManager central;
private final BluetoothCentralManagerCallback btCallback = new BluetoothCentralManagerCallback() {
@Override
public void onDiscoveredPeripheral(@NotNull BluetoothPeripheral peripheral, @NotNull ScanResult scanResult) {
SparseArray<byte[]> manufacturerSpecificData = scanResult.getScanRecord().getManufacturerSpecificData();
if (manufacturerSpecificData.indexOfKey(MANUFACTURER_DATA_ID_V20) > -1) {
byte[] data = manufacturerSpecificData.get(MANUFACTURER_DATA_ID_V20);
float divider = 10.0f;
byte checksum = 0x20; // Version field is part of the checksum, but not in array
if (data == null || data.length != 19)
return;
if ((data[IDX_V20_FINAL] & 1) == 0)
return;
for (int i = 0; i < IDX_V20_CHECKSUM; i++)
checksum ^= data[i];
if (data[IDX_V20_CHECKSUM] != checksum) {
Timber.d("Checksum error, got %x, expected %x", data[IDX_V20_CHECKSUM] & 0xff, checksum & 0xff);
return;
}
if ((data[IDX_V20_FINAL] & 4) == 4)
divider = 100.0f;
int weight = data[IDX_V20_WEIGHT_MSB] & 0xff;
weight = weight << 8 | (data[IDX_V20_WEIGHT_LSB] & 0xff);
int impedance = data[IDX_V20_IMPEDANCE_MSB] & 0xff;
impedance = impedance << 8 | (data[IDX_V20_IMPEDANCE_LSB] & 0xff);
Timber.d("Got weight: %f and impedance %f", weight / divider, impedance / 10f);
ScaleMeasurement entry = new ScaleMeasurement();
entry.setWeight(weight / divider);
addScaleMeasurement(entry);
disconnect();
} else if (manufacturerSpecificData.indexOfKey(MANUFACTURER_DATA_ID_V11) > -1) {
byte[] data = manufacturerSpecificData.get(MANUFACTURER_DATA_ID_V11);
float divider = 10.0f;
float extraWeight = 0;
byte checksum = (byte)0xca ^ (byte)0x11; // Version and magic fields are part of the checksum, but not in array
if (data == null || data.length != IDX_V11_CHECKSUM + 6 + 1)
return;
for (int i = 0; i < IDX_V11_CHECKSUM; i++)
checksum ^= data[i];
if (data[IDX_V11_CHECKSUM] != checksum) {
Timber.d("Checksum error, got %x, expected %x", data[IDX_V11_CHECKSUM] & 0xff, checksum & 0xff);
return;
}
int weight = data[IDX_V11_WEIGHT_MSB] & 0xff;
weight = weight << 8 | (data[IDX_V11_WEIGHT_LSB] & 0xff);
switch ((data[IDX_V11_BODY_PROPERTIES] >> 1) & 3) {
default:
Timber.w("Invalid weight scale received, assuming 1 decimal");
/* fall-through */
case 0:
divider = 10.0f;
break;
case 1:
divider = 1.0f;
break;
case 2:
divider = 100.0f;
break;
}
switch ((data[IDX_V11_BODY_PROPERTIES] >> 3) & 3) {
case 0: // kg
break;
case 1: // Jin
divider *= 2;
break;
case 3: // st & lb
extraWeight = (weight >> 8) * 6.350293f;
weight &= 0xff;
/* fall-through */
case 2: // lb
divider *= 2.204623;
break;
}
Timber.d("Got weight: %f", weight / divider);
ScaleMeasurement entry = new ScaleMeasurement();
entry.setWeight(extraWeight + weight / divider);
addScaleMeasurement(entry);
disconnect();
} else if (manufacturerSpecificData.indexOfKey(MANUFACTURER_DATA_ID_VF0) > -1) {
byte[] data = manufacturerSpecificData.get(MANUFACTURER_DATA_ID_VF0);
float divider = 10.0f;
int weight = data[IDX_VF0_WEIGHT_MSB] & 0xff;
weight = weight << 8 | (data[IDX_VF0_WEIGHT_LSB] & 0xff);
Timber.d("Got weight: %f", weight / divider);
ScaleMeasurement entry = new ScaleMeasurement();
entry.setWeight(weight / divider);
addScaleMeasurement(entry);
disconnect();
}
}
};
public BluetoothOKOK(Context context)
{
super(context);
central = new BluetoothCentralManager(context, btCallback, new Handler(Looper.getMainLooper()));
}
@Override
public String driverName() {
return "OKOK";
}
@Override
public void connect(String macAddress) {
Timber.d("Mac address: %s", macAddress);
List<ScanFilter> filters = new LinkedList<ScanFilter>();
ScanFilter.Builder b = new ScanFilter.Builder();
b.setDeviceAddress(macAddress);
b.setDeviceName("ADV");
b.setManufacturerData(MANUFACTURER_DATA_ID_V20, null, null);
filters.add(b.build());
b.setDeviceName("Chipsea-BLE");
b.setManufacturerData(MANUFACTURER_DATA_ID_V11, null, null);
filters.add(b.build());
central.scanForPeripheralsUsingFilters(filters);
}
@Override
public void disconnect() {
if (central != null)
central.stopScan();
central = null;
super.disconnect();
}
@Override
protected boolean onNextStep(int stepNr) {
return false;
}
}

View File

@@ -1,202 +0,0 @@
/* Copyright (C) 2024 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import static com.health.openscale.core.utils.Converters.WeightUnit.LB;
import static com.health.openscale.core.utils.Converters.WeightUnit.ST;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.SparseArray;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.utils.Converters;
import com.welie.blessed.BluetoothCentralManager;
import com.welie.blessed.BluetoothCentralManagerCallback;
import com.welie.blessed.BluetoothPeripheral;
import org.jetbrains.annotations.NotNull;
import java.util.LinkedList;
import java.util.List;
import timber.log.Timber;
public class BluetoothOKOK2 extends BluetoothCommunication {
private static final int IDX_WEIGHT_MSB = 0;
private static final int IDX_WEIGHT_LSB = 1;
private static final int IDX_IMPEDANCE_MSB = 2;
private static final int IDX_IMPEDANCE_LSB = 3;
private static final int IDX_PRODUCTID_MSB = 4;
private static final int IDX_PRODUCTID_LSB = 5;
private static final int IDX_ATTRIB = 6;
private static final int IDX_MAC_1 = 7;
private static final int IDX_MAC_2 = 8;
private static final int IDX_MAC_3 = 9;
private static final int IDX_MAC_4 = 10;
private static final int IDX_MAC_5 = 11;
private static final int IDX_MAC_6 = 12;
private static final int UNIT_KG = 0;
private static final int UNIT_LB = 2;
private static final int UNIT_STLB = 3;
private BluetoothCentralManager central;
private String mMacAddress;
private float mLastWeight = 0f;
public BluetoothOKOK2(Context context) {
super(context);
central = new BluetoothCentralManager(context, btCallback, new Handler(Looper.getMainLooper()));
}
static String convertNoNameToDeviceName(SparseArray<byte[]> manufacturerSpecificData) {
int vendorIndex = -1;
for (int i = 0; i < manufacturerSpecificData.size(); i++) {
int vendorId = manufacturerSpecificData.keyAt(i);
if ((vendorId & 0xff) == 0xc0) { // 0x00c0-->0xffc0
vendorIndex = vendorId;
}
}
if (vendorIndex == -1) {
return null;
}
return "NoName OkOk";
}
private final BluetoothCentralManagerCallback btCallback = new BluetoothCentralManagerCallback() {
@Override
public void onDiscoveredPeripheral(@NotNull BluetoothPeripheral peripheral, @NotNull ScanResult scanResult) {
SparseArray<byte[]> manufacturerSpecificData = scanResult.getScanRecord().getManufacturerSpecificData();
int vendorIndex = -1;
for (int i = 0; i < manufacturerSpecificData.size(); i++) {
int vendorId = manufacturerSpecificData.keyAt(i);
if ((vendorId & 0xff) == 0xc0) { // 0x00c0-->0xffc0
vendorIndex = vendorId;
break;
}
}
if (vendorIndex == -1) {
return;
}
byte[] data = manufacturerSpecificData.get(vendorIndex);
StringBuilder sb = new StringBuilder(data.length * 3);
for (byte b : data) {
sb.append(String.format("%02x ", b));
}
Timber.d("manufacturerSpecificData: [VID=%04x] %s", vendorIndex, sb.toString());
if (data[IDX_MAC_1] != (byte) ((Character.digit(mMacAddress.charAt(0), 16) << 4) + Character.digit(mMacAddress.charAt(1), 16))
|| data[IDX_MAC_2] != (byte) ((Character.digit(mMacAddress.charAt(3), 16) << 4) + Character.digit(mMacAddress.charAt(4), 16))
|| data[IDX_MAC_3] != (byte) ((Character.digit(mMacAddress.charAt(6), 16) << 4) + Character.digit(mMacAddress.charAt(7), 16))
|| data[IDX_MAC_4] != (byte) ((Character.digit(mMacAddress.charAt(9), 16) << 4) + Character.digit(mMacAddress.charAt(10), 16))
|| data[IDX_MAC_5] != (byte) ((Character.digit(mMacAddress.charAt(12), 16) << 4) + Character.digit(mMacAddress.charAt(13), 16))
|| data[IDX_MAC_6] != (byte) ((Character.digit(mMacAddress.charAt(15), 16) << 4) + Character.digit(mMacAddress.charAt(16), 16)))
return;
if ((data[IDX_ATTRIB] & 1) == 0) // in progress
return;
float divider = 10f;
switch ((data[IDX_ATTRIB] >> 1) & 3) {
case 0:
divider = 10f;
break;
case 1:
divider = 1f;
break;
case 2:
divider = 100f;
break;
}
float weight = 0f;
switch ((data[IDX_ATTRIB] >> 3) & 3) {
case UNIT_KG: {
float val = ((data[IDX_WEIGHT_MSB] & 0xff) << 8) | (data[IDX_WEIGHT_LSB] & 0xff);
weight = val / divider;
break;
}
case UNIT_LB: {
float val = ((data[IDX_WEIGHT_MSB] & 0xff) << 8) | (data[IDX_WEIGHT_LSB] & 0xff);
weight = Converters.toKilogram(val / divider, LB);
break;
}
case UNIT_STLB: {
float val = data[IDX_WEIGHT_MSB] /*ST*/ + data[IDX_WEIGHT_LSB] /*LB*/ / divider / 14f;
weight = Converters.toKilogram(val, ST);
break;
}
}
if (mLastWeight != weight) {
ScaleMeasurement entry = new ScaleMeasurement();
entry.setWeight(weight);
addScaleMeasurement(entry);
mLastWeight = weight;
// disconnect();
}
}
};
@Override
public void connect(String macAddress) {
mMacAddress = macAddress;
List<ScanFilter> filters = new LinkedList<>();
byte[] data = new byte[13];
data[IDX_MAC_1] = (byte) ((Character.digit(macAddress.charAt(0), 16) << 4) + Character.digit(macAddress.charAt(1), 16));
data[IDX_MAC_2] = (byte) ((Character.digit(macAddress.charAt(3), 16) << 4) + Character.digit(macAddress.charAt(4), 16));
data[IDX_MAC_3] = (byte) ((Character.digit(macAddress.charAt(6), 16) << 4) + Character.digit(macAddress.charAt(7), 16));
data[IDX_MAC_4] = (byte) ((Character.digit(macAddress.charAt(9), 16) << 4) + Character.digit(macAddress.charAt(10), 16));
data[IDX_MAC_5] = (byte) ((Character.digit(macAddress.charAt(12), 16) << 4) + Character.digit(macAddress.charAt(13), 16));
data[IDX_MAC_6] = (byte) ((Character.digit(macAddress.charAt(15), 16) << 4) + Character.digit(macAddress.charAt(16), 16));
byte[] mask = new byte[13];
mask[IDX_MAC_1] = mask[IDX_MAC_2] = mask[IDX_MAC_3] = mask[IDX_MAC_4] = mask[IDX_MAC_5] = mask[IDX_MAC_6] = (byte) 0xff;
for (int i = 0x00; i <= 0xff; i++) {
ScanFilter.Builder b = new ScanFilter.Builder();
b.setDeviceAddress(macAddress);
b.setManufacturerData((i << 8) | 0xc0, data, mask);
filters.add(b.build());
}
central.scanForPeripheralsUsingFilters(filters);
}
@Override
public void disconnect() {
if (central != null)
central.stopScan();
central = null;
super.disconnect();
}
@Override
public String driverName() {
return "OKOK (nameless)";
}
@Override
protected boolean onNextStep(int stepNr) {
return false;
}
}

View File

@@ -1,231 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.bluetooth.lib.OneByoneLib;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.Calendar;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothOneByone extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xfff0);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC_BODY_COMPOSITION = BluetoothGattUuid.fromShortCode(0xfff4); // notify
private final UUID CMD_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xfff1); // write only
private boolean waitAck = false; // if true, resume after receiving acknowledgement
private boolean historicMeasurement = false; // processing real-time vs historic measurement
private int noHistoric = 0; // number of historic measurements received
// don't save any measurements closer than 3 seconds to each other
private Calendar lastDateTime;
private final int DATE_TIME_THRESHOLD = 3000;
public BluetoothOneByone(Context context) {
super(context);
lastDateTime = Calendar.getInstance();
lastDateTime.set(2000, 1, 1);
}
@Override
public String driverName() {
return "1byone";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC_BODY_COMPOSITION);
break;
case 1:
ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser();
byte unit = 0x00; // kg
switch (currentUser.getScaleUnit()) {
case LB:
unit = 0x01;
break;
case ST:
unit = 0x02;
break;
}
byte group = 0x01;
byte[] magicBytes = {(byte)0xfd, (byte)0x37, unit, group,
(byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00,
(byte)0x00, (byte)0x00, (byte)0x00};
magicBytes[magicBytes.length - 1] =
xorChecksum(magicBytes, 0, magicBytes.length - 1);
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, magicBytes);
break;
case 2:
Calendar dt = Calendar.getInstance();
final byte[] setClockCmd = {(byte)0xf1, (byte)(dt.get(Calendar.YEAR) >> 8),
(byte)(dt.get(Calendar.YEAR) & 255), (byte)(dt.get(Calendar.MONTH) + 1),
(byte)dt.get(Calendar.DAY_OF_MONTH), (byte)dt.get(Calendar.HOUR_OF_DAY),
(byte)dt.get(Calendar.MINUTE), (byte)dt.get(Calendar.SECOND)};
waitAck = true;
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, setClockCmd);
// 2-byte notification value f1 00 will be received after this command
stopMachineState(); // we will resume after receiving acknowledgement f1 00
break;
case 3:
// request historic measurements; they are followed by real-time measurements
historicMeasurement = true;
final byte[] getHistoryCmd = {(byte)0xf2, (byte)0x00};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, getHistoryCmd);
// multiple measurements will be received, they start cf ... and are 11 or 18 bytes long
// 2-byte notification value f2 00 follows last historic measurement
break;
case 4:
sendMessage(R.string.info_step_on_scale, 0);
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (data == null) {
return;
}
// if data is valid data
if (data.length >= 11 && data[0] == (byte)0xcf) {
if (historicMeasurement) {
++noHistoric;
}
parseBytes(data);
} else {
// show 2-byte ack messages in debug output:
// f1 00 setClockCmd acknowledgement
// f2 00 end of historic measurements, real-time measurements follow
// f2 01 clearHistoryCmd acknowledgement
Timber.d("received bytes [%s]", byteInHex(data));
if (waitAck && data.length == 2 && data[0] == (byte)0xf1 && data[1] == 0) {
waitAck = false;
resumeMachineState();
} else if (data.length == 2 && data[0] == (byte)0xf2 && data[1] == 0) {
historicMeasurement = false;
if (noHistoric > 0) {
final byte[] clearHistoryCmd = {(byte)0xf2, (byte)0x01};
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_MEASUREMENT_CHARACTERISTIC, clearHistoryCmd);
}
}
}
}
private void parseBytes(byte[] weightBytes) {
float weight = Converters.fromUnsignedInt16Le(weightBytes, 3) / 100.0f;
float impedanceValue = ((float)(((weightBytes[2] & 0xFF) << 8) + (weightBytes[1] & 0xFF))) * 0.1f;
boolean impedancePresent = (weightBytes[9] != 1) && (impedanceValue != 0);
boolean dateTimePresent = weightBytes.length >= 18;
if (!impedancePresent || (!dateTimePresent && historicMeasurement)) {
// unwanted, no impedance or historic measurement w/o time-stamp
return;
}
Calendar dateTime = Calendar.getInstance();
if (dateTimePresent) {
// 18-byte or longer measurements contain date and time, used in history
dateTime.set(Converters.fromUnsignedInt16Be(weightBytes, 11),
weightBytes[13] - 1, weightBytes[14], weightBytes[15],
weightBytes[16], weightBytes[17]);
}
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
Timber.d("received bytes [%s]", byteInHex(weightBytes));
Timber.d("received decoded bytes [weight: %.2f, impedanceValue: %f]", weight, impedanceValue);
Timber.d("user [%s]", scaleUser);
int sex = 0, peopleType = 0;
if (scaleUser.getGender() == Converters.Gender.MALE) {
sex = 1;
} else {
sex = 0;
}
switch (scaleUser.getActivityLevel()) {
case SEDENTARY:
peopleType = 0;
break;
case MILD:
peopleType = 0;
break;
case MODERATE:
peopleType = 1;
break;
case HEAVY:
peopleType = 2;
break;
case EXTREME:
peopleType = 2;
break;
}
OneByoneLib oneByoneLib = new OneByoneLib(sex, scaleUser.getAge(), scaleUser.getBodyHeight(), peopleType);
ScaleMeasurement scaleBtData = new ScaleMeasurement();
scaleBtData.setWeight(weight);
try {
dateTime.setLenient(false);
scaleBtData.setDateTime(dateTime.getTime());
scaleBtData.setFat(oneByoneLib.getBodyFat(weight, impedanceValue));
scaleBtData.setWater(oneByoneLib.getWater(scaleBtData.getFat()));
scaleBtData.setBone(oneByoneLib.getBoneMass(weight, impedanceValue));
scaleBtData.setVisceralFat(oneByoneLib.getVisceralFat(weight));
scaleBtData.setMuscle(oneByoneLib.getMuscle(weight, impedanceValue));
scaleBtData.setLbm(oneByoneLib.getLBM(weight, scaleBtData.getFat()));
Timber.d("scale measurement [%s]", scaleBtData);
if (dateTime.getTimeInMillis() - lastDateTime.getTimeInMillis() < DATE_TIME_THRESHOLD) {
return; // don't save measurements too close to each other
}
lastDateTime = dateTime;
addScaleMeasurement(scaleBtData);
}
catch (IllegalArgumentException e) {
if (historicMeasurement) {
Timber.d("invalid time-stamp: year %d, month %d, day %d, hour %d, minute %d, second %d",
Converters.fromUnsignedInt16Be(weightBytes, 11),
weightBytes[13], weightBytes[14], weightBytes[15],
weightBytes[16], weightBytes[17]);
return; // discard historic measurement with invalid time-stamp
}
}
}
}

View File

@@ -1,333 +0,0 @@
package com.health.openscale.core.bluetooth;
import android.content.Context;
import androidx.annotation.NonNull;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.bluetooth.lib.OneByoneNewLib;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.math.BigInteger;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothOneByoneNew extends BluetoothCommunication{
private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffb0);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC_BODY_COMPOSITION = BluetoothGattUuid.fromShortCode(0xffb2);
private final UUID CMD_AFTER_MEASUREMENT = BluetoothGattUuid.fromShortCode(0xffb1);
private final int MSG_LENGTH = 20;
private final byte[] HEADER_BYTES = { (byte)0xAB, (byte)0x2A };
private ScaleMeasurement currentMeasurement;
public BluetoothOneByoneNew(Context context) {
super(context);
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] data){
if(data == null){
Timber.e("Received an empty message");
return;
}
Timber.i("Received %s", new BigInteger(1, data).toString(16));
if(data.length < MSG_LENGTH){
Timber.e("Received a message too short");
return;
}
if(!(data[0] == HEADER_BYTES[0] && data[1] == HEADER_BYTES[1])){
Timber.e("Unrecognized message received from scale.");
}
float weight;
int impedance;
switch(data[2]){
case (byte)0x00:
// real time measurement OR historic measurement
// real time has the exact same format of 0x80, but we can ignore it
// we want to capture the historic measures
// filter out real time measurments
if (data[7] != (byte)0x80){
Timber.i("Received real-time measurement. Skipping.");
break;
}
Date time = getTimestamp32(data, 3);
weight = Converters.fromUnsignedInt24Be(data, 8) & 0x03ffff;
weight /= 1000;
impedance = Converters.fromUnsignedInt16Be(data, 15);
ScaleMeasurement historicMeasurement = new ScaleMeasurement();
int assignableUserId = OpenScale.getInstance().getAssignableUser(weight);
if(assignableUserId == -1){
Timber.i("Discarding historic measurement: no user found with intelligent user recognition");
break;
}
populateMeasurement(assignableUserId, historicMeasurement, impedance, weight);
historicMeasurement.setDateTime(time);
addScaleMeasurement(historicMeasurement);
Timber.i("Added historic measurement. Weight: %s, impedance: %s, timestamp: %s", weight, impedance, time.toString());
break;
case (byte)0x80:
// final measurement
currentMeasurement = new ScaleMeasurement();
weight = Converters.fromUnsignedInt24Be(data, 3) & 0x03ffff;
weight = weight / 1000;
currentMeasurement.setWeight(weight);
Timber.d("Weight: %s", weight);
break;
case (byte)0x01:
impedance = Converters.fromUnsignedInt16Be(data, 4);
Timber.d("impedance: %s", impedance);
if(currentMeasurement == null){
Timber.e("Received impedance value without weight");
break;
}
float measurementWeight = currentMeasurement.getWeight();
ScaleUser user = OpenScale.getInstance().getSelectedScaleUser();
populateMeasurement(user.getId(), currentMeasurement, impedance, measurementWeight);
addScaleMeasurement(currentMeasurement);
resumeMachineState();
break;
default:
Timber.e("Unrecognized message receveid");
}
}
private void populateMeasurement(int userId, ScaleMeasurement measurement, int impedance, float weight) {
if(userId == -1){
Timber.e("Discarding measurement population since invalid user");
return;
}
ScaleUser user = OpenScale.getInstance().getScaleUser(userId);
float cmHeight = Converters.fromCentimeter(user.getBodyHeight(), user.getMeasureUnit());
OneByoneNewLib onebyoneLib = new OneByoneNewLib(getUserGender(user), user.getAge(), cmHeight, user.getActivityLevel().toInt());
measurement.setUserId(userId);
measurement.setWeight(weight);
measurement.setDateTime(Calendar.getInstance().getTime());
measurement.setFat(onebyoneLib.getBodyFatPercentage(weight, impedance));
measurement.setWater(onebyoneLib.getWaterPercentage(weight, impedance));
measurement.setBone(onebyoneLib.getBoneMass(weight, impedance));
measurement.setVisceralFat(onebyoneLib.getVisceralFat(weight));
measurement.setMuscle(onebyoneLib.getSkeletonMusclePercentage(weight, impedance));
measurement.setLbm(onebyoneLib.getLBM(weight, impedance));
}
@Override
public String driverName() {
return "OneByoneNew";
}
@Override
protected boolean onNextStep(int stepNr) {
switch(stepNr){
case 0:
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, WEIGHT_MEASUREMENT_CHARACTERISTIC_BODY_COMPOSITION);
break;
case 1:
// Setup notification on new weight
sendWeightRequest();
// Update the user history on the scale
// Priority given to the current user
ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser();
sendUsersHistory(currentUser.getId());
// We wait for the response
stopMachineState();
break;
case 2:
// After the measurement took place, we store the data and send back to the scale
sendUsersHistory(OpenScale.getInstance().getSelectedScaleUserId());
break;
default:
return false;
}
return true;
}
private void sendWeightRequest() {
byte[] msgSetup = new byte[MSG_LENGTH];
setupMeasurementMessage(msgSetup, 0);
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_AFTER_MEASUREMENT, msgSetup, true);
}
private void sendUsersHistory(int priorityUser){
List<ScaleUser> scaleUsers = OpenScale.getInstance().getScaleUserList();
Collections.sort(scaleUsers, (ScaleUser u1, ScaleUser u2) -> {
if(u1.getId() == priorityUser) return -9999;
if(u2.getId() == priorityUser) return 9999;
Date u1LastMeasureDate = OpenScale.getInstance().getLastScaleMeasurement(u1.getId()).getDateTime();
Date u2LastMeasureDate = OpenScale.getInstance().getLastScaleMeasurement(u2.getId()).getDateTime();
return u1LastMeasureDate.compareTo(u2LastMeasureDate);
}
);
byte[] msg = new byte[MSG_LENGTH];
int msgCounter = 0;
for(int i = 0; i < scaleUsers.size(); i++){
ScaleUser user = scaleUsers.get(i);
ScaleMeasurement lastMeasure = OpenScale.getInstance().getLastScaleMeasurement(user.getId());
float weight = 0;
int impedance = 0;
if(lastMeasure != null){
weight = lastMeasure.getWeight();
impedance = getImpedanceFromLBM(user, lastMeasure);
}
int entryPosition = i % 2;
if (entryPosition == 0){
msg = new byte[MSG_LENGTH];
msgCounter ++;
msg[0] = HEADER_BYTES[0];
msg[1] = HEADER_BYTES[1];
msg[2] = (byte) scaleUsers.size();
msg[3] = (byte) msgCounter;
}
setMeasurementEntry(msg, 4 + entryPosition * 7, i + 1,
Math.round(user.getBodyHeight()),
weight,
getUserGender(user),
user.getAge(),
impedance,
true);
if (entryPosition == 1 || i + 1 == scaleUsers.size()){
msg[18] = (byte)0xD4;
msg[19] = d4Checksum(msg, 0, MSG_LENGTH);
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CMD_AFTER_MEASUREMENT, msg, true);
}
}
}
private void setMeasurementEntry(byte[] msg, int offset, int entryNum, int height, float weight, int sex, int age, int impedance, boolean impedanceLe){
// The scale wants a value rounded to the first decimal place
// Otherwise we receive always a UP/DOWN arrow since we would communicate
// AB.CX instead of AB.D0 where D0 is the approximation of CX and it is what the scale uses
// to compute the UP/DOWN arrows
int roundedWeight = Math.round( weight * 10) * 10;
msg[offset] = (byte)(entryNum & 0xFF);
msg[offset+1] = (byte)(height & 0xFF);
Converters.toInt16Be(msg, offset+2, roundedWeight);
msg[offset+4] = (byte)(((sex & 0xFF) << 7) + (age & 0x7F));
if(impedanceLe) {
msg[offset + 5] = (byte) (impedance >> 8);
msg[offset + 6] = (byte) impedance;
} else {
msg[offset + 5] = (byte) impedance;
msg[offset + 6] = (byte) (impedance >> 8);
}
}
private void setTimestamp32(byte[] msg, int offset){
long timestamp = System.currentTimeMillis()/1000L;
Converters.toInt32Be(msg, offset, timestamp);
}
private Date getTimestamp32(byte[] msg, int offset){
long timestamp = Converters.fromUnsignedInt32Be(msg, offset);
return new Date(timestamp * 1000);
}
private boolean setupMeasurementMessage(byte[] msg, int offset){
if(offset + MSG_LENGTH > msg.length){
return false;
}
ScaleUser currentUser = OpenScale.getInstance().getSelectedScaleUser();
Converters.WeightUnit weightUnit = currentUser.getScaleUnit();
msg[offset] = HEADER_BYTES[0];
msg[offset+1] = HEADER_BYTES[1];
setTimestamp32(msg, offset+2);
// This byte has been left empty in all the observations, unknown meaning
msg[offset+6] = 0;
msg[offset+7] = (byte) weightUnit.toInt();
int userId = currentUser.getId();
// We send the last measurement or if not present an empty one
ScaleMeasurement lastMeasure = OpenScale.getInstance().getLastScaleMeasurement(userId);
float weight = 0;
int impedance = 0;
if(lastMeasure != null){
weight = lastMeasure.getWeight();
impedance = getImpedanceFromLBM(currentUser, lastMeasure);
}
setMeasurementEntry(msg, offset+8,
userId,
Math.round(currentUser.getBodyHeight()),
weight,
getUserGender(currentUser),
currentUser.getAge(),
impedance,
false
);
// Blank bytes after the empty measurement
msg[offset + 18] = (byte) 0xD7;
msg[offset+19] = d7Checksum(msg, offset+2, 17);
return true;
}
private int getUserGender(ScaleUser user){
// Custom function since the toInt() gives the opposite values
return user.getGender().isMale() ? 1 : 0;
}
private byte d4Checksum(byte[] msg, int offset, int length){
byte sum = sumChecksum(msg, offset + 2, length - 2);
// Remove impedance MSB first entry
sum -= msg[offset+9];
// Remove second entry weight
sum -= msg[offset+13];
sum -= msg[offset+14];
// Remove impedance MSB second entry
sum -= msg[offset+16];
return sum;
}
private byte d7Checksum(byte[] msg, int offset, int length){
byte sum = sumChecksum(msg, offset+2, length-2);
// Remove impedance MSB
sum -= msg[offset+14];
return sum;
}
// Since we need to send the impedance to the scale the next time,
// we obtain it from the previous measurement using the LBM
public int getImpedanceFromLBM(ScaleUser user, ScaleMeasurement measurement) {
float finalLbm = measurement.getLbm();
float postImpedanceLbm = finalLbm + user.getAge() * 0.0542F;
float preImpedanceLbm = user.getBodyHeight() / 100 * user.getBodyHeight() / 100 * 9.058F + 12.226F + measurement.getWeight() * 0.32F;
return Math.round((preImpedanceLbm - postImpedanceLbm) / 0.0068F);
}
}

View File

@@ -1,283 +0,0 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothQNScale extends BluetoothCommunication {
// accurate. Indication means requires ack. notification does not
private final UUID WEIGHT_MEASUREMENT_SERVICE = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb");
//private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"); // notify, read-only
//private final UUID CMD_MEASUREMENT_CHARACTERISTIC = UUID.fromString("29f11080-75b9-11e2-8bf6-0002a5d5c51b"); // write only
// Client Characteristic Configuration Descriptor, constant value of 0x2902
private final UUID WEIGHT_MEASUREMENT_CONFIG = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
///////////// explore
// Read value notification to get weight. Some other payload structures as well 120f15 & 140b15
// Also handle 14. Send write requests that are empty? Subscribes to notification on 0xffe1
private final UUID CUSTOM1_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"); // notify, read-only
// Receive value indication. Is always magic value 210515013c. Message occurs before or after last weight...almost always before.
// Also send (empty?) write requests on handle 17? Subscribes to indication on 0xffe2
private final UUID CUSTOM2_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe2-0000-1000-8000-00805f9b34fb"); // indication, read-only
// Sending write with magic 1f05151049 terminates connection. Sending magic 130915011000000042 only occurs after receiveing a 12 or 14 on 0xffe1 and is always followed by receiving a 14 on 0xffe1
private final UUID CUSTOM3_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe3-0000-1000-8000-00805f9b34fb"); // write-only
// Send write of value like 20081568df4023e7 (constant until 815. assuming this is time?). Always sent following the receipt of a 14 on 0xffe1. Always prompts the receipt of a value indication on 0xffe2. This has to be sending time, then prompting for scale to send time for host to finally confirm
private final UUID CUSTOM4_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe4-0000-1000-8000-00805f9b34fb"); // write-only
// Never used
private final UUID CUSTOM5_MEASUREMENT_CHARACTERISTIC = UUID.fromString("0000ffe5-0000-1000-8000-00805f9b34fb"); // write-only
/////////////
// 2nd Type Service and Characteristics (2nd Type doesn't need to indicate, and 4th characteristic is shared with 3rd.)
private final UUID WEIGHT_MEASUREMENT_SERVICE_ALTERNATIVE = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb");
private final UUID CUSTOM1_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb"); // notify, read-only
private final UUID CUSTOM3_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb"); // write-only
private boolean useFirstType = true;
/** API
connectDevice(device, "userId", 170, 1, birthday, new new QNBleCallback(){
void onConnectStart(QNBleDevice bleDevice);
void onConnected(QNBleDevice bleDevice);
void onDisconnected(QNBleDevice bleDevice,int status);
void onUnsteadyWeight(QNBleDevice bleDevice, float weight);
void onReceivedData(QNBleDevice bleDevice, QNData data);
void onReceivedStoreData(QNBleDevice bleDevice, List<QNData> datas);
void onLowPower();
**/
// Scale time is in seconds since 2000-01-01 00:00:00 (utc).
private static final long SCALE_UNIX_TIMESTAMP_OFFSET = 946702800;
private static long MILLIS_2000_YEAR = 949334400000L;
private boolean hasReceived;
private float weightScale=100.0f;
public BluetoothQNScale(Context context) {
super(context);
}
// Includes FITINDEX ES-26M
@Override
public String driverName() {
return "QN Scale";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
// Try writing bytes to 0xffe4 to check whether to use 1st or 2nd type
try {
long timestamp = new Date().getTime() / 1000;
timestamp -= SCALE_UNIX_TIMESTAMP_OFFSET;
byte[] date = new byte[4];
Converters.toInt32Le(date, 0, timestamp);
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CUSTOM4_MEASUREMENT_CHARACTERISTIC, new byte[]{(byte) 0x02, date[0], date[1], date[2], date[3]});
} catch (NullPointerException e) {
useFirstType = false;
}
break;
case 1:
// set indication on for weight measurement and for custom characteristic 1 (weight, time, and others)
if (useFirstType) {
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE, CUSTOM1_MEASUREMENT_CHARACTERISTIC);
setIndicationOn(WEIGHT_MEASUREMENT_SERVICE, CUSTOM2_MEASUREMENT_CHARACTERISTIC);
} else {
setNotificationOn(WEIGHT_MEASUREMENT_SERVICE_ALTERNATIVE, CUSTOM1_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE);
}
break;
case 2:
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
final Converters.WeightUnit scaleUserWeightUnit = scaleUser.getScaleUnit();
// Value of 0x01 = KG. 0x02 = LB. Requests with stones unit are sent as LB, with post-processing in vendor app.
byte weightUnitByte = (byte) 0x01;
// Default weight unit KG. If user config set to LB or ST, scale will show LB units, consistent with vendor app
if (scaleUserWeightUnit == Converters.WeightUnit.LB || scaleUserWeightUnit == Converters.WeightUnit.ST){
weightUnitByte = (byte) 0x02;
}
// write magicnumber 0x130915[WEIGHT_BYTE]10000000[CHECK_SUM] to 0xffe3
// 0x01 weight byte = KG. 0x02 weight byte = LB.
byte[] ffe3magicBytes = new byte[]{(byte) 0x13, (byte) 0x09, (byte) 0x15, weightUnitByte, (byte) 0x10, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00};
// Set last byte to be checksum
ffe3magicBytes[ffe3magicBytes.length -1] = sumChecksum(ffe3magicBytes, 0, ffe3magicBytes.length - 1);
if (useFirstType) {
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CUSTOM3_MEASUREMENT_CHARACTERISTIC, ffe3magicBytes);
} else {
writeBytes(WEIGHT_MEASUREMENT_SERVICE_ALTERNATIVE, CUSTOM3_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE, ffe3magicBytes);
}
break;
case 3:
// send time magic number to receive weight data
long timestamp = new Date().getTime() / 1000;
timestamp -= SCALE_UNIX_TIMESTAMP_OFFSET;
byte[] date = new byte[4];
Converters.toInt32Le(date, 0, timestamp);
byte[] timeMagicBytes = new byte[]{(byte) 0x02, date[0], date[1], date[2], date[3]};
if (useFirstType) {
writeBytes(WEIGHT_MEASUREMENT_SERVICE, CUSTOM4_MEASUREMENT_CHARACTERISTIC, timeMagicBytes);
} else {
writeBytes(WEIGHT_MEASUREMENT_SERVICE_ALTERNATIVE, CUSTOM3_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE, timeMagicBytes);
}
break;
case 4:
sendMessage(R.string.info_step_on_scale, 0);
break;
/*case 5:
// send stop command to scale (0x1f05151049)
writeBytes(CUSTOM3_MEASUREMENT_CHARACTERISTIC, new byte[]{(byte)0x1f, (byte)0x05, (byte)0x15, (byte)0x10, (byte)0x49});
break;*/
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
final byte[] data = value;
if (characteristic.equals(CUSTOM1_MEASUREMENT_CHARACTERISTIC) || characteristic.equals(CUSTOM1_MEASUREMENT_CHARACTERISTIC_ALTERNATIVE)) {
parseCustom1Data(data);
}
}
private void parseCustom1Data(byte[] data){
StringBuilder sb = new StringBuilder();
int len = data.length;
for (int i = 0; i < len; i++) {
sb.append(String.format("%02X ", new Object[]{Byte.valueOf(data[i])}));
}
Timber.d(sb.toString());
float weightKg=0;
switch (data[0]) {
case (byte) 16:
if (data[5] == (byte) 0) {
this.hasReceived = false;
//this.callback.onUnsteadyWeight(this.qnBleDevice, decodeWeight(data[3], data[4]));
} else if (data[5] == (byte) 1) {
// writeData(CmdBuilder.buildOverCmd(this.protocolType, 16));
if (!this.hasReceived) {
this.hasReceived = true;
weightKg = decodeWeight(data[3], data[4]);
// Weight needs to be divided by 10 if 2nd type
if (!useFirstType) {
weightKg /= 10;
}
int weightByteOne = data[3] & 0xFF;
int weightByteTwo = data[4] & 0xFF;
Timber.d("Weight byte 1 %d", weightByteOne);
Timber.d("Weight byte 2 %d", weightByteTwo);
Timber.d("Raw Weight: %f", weightKg);
if (weightKg > 0.0f) {
//QNData md = buildMeasuredData(this.qnUser, weight, decodeIntegerValue
// (data[6], data[7]), decodeIntegerValue(data[8], data[9]),
// new Date(), data);
int resistance1 = decodeIntegerValue (data[6], data[7]);
int resistance2 = decodeIntegerValue(data[8], data[9]);
Timber.d("resistance1: %d", resistance1);
Timber.d("resistance2: %d", resistance2);
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
Timber.d("scale user " + scaleUser);
ScaleMeasurement btScaleMeasurement = new ScaleMeasurement();
//TrisaBodyAnalyzeLib gives almost simillar values for QNScale body fat calcualtion
TrisaBodyAnalyzeLib qnscalelib = new TrisaBodyAnalyzeLib(scaleUser.getGender().isMale() ? 1 : 0, scaleUser.getAge(), (int)scaleUser.getBodyHeight());
//Now much difference between resistance1 and resistance2.
//Will use resistance 1 for now
float impedance = resistance1 < 410f ? 3.0f : 0.3f * (resistance1 - 400f);
btScaleMeasurement.setFat(qnscalelib.getFat(weightKg, impedance));
btScaleMeasurement.setWater(qnscalelib.getWater(weightKg, impedance));
btScaleMeasurement.setMuscle(qnscalelib.getMuscle(weightKg, impedance));
btScaleMeasurement.setBone(qnscalelib.getBone(weightKg, impedance));
btScaleMeasurement.setWeight(weightKg);
addScaleMeasurement(btScaleMeasurement);
}
}
}
break;
case (byte) 18:
byte protocolType = data[2];
this.weightScale = data[10] == (byte) 1 ? 100.0f : 10.0f;
int[] iArr = new int[5];
//TODO
//writeData(CmdBuilder.buildCmd(19, this.protocolType, 1, 16, 0, 0, 0));
break;
case (byte) 33:
// TODO
//writeBleData(CmdBuilder.buildCmd(34, this.protocolType, new int[0]));
break;
case (byte) 35:
weightKg = decodeWeight(data[9], data[10]);
if (weightKg > 0.0f) {
int resistance = decodeIntegerValue(data[11], data[12]);
int resistance500 = decodeIntegerValue(data[13], data[14]);
long differTime = 0;
for (int i = 0; i < 4; i++) {
differTime |= (((long) data[i + 5]) & 255) << (i * 8);
}
Date date = new Date(MILLIS_2000_YEAR + (1000 * differTime));
// TODO
// QNData qnData = buildMeasuredData(user, weight, resistance,
// resistance500, date, data);
if (data[3] == data[4]) {
// TODO
}
}
break;
}
}
private float decodeWeight(byte a, byte b) {
return ((float) (((a & 255) << 8) + (b & 255))) / this.weightScale;
}
private int decodeIntegerValue(byte a, byte b) {
return ((a & 255) << 8) + (b & 255);
}
}

View File

@@ -1,255 +0,0 @@
package com.health.openscale.core.bluetooth;
import static com.health.openscale.core.utils.Converters.toCentimeter;
import android.content.Context;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;
import java.time.LocalDateTime;
import timber.log.Timber;
public class BluetoothRenphoScale extends BluetoothCommunication {
private static final UUID SERV_BODY_COMP = BluetoothGattUuid.fromShortCode(0x181b);
private static final UUID SERV_USER_DATA = BluetoothGattUuid.fromShortCode(0x181c);
private static final UUID SERV_WEIGHT_SCALE = BluetoothGattUuid.fromShortCode(0x181d);
private static final UUID SERV_CUR_TIME = BluetoothGattUuid.fromShortCode(0x1805);
// Custom characteristic nr. 0 (Service: body comp)
// Written data was always the same on all my tests
private static final UUID CHAR_CUSTOM0_NOTIFY = BluetoothGattUuid.fromShortCode(0xffe1);
private static final UUID CHAR_CUSTOM0 = BluetoothGattUuid.fromShortCode(0xffe2);
private static final byte[] CHAR_CUSTOM0_MAGIC0 = new byte[]{(byte) 0x10, (byte) 0x01, (byte) 0x00, (byte) 0x11};
private static final byte[] CHAR_CUSTOM0_MAGIC1 = new byte[]{(byte) 0x03, (byte) 0x00, (byte) 0x01, (byte) 0x04};
// Custom characteristic nr. 1 (Service: user data)
// Written data was always the same on all my tests
private static final UUID CHAR_CUSTOM1_NOTIFY = BluetoothGattUuid.fromShortCode(0x2a9f);
private static final UUID CHAR_CUSTOM1 = BluetoothGattUuid.fromShortCode(0x2a9f);
private static final byte[] CHAR_CUSTOM1_MAGIC = new byte[]{(byte) 0x02, (byte) 0xaa, (byte) 0x0f, (byte) 0x27};
// Service: body comp
private static final UUID CHAR_BODY_COMP_FEAT = BluetoothGattUuid.fromShortCode(0x2a9b);
private static final UUID CHAR_BODY_COMP_MEAS = BluetoothGattUuid.fromShortCode(0x2a9c);
// Service: user data
private static final UUID CHAR_GENDER = BluetoothGattUuid.fromShortCode(0x2a8c); // 0x00 male, 0x01 female
private static final UUID CHAR_HEIGHT = BluetoothGattUuid.fromShortCode(0x2a8e); // in cm. 177cm = {0xb1 0x00}
private static final UUID CHAR_BIRTH = BluetoothGattUuid.fromShortCode(0x2a85); // 2 bytes year, 1 byte month, 1 byte day of year (1-366)
private static final UUID CHAR_AGE = BluetoothGattUuid.fromShortCode(0x2a80); // 1 byte
private static final UUID CHAR_ATHLETE= BluetoothGattUuid.fromShortCode(0x2aff); // {0x0d 0x00} = Athlete; {0x03 0x00} = Not athlete
// Service: weight scale
private static final UUID CHAR_WEIGHT = BluetoothGattUuid.fromShortCode(0x2a9d); // {0x0d 0x00} = Athlete; {0x03 0x00} = Not athlete
// Curr time
private static final UUID CHAR_CUR_TIME = BluetoothGattUuid.fromShortCode(0x2a2b);
private static final UUID CHAR_ICCEDK = BluetoothGattUuid.fromShortCode(0xfff1);
/*
Despite notified data is discarded, notify must be set on
- 0x2a2b (CHAR_CUR_TIME)
- 0x2a9f (CHAR_CUSTOM1_NOTIFY)
- 0xfff1 (CHAR_ICCEDK)
- 0xffe1 (CHAR_CUSTOM0_NOTIFY)
*/
private ScaleUser user;
public BluetoothRenphoScale(Context context) {
super(context);
}
@Override
public String driverName() {
// Not sure of the driver name. Tested with ES-WBE28
return "RENPHO ES-WBE28";
}
@Override
protected boolean onNextStep(int stepNr) {
Timber.i("onNextStep(%d)", stepNr);
switch (stepNr) {
case 0:
user = OpenScale.getInstance().getSelectedScaleUser();
setNotificationOn(SERV_CUR_TIME, CHAR_CUR_TIME);
break;
case 1:
setIndicationOn(SERV_USER_DATA, CHAR_CUSTOM1_NOTIFY);
break;
case 2:
setNotificationOn(SERV_CUR_TIME, CHAR_ICCEDK);
break;
case 3:
setNotificationOn(SERV_BODY_COMP, CHAR_CUSTOM0_NOTIFY);
break;
case 4:
LocalDateTime now = LocalDateTime.now();
byte[] currtime = new byte[]{
(byte) (now.getYear() & 0xff), // Year LSB
(byte) (now.getYear() >> 8), // Year MSB
(byte) (now.getMonthValue()),
(byte) (now.getDayOfMonth()),
(byte) (now.getHour()),
(byte) (now.getMinute()),
(byte) (now.getSecond()),
(byte) (now.getDayOfWeek().getValue()), // 1 = Monday, 7 = Sunday
(byte) 0, // Fraction of seconds, unused
(byte) 0 // Reason of update: not specified
};
writeBytes(SERV_CUR_TIME, CHAR_CUR_TIME, currtime);
break;
case 5:
stopMachineState();
writeBytes(SERV_BODY_COMP, CHAR_CUSTOM0, CHAR_CUSTOM0_MAGIC0);
break;
case 6:
stopMachineState();
writeBytes(SERV_BODY_COMP, CHAR_CUSTOM0, CHAR_CUSTOM0_MAGIC1);
break;
case 7:
stopMachineState();
writeBytes(SERV_USER_DATA, CHAR_CUSTOM1, CHAR_CUSTOM1_MAGIC);
break;
case 8:
byte[] gender = new byte[]{(byte) (user.getGender().isMale() ? 0x00 : 0x01)};
writeBytes(SERV_USER_DATA, CHAR_GENDER, gender);
break;
case 9:
int height = (int) toCentimeter(user.getBodyHeight(), user.getMeasureUnit());
byte[] height_data = new byte[]{
(byte) (height & 0xff) , // Height, cm, LSB
(byte) (height >> 8) // Height, cm, MSB
};
writeBytes(SERV_USER_DATA, CHAR_HEIGHT, height_data);
break;
case 10:
Date dob_d = user.getBirthday();
// Needed to calculate DAY_OF_YEAR.
// Moreover, Date::getXXX() is deprecated and replaced by Calendar::get
Calendar dob = Calendar.getInstance();
dob.setTime(dob_d);
byte[] dob_data = new byte[]{
(byte) (dob.get(Calendar.YEAR) & 0xff), // Year LSB
(byte) (dob.get(Calendar.YEAR) >> 8), // Year MSB
// Calendar.JANUARY is zero, but scale needs Jan = 1, Dec = 12
(byte) (dob.get(Calendar.MONTH) - Calendar.JANUARY + 1),
// GATT spec says DAY_OF_MONTH (1-31) but Renpho app sends some strange values
(byte) dob.get(Calendar.DAY_OF_MONTH)
};
writeBytes(SERV_USER_DATA, CHAR_BIRTH, dob_data);
break;
case 11:
byte[] age = new byte[]{(byte) user.getAge()};
writeBytes(SERV_USER_DATA, CHAR_AGE, age);
break;
case 12:
byte[] athl = new byte[]{(byte) 0x03, (byte)0x00}; // Non athlete
switch (user.getActivityLevel()) {
case HEAVY:
case EXTREME:
athl[0] = (byte) 0x0d;
break;
}
writeBytes(SERV_USER_DATA, CHAR_ATHLETE, athl);
break;
case 13:
readBytes(SERV_BODY_COMP, CHAR_BODY_COMP_FEAT);
break;
case 14:
setNotificationOn(SERV_WEIGHT_SCALE, CHAR_WEIGHT);
break;
case 15:
setIndicationOn(SERV_BODY_COMP, CHAR_BODY_COMP_MEAS);
break;
case 16:
stopMachineState();
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
Timber.d("Received notification on UUID = %s", characteristic.toString());
for(int i = 0; i < value.length; i++) {
Timber.d("Byte %d = 0x%02x", i, value[i]);
}
switch (getStepNr()) {
case 6:
case 7:
case 8:
resumeMachineState();
break;
case 17:
if (characteristic.equals(CHAR_WEIGHT)) {
if (value[0] == 0x2e) {
float weight_kg = (Byte.toUnsignedInt(value[2])*256 + Byte.toUnsignedInt(value[1])) / 20.0f;
Timber.d("Weight = 0x%02x, 0x%02x = %f",value[1], value[2], weight_kg);
saveMeasurement(weight_kg);
resumeMachineState();
}
}
if (characteristic.equals(CHAR_BODY_COMP_MEAS)) {
// TODO
/*
Not yet decoded (it does not follow GATT Body Comp standard fields).
What I've found is:
byte 0 : Unknown (always zero?)
byte 1- 3 : Unknown
byte 4 : Unknown (always zero?)
byte 5 : "metabolic_age" in years
byte 6 : Unknown (always zero?)
byte 7 : "protein" in units of 0.1%
byte 8- 9 : "subcutaneous_fat" in units of 0.1%
byte 10 : "visceral_fat_grade" in unknown/absolute units
byte 11 : Unknown (always zero?)
byte 12 : int part of "lean_body_mass" in kg. Dunno where decimal digit is encoded.
bytes 13-16 : Unknown (some flags/counters?). These fields change even between identical measurements. byte 16 = (byte 14) + 2.
bytes 17-18 : "body_water" in units of 0.1%
*/
}
break;
}
}
/**
* Save a measurement from the scale to openScale.
*
* @param weightKg The weight, in kilograms
*/
private void saveMeasurement(float weightKg) {
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
Timber.d("Saving measurement for scale user %s", scaleUser);
final ScaleMeasurement btScaleMeasurement = new ScaleMeasurement();
btScaleMeasurement.setWeight(weightKg);
addScaleMeasurement(btScaleMeasurement);
}
}

View File

@@ -1,138 +0,0 @@
/* Copyright (C) 2021 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
/*
* Based on source-code by weliem/blessed-android
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.welie.blessed.BluetoothBytesParser;
import java.util.UUID;
import timber.log.Timber;
import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8;
public class BluetoothSanitasSBF72 extends BluetoothStandardWeightProfile {
private String deviceName;
private static final UUID SERVICE_SBF72_CUSTOM = BluetoothGattUuid.fromShortCode(0xffff);
private static final UUID CHARACTERISTIC_SCALE_SETTINGS = BluetoothGattUuid.fromShortCode(0x0000);
private static final UUID CHARACTERISTIC_USER_LIST = BluetoothGattUuid.fromShortCode(0x0001);
private static final UUID CHARACTERISTIC_ACTIVITY_LEVEL = BluetoothGattUuid.fromShortCode(0x0004);
private static final UUID CHARACTERISTIC_REFER_WEIGHT_BF = BluetoothGattUuid.fromShortCode(0x000b);
private static final UUID CHARACTERISTIC_TAKE_MEASUREMENT = BluetoothGattUuid.fromShortCode(0x0006);
public BluetoothSanitasSBF72(Context context, String name) {
super(context);
deviceName = name;
}
@Override
public String driverName() {
return deviceName;
}
@Override
protected int getVendorSpecificMaxUserCount() {
return 8;
}
@Override
protected void enterScaleUserConsentUi(int appScaleUserId, int scaleUserIndex) {
//Requests the scale to display the pin for the user in it's display.
//As parameter we need to send a pin-index to the custom user-list characteristic.
//For user with index 1 the pin-index is 0x11, for user with index 2 it is 0x12 and so on.
int scalePinIndex = scaleUserIndex + 16;
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setIntValue(scalePinIndex, FORMAT_UINT8);
writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_USER_LIST, parser.getValue());
//opens the input screen for the pin in the app
super.enterScaleUserConsentUi(appScaleUserId, scaleUserIndex);
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
if (characteristic.equals(CHARACTERISTIC_USER_LIST)) {
//the if condition is to catch the response to "display-pin-on-scale", because this response would produce an error in handleVendorSpecificUserList().
if (value != null && value.length > 0 && value[0] != 17) {
handleVendorSpecificUserList(value);
}
}
else {
super.onBluetoothNotify(characteristic, value);
}
}
@Override
protected ScaleMeasurement bodyCompositionMeasurementToScaleMeasurement(byte[] value) {
ScaleMeasurement measurement = super.bodyCompositionMeasurementToScaleMeasurement(value);
float weight = measurement.getWeight();
if (weight == 0.f && previousMeasurement != null) {
weight = previousMeasurement.getWeight();
}
if (weight != 0.f) {
float water = Math.round(((measurement.getWater() / weight) * 10000.f))/100.f;
measurement.setWater(water);
}
return measurement;
}
@Override
protected void setNotifyVendorSpecificUserList() {
if (setNotificationOn(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_USER_LIST)) {
Timber.d("setNotifyVendorSpecificUserList() OK");
} else {
Timber.d("setNotifyVendorSpecificUserList() FAILED");
}
}
@Override
protected synchronized void requestVendorSpecificUserList() {
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setIntValue(0, FORMAT_UINT8);
writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_USER_LIST, parser.getValue());
}
@Override
protected void writeActivityLevel() {
BluetoothBytesParser parser = new BluetoothBytesParser();
int activityLevel = this.selectedUser.getActivityLevel().toInt() + 1;
Timber.d(String.format("activityLevel: %d", activityLevel));
parser.setIntValue(activityLevel, FORMAT_UINT8);
writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_ACTIVITY_LEVEL, parser.getValue());
}
@Override
protected void writeInitials() {
Timber.d("Write user initials is not supported by " + deviceName + "!");
}
@Override
protected synchronized void requestMeasurement() {
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setIntValue(0, FORMAT_UINT8);
writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_TAKE_MEASUREMENT, parser.getValue());
}
}

View File

@@ -1,234 +0,0 @@
/* Copyright (C) 2018 Marco Gittler <marco@gitma.de>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.welie.blessed.BluetoothPeripheral;
import java.util.Calendar;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothSenssun extends BluetoothCommunication {
private final UUID MODEL_A_MEASUREMENT_SERVICE = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb");
private final UUID MODEL_A_NOTIFICATION_CHARACTERISTIC = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb");
private final UUID MODEL_A_WRITE_CHARACTERISTIC = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb");
private final UUID MODEL_B_MEASUREMENT_SERVICE = UUID.fromString("0000ffb0-0000-1000-8000-00805f9b34fb");
private final UUID MODEL_B_NOTIFICATION_CHARACTERISTIC = UUID.fromString("0000ffb2-0000-1000-8000-00805f9b34fb");
private final UUID MODEL_B_WRITE_CHARACTERISTIC = UUID.fromString("0000ffb2-0000-1000-8000-00805f9b34fb");
private UUID writeService;
private UUID writeCharacteristic;
private int lastWeight, lastFat, lastHydration, lastMuscle, lastBone, lastKcal;
private boolean weightStabilized, stepMessageDisplayed;
private int values;
public BluetoothSenssun(Context context) {
super(context);
}
@Override
public String driverName() {
return "Senssun Fat";
}
@Override
protected void onBluetoothDiscovery(BluetoothPeripheral peripheral) {
if (peripheral.getService(MODEL_A_MEASUREMENT_SERVICE) != null) {
writeService = MODEL_A_MEASUREMENT_SERVICE;
writeCharacteristic = MODEL_A_WRITE_CHARACTERISTIC;
setNotificationOn(MODEL_A_MEASUREMENT_SERVICE, MODEL_A_NOTIFICATION_CHARACTERISTIC);
Timber.d("Found a Model A");
}
if (peripheral.getService(MODEL_B_MEASUREMENT_SERVICE) != null) {
writeService = MODEL_B_MEASUREMENT_SERVICE;
writeCharacteristic = MODEL_B_WRITE_CHARACTERISTIC;
setNotificationOn(MODEL_B_MEASUREMENT_SERVICE, MODEL_B_NOTIFICATION_CHARACTERISTIC);
Timber.d("Found a Model B");
}
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
weightStabilized = false;
stepMessageDisplayed = false;
values = 0;
Timber.d("Sync Date");
synchroniseDate();
break;
case 1:
Timber.d("Sync Time");
synchroniseTime();
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
if (value == null || value[0] != (byte)0xFF) {
return;
}
System.arraycopy(value, 1, value, 0, value.length - 1);
switch (value[0]) {
case (byte)0xA5:
parseMeasurement(value);
break;
}
}
private void parseMeasurement(byte[] data) {
switch(data[5]) {
case (byte)0xAA:
case (byte)0xA0:
if (weightStabilized) {
return;
}
if (!stepMessageDisplayed) {
sendMessage(R.string.info_step_on_scale, 0);
stepMessageDisplayed = true;
}
weightStabilized = data[5] == (byte)0xAA;
Timber.d("the byte is %d stable is %s", (data[5] & 0xff), weightStabilized ? "true": "false");
lastWeight = ((data[1] & 0xff) << 8) | (data[2] & 0xff);
if (weightStabilized) {
values |= 1;
sendMessage(R.string.info_measuring, lastWeight / 10.0f);
synchroniseUser();
}
break;
case (byte)0xBE:
setBluetoothStatus(BT_STATUS.UNEXPECTED_ERROR, "Fat Test Error");
disconnect();
break;
case (byte)0xB0:
lastFat = ((data[1] & 0xff) << 8) | (data[2] & 0xff);
lastHydration = ((data[3] & 0xff) << 8) | (data[4] & 0xff);
values |= 2;
Timber.d("got fat %d", values);
break;
case (byte)0xC0:
lastMuscle = ((data[1] & 0xff) << 8) | (data[2] & 0xff);
lastBone = ((data[4] & 0xff) << 8) | (data[3] & 0xff);
values |= 4;
Timber.d("got muscle %d", values);
break;
case (byte)0xD0:
lastKcal = ((data[1] & 0xff) << 8) | (data[2] & 0xff);
int unknown = ((data[3] & 0xff) << 8) | (data[4] & 0xff);
values |= 8;
Timber.d("got kal %d", values);
break;
}
if (values == 15) {
ScaleMeasurement scaleBtData = new ScaleMeasurement();
scaleBtData.setWeight((float)lastWeight / 10.0f);
scaleBtData.setFat((float)lastFat / 10.0f);
scaleBtData.setWater((float)lastHydration / 10.0f);
scaleBtData.setBone((float)lastBone / 10.0f);
scaleBtData.setMuscle((float)lastMuscle / 10.0f);
scaleBtData.setDateTime(new Date());
addScaleMeasurement(scaleBtData);
disconnect();
}
}
private void synchroniseDate() {
Calendar cal = Calendar.getInstance();
byte message[] = new byte[]{(byte)0xA5, (byte)0x30, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00};
message[2] = (byte)Integer.parseInt(Long.toHexString(Integer.valueOf(String.valueOf(cal.get(Calendar.YEAR)).substring(2))), 16);
String DayLength=Long.toHexString(cal.get(Calendar.DAY_OF_YEAR));
DayLength=DayLength.length()==1?"000"+DayLength:
DayLength.length()==2?"00"+DayLength:
DayLength.length()==3?"0"+DayLength:DayLength;
message[3]=(byte)Integer.parseInt(DayLength.substring(0,2), 16);
message[4]=(byte)Integer.parseInt(DayLength.substring(2,4), 16);
addChecksum(message);
writeBytes(writeService, writeCharacteristic, message);
}
private void synchroniseTime() {
Calendar cal = Calendar.getInstance();
byte message[] = new byte[]{(byte)0xA5, (byte)0x31, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00};
message[2]=(byte)Integer.parseInt(Long.toHexString(cal.get(Calendar.HOUR_OF_DAY)), 16);
message[3]=(byte)Integer.parseInt(Long.toHexString(cal.get(Calendar.MINUTE)), 16);
message[4]=(byte)Integer.parseInt(Long.toHexString(cal.get(Calendar.SECOND)), 16);
addChecksum(message);
writeBytes(writeService, writeCharacteristic, message);
}
private void addChecksum(byte[] message) {
byte verify = 0;
for(int i=1;i<message.length-2;i++){
verify=(byte) (verify+message[i] & 0xff);
}
message[message.length-2]=verify;
}
private void synchroniseUser() {
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
byte message[] = new byte[]{(byte)0xA5, (byte)0x10, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00, (byte)0x00};
//message[2] = (byte)((selectedUser.getGender().isMale() ? (byte)0x80: (byte)0x00) + 1+selectedUser.getId());
message[2] = (byte) ((selectedUser.getGender().isMale() ? 15 : 0) * 16 + selectedUser.getId());
message[3] = (byte)selectedUser.getAge();
message[4] = (byte)selectedUser.getBodyHeight();
addChecksum(message);
writeBytes(writeService, writeCharacteristic, message);
}
}

View File

@@ -1,119 +0,0 @@
package com.health.openscale.core.bluetooth;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.SparseArray;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.welie.blessed.BluetoothCentralManager;
import com.welie.blessed.BluetoothCentralManagerCallback;
import com.welie.blessed.BluetoothPeripheral;
import org.jetbrains.annotations.NotNull;
import java.util.LinkedList;
import java.util.List;
import timber.log.Timber;
public class BluetoothSinocare extends BluetoothCommunication {
private static final int MANUFACTURER_DATA_ID = 0xff64; // 16-bit little endian "header"
private static final int WEIGHT_MSB = 10;
private static final int WEIGHT_LSB = 9;
private static final int CHECKSUM_INDEX = 16;
// the number of consecutive times the same weight should be seen before it is considered "final"
private static final int WEIGHT_TRIGGER_THRESHOLD = 9;
//these values are used to check for whether the scale's weight reading has leveled out since
// the scale doesnt appear to communicate when it has a solid reading.
private static int last_seen_weight = 0;
private static int last_wait_repeat_count = 0;
private BluetoothCentralManager central;
private final BluetoothCentralManagerCallback btCallback = new BluetoothCentralManagerCallback() {
@Override
public void onDiscoveredPeripheral(@NotNull BluetoothPeripheral peripheral, @NotNull ScanResult scanResult) {
SparseArray<byte[]> manufacturerSpecificData = scanResult.getScanRecord().getManufacturerSpecificData();
byte[] data = manufacturerSpecificData.get(MANUFACTURER_DATA_ID);
float divider = 100.0f;
byte checksum = 0x00;
//the checksum here only covers the data that is between the MAC address and the checksum
//this should be bytes at indices 6-15 (both inclusive)
for (int i = 6; i < CHECKSUM_INDEX; i++)
checksum ^= data[i];
if (data[CHECKSUM_INDEX] != checksum) {
Timber.d("Checksum error, got %x, expected %x", data[CHECKSUM_INDEX] & 0xff, checksum & 0xff);
return;
}
// mac address is first 6 bytes, might be helpful if this needs to be capable of handling
// multiple scales at once. Is this a priority?
// byte[] macAddress = ;
//this is the "raw" weight as an integer number of dekagrams (1 dekagram is 0.01kg or 10 grams),
// regardless of what unit the scale is set to
int weight = data[WEIGHT_MSB] & 0xff;
weight = weight << 8 | (data[WEIGHT_LSB] & 0xff);
if (weight > 0){
if (weight != last_seen_weight) {
//record the current weight and reset the count for mow many times that value has been seen
last_seen_weight = weight;
last_wait_repeat_count = 1;
} else if (weight == last_seen_weight && last_wait_repeat_count >= WEIGHT_TRIGGER_THRESHOLD){
// record the weight
ScaleMeasurement entry = new ScaleMeasurement();
entry.setWeight(last_seen_weight / divider);
addScaleMeasurement(entry);
disconnect();
} else {
//increment the counter for the number of times this weight value has been seen
last_wait_repeat_count += 1;
}
}
}
};
public BluetoothSinocare(Context context)
{
super(context);
central = new BluetoothCentralManager(context, btCallback, new Handler(Looper.getMainLooper()));
}
@Override
public String driverName() {
return "Sinocare";
}
@Override
public void connect(String macAddress) {
Timber.d("Mac address: %s", macAddress);
List<ScanFilter> filters = new LinkedList<ScanFilter>();
ScanFilter.Builder b = new ScanFilter.Builder();
b.setDeviceAddress(macAddress);
b.setDeviceName("Weight Scale");
b.setManufacturerData(MANUFACTURER_DATA_ID, null, null);
filters.add(b.build());
central.scanForPeripheralsUsingFilters(filters);
}
@Override
public void disconnect() {
if (central != null)
central.stopScan();
central = null;
super.disconnect();
}
@Override
protected boolean onNextStep(int stepNr) {
return false;
}
}

View File

@@ -1,275 +0,0 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.bluetooth.lib.SoehnleLib;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import com.welie.blessed.BluetoothBytesParser;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothSoehnle extends BluetoothCommunication {
private final UUID WEIGHT_CUSTOM_SERVICE = UUID.fromString("352e3000-28e9-40b8-a361-6db4cca4147c");
private final UUID WEIGHT_CUSTOM_A_CHARACTERISTIC = UUID.fromString("352e3001-28e9-40b8-a361-6db4cca4147c"); // notify, read
private final UUID WEIGHT_CUSTOM_B_CHARACTERISTIC = UUID.fromString("352e3004-28e9-40b8-a361-6db4cca4147c"); // notify, read
private final UUID WEIGHT_CUSTOM_CMD_CHARACTERISTIC = UUID.fromString("352e3002-28e9-40b8-a361-6db4cca4147c"); // write
SharedPreferences prefs;
public BluetoothSoehnle(Context context) {
super(context);
prefs = PreferenceManager.getDefaultSharedPreferences(context);
}
@Override
public String driverName() {
return "Soehnle Scale";
}
@Override
protected boolean onNextStep(int stepNr) {
switch (stepNr) {
case 0:
List<ScaleUser> openScaleUserList = OpenScale.getInstance().getScaleUserList();
int index = -1;
// check if an openScale user is stored as a Soehnle user otherwise do a factory reset
for (ScaleUser openScaleUser : openScaleUserList) {
index = getSoehnleUserIndex(openScaleUser.getId());
if (index != -1) {
break;
}
}
if (index == -1) {
invokeScaleFactoryReset();
}
break;
case 1:
setNotificationOn(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL);
readBytes(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL);
break;
case 2:
// Write the current time
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setCurrentTime(Calendar.getInstance());
writeBytes(BluetoothGattUuid.SERVICE_CURRENT_TIME, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME, parser.getValue());
break;
case 3:
// Turn on notification for User Data Service
setNotificationOn(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT);
break;
case 4:
int openScaleUserId = OpenScale.getInstance().getSelectedScaleUserId();
int soehnleUserIndex = getSoehnleUserIndex(openScaleUserId);
if (soehnleUserIndex == -1) {
// create new user
Timber.d("create new Soehnle scale user");
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, new byte[]{(byte)0x01, (byte)0x00, (byte)0x00});
} else {
// select user
Timber.d("select Soehnle scale user with index " + soehnleUserIndex);
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, new byte[]{(byte) 0x02, (byte) soehnleUserIndex, (byte) 0x00, (byte) 0x00});
}
break;
case 5:
// set age
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_AGE, new byte[]{(byte)OpenScale.getInstance().getSelectedScaleUser().getAge()});
break;
case 6:
// set gender
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_GENDER, new byte[]{OpenScale.getInstance().getSelectedScaleUser().getGender().isMale() ? (byte)0x00 : (byte)0x01});
break;
case 7:
// set height
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_HEIGHT, Converters.toInt16Le((int)OpenScale.getInstance().getSelectedScaleUser().getBodyHeight()));
break;
case 8:
setNotificationOn(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_A_CHARACTERISTIC);
setNotificationOn(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_B_CHARACTERISTIC);
//writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[] {(byte)0x0c, (byte)0xff});
break;
case 9:
for (int i=1; i<8; i++) {
// get history data for soehnle user index i
writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[]{(byte) 0x09, (byte) i});
}
break;
default:
return false;
}
return true;
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
Timber.d("on bluetooth notify change " + byteInHex(value) + " on " + characteristic.toString());
if (value == null) {
return;
}
if (characteristic.equals(WEIGHT_CUSTOM_A_CHARACTERISTIC) && value.length == 15) {
if (value[0] == (byte) 0x09) {
handleWeightMeasurement(value);
}
} else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT)) {
handleUserControlPoint(value);
} else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL)) {
int batteryLevel = value[0];
Timber.d("Soehnle scale battery level is " + batteryLevel);
if (batteryLevel <= 10) {
sendMessage(R.string.info_scale_low_battery, batteryLevel);
}
}
}
private void handleUserControlPoint(byte[] value) {
if (value[0] == (byte)0x20) {
int cmd = value[1];
if (cmd == (byte)0x01) { // user create
int userId = OpenScale.getInstance().getSelectedScaleUserId();
int success = value[2];
int soehnleUserIndex = value[3];
if (success == (byte)0x01) {
Timber.d("User control point index is " + soehnleUserIndex + " for user id " + userId);
prefs.edit().putInt("userScaleIndex" + soehnleUserIndex, userId).apply();
sendMessage(R.string.info_step_on_scale_for_reference, 0);
} else {
Timber.e("Error creating new Sohnle user");
}
}
else if (cmd == (byte)0x02) { // user select
int success = value[2];
if (success != (byte)0x01) {
Timber.e("Error selecting Soehnle user");
invokeScaleFactoryReset();
jumpNextToStepNr(0);
}
}
}
}
private int getSoehnleUserIndex(int openScaleUserId) {
for (int i= 1; i<8; i++) {
int prefOpenScaleUserId = prefs.getInt("userScaleIndex"+i, -1);
if (openScaleUserId == prefOpenScaleUserId) {
return i;
}
}
return -1;
}
private void invokeScaleFactoryReset() {
Timber.d("Do a factory reset on Soehnle scale to swipe old users");
// factory reset
writeBytes(WEIGHT_CUSTOM_SERVICE, WEIGHT_CUSTOM_CMD_CHARACTERISTIC, new byte[]{(byte) 0x0b, (byte) 0xff});
for (int i= 1; i<8; i++) {
prefs.edit().putInt("userScaleIndex" + i, -1).apply();
}
}
private void handleWeightMeasurement(byte[] value) {
float weight = Converters.fromUnsignedInt16Be(value, 9) / 10.0f; // kg
int soehnleUserIndex = (int) value[1];
final int year = Converters.fromUnsignedInt16Be(value, 2);
final int month = (int) value[4];
final int day = (int) value[5];
final int hours = (int) value[6];
final int min = (int) value[7];
final int sec = (int) value[8];
final int imp5 = Converters.fromUnsignedInt16Be(value, 11);
final int imp50 = Converters.fromUnsignedInt16Be(value, 13);
String date_string = year + "/" + month + "/" + day + "/" + hours + "/" + min;
Date date_time = new Date();
try {
date_time = new SimpleDateFormat("yyyy/MM/dd/HH/mm").parse(date_string);
} catch (ParseException e) {
Timber.e("parse error " + e.getMessage());
}
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
int activityLevel = 0;
switch (scaleUser.getActivityLevel()) {
case SEDENTARY:
activityLevel = 0;
break;
case MILD:
activityLevel = 1;
break;
case MODERATE:
activityLevel = 2;
break;
case HEAVY:
activityLevel = 4;
break;
case EXTREME:
activityLevel = 5;
break;
}
int openScaleUserId = prefs.getInt("userScaleIndex"+soehnleUserIndex, -1);
if (openScaleUserId == -1) {
Timber.e("Unknown Soehnle user index " + soehnleUserIndex);
} else {
SoehnleLib soehnleLib = new SoehnleLib(scaleUser.getGender().isMale(), scaleUser.getAge(), scaleUser.getBodyHeight(), activityLevel);
ScaleMeasurement scaleMeasurement = new ScaleMeasurement();
scaleMeasurement.setUserId(openScaleUserId);
scaleMeasurement.setWeight(weight);
scaleMeasurement.setDateTime(date_time);
scaleMeasurement.setWater(soehnleLib.getWater(weight, imp50));
scaleMeasurement.setFat(soehnleLib.getFat(weight, imp50));
scaleMeasurement.setMuscle(soehnleLib.getMuscle(weight, imp50, imp5));
addScaleMeasurement(scaleMeasurement);
}
}
}

View File

@@ -1,869 +0,0 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
/*
* Based on source-code by weliem/blessed-android
*/
package com.health.openscale.core.bluetooth;
import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT32;
import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT16;
import static com.welie.blessed.BluetoothBytesParser.FORMAT_UINT8;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Handler;
import android.preference.PreferenceManager;
import android.util.Pair;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import com.welie.blessed.BluetoothBytesParser;
import java.text.DateFormat;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import java.util.Random;
import java.util.UUID;
import java.util.Vector;
import timber.log.Timber;
public abstract class BluetoothStandardWeightProfile extends BluetoothCommunication {
// UDS control point codes
protected static final byte UDS_CP_REGISTER_NEW_USER = 0x01;
protected static final byte UDS_CP_CONSENT = 0x02;
protected static final byte UDS_CP_DELETE_USER_DATA = 0x03;
protected static final byte UDS_CP_LIST_ALL_USERS = 0x04;
protected static final byte UDS_CP_DELETE_USERS = 0x05;
protected static final byte UDS_CP_RESPONSE = 0x20;
// UDS response codes
protected static final byte UDS_CP_RESP_VALUE_SUCCESS = 0x01;
protected static final byte UDS_CP_RESP_OP_CODE_NOT_SUPPORTED = 0x02;
protected static final byte UDS_CP_RESP_INVALID_PARAMETER = 0x03;
protected static final byte UDS_CP_RESP_OPERATION_FAILED = 0x04;
protected static final byte UDS_CP_RESP_USER_NOT_AUTHORIZED = 0x05;
SharedPreferences prefs;
protected boolean registerNewUser;
ScaleUser selectedUser;
ScaleMeasurement previousMeasurement;
protected boolean haveBatteryService;
protected Vector<ScaleUser> scaleUserList;
public BluetoothStandardWeightProfile(Context context) {
super(context);
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
this.selectedUser = OpenScale.getInstance().getSelectedScaleUser();
this.registerNewUser = false;
previousMeasurement = null;
haveBatteryService = false;
scaleUserList = new Vector<ScaleUser>();
}
@Override
public String driverName() {
return "Bluetooth Standard Weight Profile";
}
protected abstract int getVendorSpecificMaxUserCount();
private enum SM_STEPS {
START,
READ_DEVICE_MANUFACTURER,
READ_DEVICE_MODEL,
WRITE_CURRENT_TIME,
SET_NOTIFY_WEIGHT_MEASUREMENT,
SET_NOTIFY_BODY_COMPOSITION_MEASUREMENT,
SET_NOTIFY_CHANGE_INCREMENT,
SET_INDICATION_USER_CONTROL_POINT,
SET_NOTIFY_BATTERY_LEVEL,
READ_BATTERY_LEVEL,
SET_NOTIFY_VENDOR_SPECIFIC_USER_LIST,
REQUEST_VENDOR_SPECIFIC_USER_LIST,
REGISTER_NEW_SCALE_USER,
SELECT_SCALE_USER,
SET_SCALE_USER_DATA,
REQUEST_MEASUREMENT,
MAX_STEP
}
@Override
protected boolean onNextStep(int stepNr) {
if (stepNr > SM_STEPS.MAX_STEP.ordinal()) {
Timber.d( "WARNING: stepNr == " + stepNr + " outside range, must be from 0 to " + SM_STEPS.MAX_STEP.ordinal());
stepNr = SM_STEPS.MAX_STEP.ordinal();
}
SM_STEPS step = SM_STEPS.values()[stepNr];
Timber.d("stepNr: " + stepNr + " " + step);
switch (step) {
case START:
break;
case READ_DEVICE_MANUFACTURER:
// Read manufacturer from the Device Information Service
readBytes(BluetoothGattUuid.SERVICE_DEVICE_INFORMATION, BluetoothGattUuid.CHARACTERISTIC_MANUFACTURER_NAME_STRING);
break;
case READ_DEVICE_MODEL:
// Read model number from the Device Information Service
readBytes(BluetoothGattUuid.SERVICE_DEVICE_INFORMATION, BluetoothGattUuid.CHARACTERISTIC_MODEL_NUMBER_STRING);
break;
case WRITE_CURRENT_TIME:
writeCurrentTime();
break;
case SET_NOTIFY_WEIGHT_MEASUREMENT:
// Turn on notification for Weight Service
setNotificationOn(BluetoothGattUuid.SERVICE_WEIGHT_SCALE, BluetoothGattUuid.CHARACTERISTIC_WEIGHT_MEASUREMENT);
break;
case SET_NOTIFY_BODY_COMPOSITION_MEASUREMENT:
// Turn on notification for Body Composition Service
setNotificationOn(BluetoothGattUuid.SERVICE_BODY_COMPOSITION, BluetoothGattUuid.CHARACTERISTIC_BODY_COMPOSITION_MEASUREMENT);
break;
case SET_NOTIFY_CHANGE_INCREMENT:
// Turn on notification for User Data Service Change Increment
setNotificationOn(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_CHANGE_INCREMENT);
break;
case SET_INDICATION_USER_CONTROL_POINT:
// Turn on notification for User Control Point
setIndicationOn(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT);
break;
case SET_NOTIFY_BATTERY_LEVEL:
// Turn on notifications for Battery Service
if (setNotificationOn(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL)) {
haveBatteryService = true;
}
else {
haveBatteryService = false;
}
break;
case READ_BATTERY_LEVEL:
// read Battery Service
if (haveBatteryService) {
readBytes(BluetoothGattUuid.SERVICE_BATTERY_LEVEL, BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL);
}
break;
case SET_NOTIFY_VENDOR_SPECIFIC_USER_LIST:
setNotifyVendorSpecificUserList();
break;
case REQUEST_VENDOR_SPECIFIC_USER_LIST:
scaleUserList.clear();
requestVendorSpecificUserList();
stopMachineState();
break;
case REGISTER_NEW_SCALE_USER:
int userId = this.selectedUser.getId();
int consentCode = getUserScaleConsent(userId);
int userIndex = getUserScaleIndex(userId);
if (consentCode == -1 || userIndex == -1) {
registerNewUser = true;
}
if (registerNewUser) {
Random randomFactory = new Random();
consentCode = randomFactory.nextInt(10000);
storeUserScaleConsentCode(userId, consentCode);
registerUser(consentCode);
stopMachineState();
}
break;
case SELECT_SCALE_USER:
Timber.d("Select user on scale!");
setUser(this.selectedUser.getId());
stopMachineState();
break;
case SET_SCALE_USER_DATA:
if (registerNewUser) {
writeUserDataToScale();
// stopping machine state to have all user data written, before the reference measurment starts, otherwise the scale might not store the user
stopMachineState();
// reading CHARACTERISTIC_CHANGE_INCREMENT to resume machine state
readBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_CHANGE_INCREMENT);
}
break;
case REQUEST_MEASUREMENT:
if (registerNewUser) {
requestMeasurement();
stopMachineState();
sendMessage(R.string.info_step_on_scale_for_reference, 0);
}
break;
default:
return false;
}
return true;
}
protected void writeUserDataToScale() {
writeBirthday();
writeGender();
writeHeight();
writeActivityLevel();
writeInitials();
setChangeIncrement();
}
@Override
public void onBluetoothNotify(UUID characteristic, byte[] value) {
BluetoothBytesParser parser = new BluetoothBytesParser(value);
if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME)) {
Date currentTime = parser.getDateTime();
Timber.d(String.format("Received device time: %s", currentTime));
}
else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_WEIGHT_MEASUREMENT)) {
handleWeightMeasurement(value);
}
else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_BODY_COMPOSITION_MEASUREMENT)) {
handleBodyCompositionMeasurement(value);
}
else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_BATTERY_LEVEL)) {
int batteryLevel = parser.getIntValue(FORMAT_UINT8);
Timber.d(String.format("Received battery level %d%%", batteryLevel));
if (batteryLevel <= 10) {
sendMessage(R.string.info_scale_low_battery, batteryLevel);
}
}
else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_MANUFACTURER_NAME_STRING)) {
String manufacturer = parser.getStringValue(0);
Timber.d(String.format("Received manufacturer: %s", manufacturer));
}
else if(characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_MODEL_NUMBER_STRING)) {
String modelNumber = parser.getStringValue(0);
Timber.d(String.format("Received modelnumber: %s", modelNumber));
}
else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT)) {
handleUserControlPointNotify(value);
}
else if (characteristic.equals(BluetoothGattUuid.CHARACTERISTIC_CHANGE_INCREMENT)) {
int increment = parser.getIntValue(FORMAT_UINT32);
Timber.d(String.format("Notification from CHARACTERISTIC_CHANGE_INCREMENT, value: %s", increment));
resumeMachineState();
}
else {
Timber.d(String.format("Notification from unhandled characteristic: %s, value: [%s]",
characteristic.toString(), byteInHex(value)));
}
}
protected void handleUserControlPointNotify(byte[] value) {
if(value[0]==UDS_CP_RESPONSE) {
switch (value[1]) {
case UDS_CP_LIST_ALL_USERS:
Timber.d("UDS_CP_LIST_ALL_USERS value [" + byteInHex(value) + "]");
break;
case UDS_CP_REGISTER_NEW_USER:
if (value[2] == UDS_CP_RESP_VALUE_SUCCESS) {
int userIndex = value[3];
int userId = this.selectedUser.getId();
Timber.d(String.format("UDS_CP_REGISTER_NEW_USER: Created scale user index: "
+ "%d (app user id: %d)", userIndex, userId));
storeUserScaleIndex(userId, userIndex);
resumeMachineState();
} else {
Timber.e("UDS_CP_REGISTER_NEW_USER: ERROR: could not register new scale user, code: " + value[2]);
}
break;
case UDS_CP_CONSENT:
if (registerNewUser) {
Timber.d("UDS_CP_CONSENT: registerNewUser==true, value[2] == " + value[2]);
resumeMachineState();
break;
}
if (value[2] == UDS_CP_RESP_VALUE_SUCCESS) {
Timber.d("UDS_CP_CONSENT: Success user consent");
resumeMachineState();
} else if (value[2] == UDS_CP_RESP_USER_NOT_AUTHORIZED) {
Timber.e("UDS_CP_CONSENT: Not authorized");
enterScaleUserConsentUi(this.selectedUser.getId(), getUserScaleIndex(this.selectedUser.getId()));
}
else {
Timber.e("UDS_CP_CONSENT: unhandled, code: " + value[2]);
}
break;
default:
Timber.e("CHARACTERISTIC_USER_CONTROL_POINT: Unhandled response code "
+ value[1] + " value [" + byteInHex(value) + "]");
break;
}
}
else {
Timber.d("CHARACTERISTIC_USER_CONTROL_POINT: non-response code " + value[0]
+ " value [" + byteInHex(value) + "]");
}
}
protected ScaleMeasurement weightMeasurementToScaleMeasurement(byte[] value) {
String prefix = "weightMeasurementToScaleMeasurement() ";
Timber.d(String.format(prefix + "value: [%s]", byteInHex(value)));
BluetoothBytesParser parser = new BluetoothBytesParser(value);
final int flags = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT8);
boolean isKg = (flags & 0x01) == 0;
final boolean timestampPresent = (flags & 0x02) > 0;
final boolean userIDPresent = (flags & 0x04) > 0;
final boolean bmiAndHeightPresent = (flags & 0x08) > 0;
Timber.d(String.format(prefix + "flags: 0x%02x ", flags)
+ "[" + (isKg ? "SI" : "Imperial")
+ (timestampPresent ? ", timestamp" : "")
+ (userIDPresent ? ", userID" : "")
+ (bmiAndHeightPresent ? ", bmiAndHeight" : "")
+ "], " + String.format("reserved flags: 0x%02x ", flags & 0xf0));
ScaleMeasurement scaleMeasurement = new ScaleMeasurement();
// Determine the right weight multiplier
float weightMultiplier = isKg ? 0.005f : 0.01f;
// Get weight
float weightValue = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * weightMultiplier;
Timber.d(prefix+ "weight: " + weightValue);
scaleMeasurement.setWeight(weightValue);
if(timestampPresent) {
Date timestamp = parser.getDateTime();
Timber.d(prefix + "timestamp: " + timestamp.toString());
scaleMeasurement.setDateTime(timestamp);
}
if(userIDPresent) {
int scaleUserIndex = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT8);
int userID = getUserIdFromScaleIndex(scaleUserIndex);
Timber.d(String.format(prefix + "scale user index: %d (app user id: %d)", scaleUserIndex, userID));
if (userID != -1) {
scaleMeasurement.setUserId(userID);
}
if (registerNewUser) {
Timber.d(String.format(prefix + "Setting initial weight for user %s to: %s and registerNewUser to false", userID,
weightValue));
if (selectedUser.getId() == userID) {
this.selectedUser.setInitialWeight(weightValue);
OpenScale.getInstance().updateScaleUser(selectedUser);
}
registerNewUser = false;
resumeMachineState();
}
}
if(bmiAndHeightPresent) {
float BMI = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.1f;
Timber.d(prefix + "BMI: " + BMI);
float heightInMeters = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.001f;
Timber.d(prefix + "heightInMeters: " + heightInMeters);
}
Timber.d(String.format("Got weight: %s", weightValue));
return scaleMeasurement;
}
protected void handleWeightMeasurement(byte[] value) {
mergeWithPreviousScaleMeasurement(weightMeasurementToScaleMeasurement(value));
}
protected ScaleMeasurement bodyCompositionMeasurementToScaleMeasurement(byte[] value) {
String prefix = "bodyCompositionMeasurementToScaleMeasurement() ";
Timber.d(String.format(prefix + "value: [%s]", byteInHex(value)));
BluetoothBytesParser parser = new BluetoothBytesParser(value);
final int flags = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16);
boolean isKg = (flags & 0x0001) == 0;
float massMultiplier = (float) (isKg ? 0.005 : 0.01);
boolean timestampPresent = (flags & 0x0002) > 0;
boolean userIDPresent = (flags & 0x0004) > 0;
boolean bmrPresent = (flags & 0x0008) > 0;
boolean musclePercentagePresent = (flags & 0x0010) > 0;
boolean muscleMassPresent = (flags & 0x0020) > 0;
boolean fatFreeMassPresent = (flags & 0x0040) > 0;
boolean softLeanMassPresent = (flags & 0x0080) > 0;
boolean bodyWaterMassPresent = (flags & 0x0100) > 0;
boolean impedancePresent = (flags & 0x0200) > 0;
boolean weightPresent = (flags & 0x0400) > 0;
boolean heightPresent = (flags & 0x0800) > 0;
boolean multiPacketMeasurement = (flags & 0x1000) > 0;
Timber.d(String.format(prefix + "flags: 0x%02x ", flags)
+ "[" + (isKg ? "SI" : "Imperial")
+ (timestampPresent ? ", timestamp" : "")
+ (userIDPresent ? ", userID" : "")
+ (bmrPresent ? ", bmr" : "")
+ (musclePercentagePresent ? ", musclePercentage" : "")
+ (muscleMassPresent ? ", muscleMass" : "")
+ (fatFreeMassPresent ? ", fatFreeMass" : "")
+ (softLeanMassPresent ? ", softLeanMass" : "")
+ (bodyWaterMassPresent ? ", bodyWaterMass" : "")
+ (impedancePresent ? ", impedance" : "")
+ (weightPresent ? ", weight" : "")
+ (heightPresent ? ", height" : "")
+ (multiPacketMeasurement ? ", multiPacketMeasurement" : "")
+ "], " + String.format("reserved flags: 0x%04x ", flags & 0xe000));
ScaleMeasurement scaleMeasurement = new ScaleMeasurement();
float bodyFatPercentage = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.1f;
Timber.d(prefix + "bodyFatPercentage: " + bodyFatPercentage);
scaleMeasurement.setFat(bodyFatPercentage);
// Read timestamp if present
if (timestampPresent) {
Date timestamp = parser.getDateTime();
Timber.d(prefix + "timestamp: " + timestamp.toString());
scaleMeasurement.setDateTime(timestamp);
}
// Read userID if present
if (userIDPresent) {
int scaleUserIndex = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT8);
int userID = getUserIdFromScaleIndex(scaleUserIndex);
Timber.d(String.format(prefix + "scale user index: %d (app user id: %d)", scaleUserIndex, userID));
if (userID != -1) {
scaleMeasurement.setUserId(userID);
}
}
// Read bmr if present
if (bmrPresent) {
int bmrInJoules = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16);
int bmrInKcal = Math.round(((bmrInJoules / 4.1868f) * 10.0f) / 10.0f);
Timber.d(prefix + "bmrInJoules: " + bmrInJoules + " bmrInKcal: " + bmrInKcal);
}
// Read musclePercentage if present
if (musclePercentagePresent) {
float musclePercentage = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.1f;
Timber.d(prefix + "musclePercentage: " + musclePercentage);
scaleMeasurement.setMuscle(musclePercentage);
}
// Read muscleMass if present
if (muscleMassPresent) {
float muscleMass = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier;
Timber.d(prefix + "muscleMass: " + muscleMass);
}
// Read fatFreeMassPresent if present
if (fatFreeMassPresent) {
float fatFreeMass = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier;
Timber.d(prefix + "fatFreeMass: " + fatFreeMass);
}
// Read softleanMass if present
float softLeanMass = 0.0f;
if (softLeanMassPresent) {
softLeanMass = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier;
Timber.d(prefix + "softLeanMass: " + softLeanMass);
}
// Read bodyWaterMass if present
if (bodyWaterMassPresent) {
float bodyWaterMass = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier;
Timber.d(prefix + "bodyWaterMass: " + bodyWaterMass);
scaleMeasurement.setWater(bodyWaterMass);
}
// Read impedance if present
if (impedancePresent) {
float impedance = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * 0.1f;
Timber.d(prefix + "impedance: " + impedance);
}
// Read weight if present
float weightValue = 0.0f;
if (weightPresent) {
weightValue = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16) * massMultiplier;
Timber.d(prefix + "weightValue: " + weightValue);
scaleMeasurement.setWeight(weightValue);
}
else {
if (previousMeasurement != null) {
weightValue = previousMeasurement.getWeight();
if (weightValue > 0) {
weightPresent = true;
}
}
}
// calculate lean body mass and bone mass
if (weightPresent && softLeanMassPresent) {
float fatMass = weightValue * bodyFatPercentage / 100.0f;
float leanBodyMass = weightValue - fatMass;
float boneMass = leanBodyMass - softLeanMass;
scaleMeasurement.setLbm(leanBodyMass);
scaleMeasurement.setBone(boneMass);
}
// Read height if present
if (heightPresent) {
float heightValue = parser.getIntValue(BluetoothBytesParser.FORMAT_UINT16);
Timber.d(prefix + "heightValue: " + heightValue);
}
if (multiPacketMeasurement) {
Timber.e(prefix + "multiPacketMeasurement not supported!");
}
Timber.d(String.format("Got body composition: %s", byteInHex(value)));
return scaleMeasurement;
}
protected void handleBodyCompositionMeasurement(byte[] value) {
mergeWithPreviousScaleMeasurement(bodyCompositionMeasurementToScaleMeasurement(value));
}
/**
* Bluetooth scales usually implement both "Weight Scale Feature" and "Body Composition Feature".
* It seems that scale first transmits weight measurement (with user index and timestamp) and
* later transmits body composition measurement (without user index and timestamp).
* If previous measurement contains user index and new measurements does not then merge them and
* store as one.
* disconnect() function must store previousMeasurement to openScale db (if present).
*
* @param newMeasurement the scale data that should be merged with previous measurement or
* stored as previous measurement.
*/
protected void mergeWithPreviousScaleMeasurement(ScaleMeasurement newMeasurement) {
if (previousMeasurement == null) {
if (newMeasurement.getUserId() == -1) {
addScaleMeasurement(newMeasurement);
}
else {
previousMeasurement = newMeasurement;
}
}
else {
if ((newMeasurement.getUserId() == -1) && (previousMeasurement.getUserId() != -1)) {
previousMeasurement.merge(newMeasurement);
addScaleMeasurement(previousMeasurement);
previousMeasurement = null;
}
else {
addScaleMeasurement(previousMeasurement);
if (newMeasurement.getUserId() == -1) {
addScaleMeasurement(newMeasurement);
previousMeasurement = null;
}
else {
previousMeasurement = newMeasurement;
}
}
}
}
@Override
public void disconnect() {
if (previousMeasurement != null) {
addScaleMeasurement(previousMeasurement);
previousMeasurement = null;
}
super.disconnect();
}
protected abstract void setNotifyVendorSpecificUserList();
protected abstract void requestVendorSpecificUserList();
protected void registerUser(int consentCode) {
BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0,0,0});
parser.setIntValue(UDS_CP_REGISTER_NEW_USER, FORMAT_UINT8,0);
parser.setIntValue(consentCode, FORMAT_UINT16,1);
Timber.d(String.format("registerUser consentCode: %d", consentCode));
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, parser.getValue());
}
protected void setUser(int userIndex, int consentCode) {
BluetoothBytesParser parser = new BluetoothBytesParser(new byte[]{0,0,0,0});
parser.setIntValue(UDS_CP_CONSENT,FORMAT_UINT8,0);
parser.setIntValue(userIndex, FORMAT_UINT8,1);
parser.setIntValue(consentCode, FORMAT_UINT16,2);
Timber.d(String.format("setUser userIndex: %d, consentCode: %d", userIndex, consentCode));
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT, parser.getValue());
}
protected synchronized void setUser(int userId) {
int userIndex = getUserScaleIndex(userId);
int consentCode = getUserScaleConsent(userId);
Timber.d(String.format("setting: userId %d, userIndex: %d, consent Code: %d ", userId, userIndex, consentCode));
setUser(userIndex, consentCode);
}
protected void deleteUser(int userIndex, int consentCode) {
setUser(userIndex, consentCode);
deleteUser();
}
protected void deleteUser() {
BluetoothBytesParser parser = new BluetoothBytesParser(new byte[] { 0 });
parser.setIntValue(UDS_CP_DELETE_USER_DATA, FORMAT_UINT8, 0);
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_CONTROL_POINT,
parser.getValue());
}
protected void writeCurrentTime() {
BluetoothBytesParser parser = new BluetoothBytesParser();
parser.setCurrentTime(Calendar.getInstance());
writeBytes(BluetoothGattUuid.SERVICE_CURRENT_TIME, BluetoothGattUuid.CHARACTERISTIC_CURRENT_TIME,
parser.getValue());
}
protected void writeBirthday() {
BluetoothBytesParser parser = new BluetoothBytesParser();
Calendar userBirthday = dateToCalender(this.selectedUser.getBirthday());
Timber.d(String.format("user Birthday: %tD", userBirthday));
parser.setDateTime(userBirthday);
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_DATE_OF_BIRTH,
Arrays.copyOfRange(parser.getValue(), 0, 4));
}
protected Calendar dateToCalender(Date date) {
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
return calendar;
}
protected void writeGender() {
BluetoothBytesParser parser = new BluetoothBytesParser();
int gender = this.selectedUser.getGender().toInt();
Timber.d(String.format("gender: %d", gender));
parser.setIntValue(gender, FORMAT_UINT8);
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_GENDER,
parser.getValue());
}
protected void writeHeight() {
BluetoothBytesParser parser = new BluetoothBytesParser();
int height = (int) this.selectedUser.getBodyHeight();
Timber.d(String.format("height: %d", height));
parser.setIntValue(height, FORMAT_UINT16);
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_USER_HEIGHT,
parser.getValue());
}
protected void writeActivityLevel() {
Timber.d("Write user activity level not implemented!");
}
protected void writeInitials() {
Timber.d("Write user initials not implemented!");
}
protected void setChangeIncrement() {
BluetoothBytesParser parser = new BluetoothBytesParser();
int i = 1;
Timber.d(String.format("Setting Change increment to %s", i));
parser.setIntValue(i, FORMAT_UINT32);
writeBytes(BluetoothGattUuid.SERVICE_USER_DATA, BluetoothGattUuid.CHARACTERISTIC_CHANGE_INCREMENT,
parser.getValue());
}
protected void requestMeasurement() {
Timber.d("Take measurement command not implemented!");
}
protected synchronized void storeUserScaleConsentCode(int userId, int consentCode) {
prefs.edit().putInt("userConsentCode" + userId, consentCode).apply();
}
protected synchronized int getUserScaleConsent(int userId) {
return prefs.getInt("userConsentCode" + userId, -1);
}
protected synchronized void storeUserScaleIndex(int userId, int userIndex) {
int currentUserIndex = getUserScaleIndex(userId);
if (currentUserIndex != -1) {
prefs.edit().putInt("userIdFromUserScaleIndex" + currentUserIndex, -1);
}
prefs.edit().putInt("userScaleIndex" + userId, userIndex).apply();
if (userIndex != -1) {
prefs.edit().putInt("userIdFromUserScaleIndex" + userIndex, userId).apply();
}
}
protected synchronized int getUserIdFromScaleIndex(int userScaleIndex) {
return prefs.getInt("userIdFromUserScaleIndex" + userScaleIndex, -1);
}
protected synchronized int getUserScaleIndex(int userId) {
return prefs.getInt("userScaleIndex" + userId, -1);
}
protected void reconnectOrSetSmState(SM_STEPS requestedState, SM_STEPS minState, Handler uiHandler) {
if (needReConnect()) {
jumpNextToStepNr(SM_STEPS.START.ordinal());
stopMachineState();
reConnectPreviousPeripheral(uiHandler);
return;
}
if (getStepNr() > minState.ordinal()) {
jumpNextToStepNr(requestedState.ordinal());
}
resumeMachineState();
}
@Override
public void selectScaleUserIndexForAppUserId(int appUserId, int scaleUserIndex, Handler uiHandler) {
Timber.d("Select scale user index from UI: user id: " + appUserId + ", scale user index: " + scaleUserIndex);
if (scaleUserIndex == -1) {
reconnectOrSetSmState(SM_STEPS.REGISTER_NEW_SCALE_USER, SM_STEPS.REGISTER_NEW_SCALE_USER, uiHandler);
}
else {
storeUserScaleIndex(appUserId, scaleUserIndex);
if (getUserScaleConsent(appUserId) == -1) {
enterScaleUserConsentUi(appUserId, scaleUserIndex);
}
else {
reconnectOrSetSmState(SM_STEPS.SELECT_SCALE_USER, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler);
}
}
}
@Override
public void setScaleUserConsent(int appUserId, int scaleUserConsent, Handler uiHandler) {
Timber.d("set scale user consent from UI: user id: " + appUserId + ", scale user consent: " + scaleUserConsent);
storeUserScaleConsentCode(appUserId, scaleUserConsent);
if (scaleUserConsent == -1) {
reconnectOrSetSmState(SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler);
}
else {
reconnectOrSetSmState(SM_STEPS.SELECT_SCALE_USER, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler);
}
}
protected void handleVendorSpecificUserList(byte[] value) {
Timber.d(String.format("Got user data: <%s>", byteInHex(value)));
BluetoothBytesParser parser = new BluetoothBytesParser(value);
int userListStatus = parser.getIntValue(FORMAT_UINT8);
if (userListStatus == 2) {
Timber.d("scale have no users!");
storeUserScaleConsentCode(selectedUser.getId(), -1);
storeUserScaleIndex(selectedUser.getId(), -1);
jumpNextToStepNr(SM_STEPS.REGISTER_NEW_SCALE_USER.ordinal());
resumeMachineState();
return;
}
else if (userListStatus == 1) {
for (int i = 0; i < scaleUserList.size(); i++) {
if (i == 0) {
Timber.d("scale user list:");
}
Timber.d("\n" + (i + 1) + ". " + scaleUserList.get(i));
}
if ((scaleUserList.size() == 0)) {
storeUserScaleConsentCode(selectedUser.getId(), -1);
storeUserScaleIndex(selectedUser.getId(), -1);
jumpNextToStepNr(SM_STEPS.REGISTER_NEW_SCALE_USER.ordinal());
resumeMachineState();
return;
}
if (getUserScaleIndex(selectedUser.getId()) == -1 || getUserScaleConsent(selectedUser.getId()) == -1) {
chooseExistingScaleUser(scaleUserList);
return;
}
resumeMachineState();
return;
}
int index = parser.getIntValue(FORMAT_UINT8);
String initials = parser.getStringValue();
int end = 3 > initials.length() ? initials.length() : 3;
initials = initials.substring(0, end);
if (initials.length() == 3) {
if (initials.charAt(0) == 0xff && initials.charAt(1) == 0xff && initials.charAt(2) == 0xff) {
initials = "";
}
}
parser.setOffset(5);
int year = parser.getIntValue(FORMAT_UINT16);
int month = parser.getIntValue(FORMAT_UINT8);
int day = parser.getIntValue(FORMAT_UINT8);
int height = parser.getIntValue(FORMAT_UINT8);
int gender = parser.getIntValue(FORMAT_UINT8);
int activityLevel = parser.getIntValue(FORMAT_UINT8);
GregorianCalendar calendar = new GregorianCalendar(year, month - 1, day);
ScaleUser scaleUser = new ScaleUser();
scaleUser.setUserName(initials);
scaleUser.setBirthday(calendar.getTime());
scaleUser.setBodyHeight(height);
scaleUser.setGender(Converters.Gender.fromInt(gender));
scaleUser.setActivityLevel(Converters.ActivityLevel.fromInt(activityLevel - 1));
scaleUser.setId(index);
scaleUserList.add(scaleUser);
if (scaleUserList.size() == getVendorSpecificMaxUserCount()) {
if (getUserScaleIndex(selectedUser.getId()) == -1 || getUserScaleConsent(selectedUser.getId()) == -1) {
chooseExistingScaleUser(scaleUserList);
return;
}
resumeMachineState();
}
}
protected void chooseExistingScaleUser(Vector<ScaleUser> userList) {
final DateFormat dateFormat = DateFormat.getDateInstance();
int choicesCount = userList.size();
if (userList.size() < getVendorSpecificMaxUserCount()) {
choicesCount = userList.size() + 1;
}
CharSequence[] choiceStrings = new String[choicesCount];
int indexArray[] = new int[choicesCount];
int selectedItem = -1;
for (int i = 0; i < userList.size(); ++i) {
ScaleUser u = userList.get(i);
String name = u.getUserName();
choiceStrings[i] = (name.length() > 0 ? name : String.format("P%02d", u.getId()))
+ " " + context.getString(u.getGender().isMale() ? R.string.label_male : R.string.label_female).toLowerCase()
+ " " + context.getString(R.string.label_height).toLowerCase() + ":" + u.getBodyHeight()
+ " " + context.getString(R.string.label_birthday).toLowerCase() + ":" + dateFormat.format(u.getBirthday())
+ " " + context.getString(R.string.label_activity_level).toLowerCase() + ":" + (u.getActivityLevel().toInt() + 1);
indexArray[i] = u.getId();
}
if (userList.size() < getVendorSpecificMaxUserCount()) {
choiceStrings[userList.size()] = context.getString(R.string.info_create_new_user_on_scale);
indexArray[userList.size()] = -1;
}
Pair<CharSequence[], int[]> choices = new Pair(choiceStrings, indexArray);
chooseScaleUserUi(choices);
}
protected String getInitials(String fullName) {
if (fullName == null || fullName.isEmpty() || fullName.chars().allMatch(Character::isWhitespace)) {
return getDefaultInitials();
}
return buildInitialsStringFrom(fullName).toUpperCase();
}
private String getDefaultInitials() {
int userId = this.selectedUser.getId();
int userIndex = getUserScaleIndex(userId);
return "P" + userIndex + " ";
}
private String buildInitialsStringFrom(String fullName) {
String[] name = fullName.trim().split(" +");
String initials = "";
for (int i = 0; i < 3; i++) {
if (i < name.length && name[i] != "") {
initials += name[i].charAt(0);
} else {
initials += " ";
}
}
return initials;
}
}

View File

@@ -1,360 +0,0 @@
/* Copyright (C) 2018 Maks Verver <maks@verver.ch>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import androidx.annotation.Nullable;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.bluetooth.lib.TrisaBodyAnalyzeLib;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import java.util.Date;
import java.util.UUID;
import timber.log.Timber;
/**
* Driver for Trisa Body Analyze 4.0.
*
* @see <a href="https://github.com/maksverver/trisa-body-analyze">Protocol details</a>
*/
public class BluetoothTrisaBodyAnalyze extends BluetoothCommunication {
// GATT service UUID
private static final UUID WEIGHT_SCALE_SERVICE_UUID =
BluetoothGattUuid.fromShortCode(0x7802);
// GATT service characteristics.
private static final UUID MEASUREMENT_CHARACTERISTIC_UUID =
BluetoothGattUuid.fromShortCode(0x8a21);
private static final UUID DOWNLOAD_COMMAND_CHARACTERISTIC_UUID =
BluetoothGattUuid.fromShortCode(0x8a81);
private static final UUID UPLOAD_COMMAND_CHARACTERISTIC_UUID =
BluetoothGattUuid.fromShortCode(0x8a82);
// Commands sent from device to host.
private static final byte UPLOAD_PASSWORD = (byte) 0xa0;
private static final byte UPLOAD_CHALLENGE = (byte) 0xa1;
// Commands sent from host to device.
private static final byte DOWNLOAD_INFORMATION_UTC_COMMAND = 0x02;
private static final byte DOWNLOAD_INFORMATION_RESULT_COMMAND = 0x20;
private static final byte DOWNLOAD_INFORMATION_BROADCAST_ID_COMMAND = 0x21;
private static final byte DOWNLOAD_INFORMATION_ENABLE_DISCONNECT_COMMAND = 0x22;
/**
* Broadcast id, which the scale will include in its Bluetooth alias. This must be set to some
* value to complete the pairing process (though the actual value doesn't seem to matter).
*/
private static final int BROADCAST_ID = 0;
/**
* Prefix for {@link SharedPreferences} keys that store device passwords.
*
* @see #loadDevicePassword
* @see #saveDevicePassword
*/
private static final String SHARED_PREFERENCES_PASSWORD_KEY_PREFIX =
"trisa_body_analyze_password_for_device_";
/**
* ASCII string that identifies the connected device (i.e. the hex-encoded Bluetooth MAC
* address). Used in shared preference keys to store per-device settings.
*/
@Nullable
private String deviceId;
/** Device password as a 32-bit integer, or {@code null} if the device password is unknown. */
@Nullable
private static Integer password;
/**
* Indicates whether we are pairing. If this is {@code true} then we have written the
* set-broadcast-id command, and should disconnect after the write succeeds.
*
* @see #onPasswordReceived
* @see #onNextStep
*/
private boolean pairing = false;
/**
* Timestamp of 2010-01-01 00:00:00 UTC (or local time?)
*/
private static final long TIMESTAMP_OFFSET_SECONDS = 1262304000L;
public BluetoothTrisaBodyAnalyze(Context context) {
super(context);
}
@Override
public String driverName() {
return "Trisa Body Analyze 4.0";
}
@Override
public void connect(String hwAddress) {
Timber.i("connect(\"%s\")", hwAddress);
super.connect(hwAddress);
this.deviceId = hwAddress;
this.password = loadDevicePassword(context, hwAddress);
}
@Override
protected boolean onNextStep(int stepNr) {
Timber.i("onNextStep(%d)", stepNr);
switch (stepNr) {
case 0:
// Register for notifications of the measurement characteristic.
setIndicationOn(WEIGHT_SCALE_SERVICE_UUID, MEASUREMENT_CHARACTERISTIC_UUID);
break; // more commands follow
case 1:
// Register for notifications of the command upload characteristic.
//
// This is the last init command, which causes a switch to the main state machine
// immediately after. This is important because we should be in the main state
// to handle pairing correctly.
setIndicationOn(WEIGHT_SCALE_SERVICE_UUID, UPLOAD_COMMAND_CHARACTERISTIC_UUID);
break;
case 2:
// This state is triggered by the write in onPasswordReceived()
if (pairing) {
pairing = false;
disconnect();
}
break;
case 3:
writeCommand(DOWNLOAD_INFORMATION_ENABLE_DISCONNECT_COMMAND);
break;
default:
return false; // no more commands
}
return true;
}
@Override
protected void onBluetoothNotify(UUID characteristic, byte[] value) {
Timber.i("onBluetoothdataChange() characteristic=%s value=%s", characteristic, byteInHex(value));
if (UPLOAD_COMMAND_CHARACTERISTIC_UUID.equals(characteristic)) {
if (value.length == 0) {
Timber.e("Missing command byte!");
return;
}
byte command = value[0];
switch (command) {
case UPLOAD_PASSWORD:
onPasswordReceived(value);
break;
case UPLOAD_CHALLENGE:
onChallengeReceived(value);
break;
default:
Timber.e("Unknown command byte received: %d", command);
}
return;
}
if (MEASUREMENT_CHARACTERISTIC_UUID.equals(characteristic)) {
onScaleMeasurumentReceived(value);
return;
}
Timber.e("Unknown characteristic changed: %s", characteristic);
}
private void onPasswordReceived(byte[] data) {
if (data.length < 5) {
Timber.e("Password data too short");
return;
}
password = Converters.fromSignedInt32Le(data, 1);
if (deviceId == null) {
Timber.e("Can't save password: device id not set!");
} else {
Timber.i("Saving password '%08x' for device id '%s'", password, deviceId);
saveDevicePassword(context, deviceId, password);
}
sendMessage(R.string.trisa_scale_pairing_succeeded, null);
// To complete the pairing process, we must set the scale's broadcast id, and then
// disconnect. The writeCommand() call below will trigger the next state machine transition,
// which will disconnect when `pairing == true`.
pairing = true;
writeCommand(DOWNLOAD_INFORMATION_BROADCAST_ID_COMMAND, BROADCAST_ID);
}
private void onChallengeReceived(byte[] data) {
if (data.length < 5) {
Timber.e("Challenge data too short");
return;
}
if (password == null) {
Timber.w("Received challenge, but password is unknown.");
sendMessage(R.string.trisa_scale_not_paired, null);
disconnect();
return;
}
int challenge = Converters.fromSignedInt32Le(data, 1);
int response = challenge ^ password;
writeCommand(DOWNLOAD_INFORMATION_RESULT_COMMAND, response);
int deviceTimestamp = convertJavaTimestampToDevice(System.currentTimeMillis());
writeCommand(DOWNLOAD_INFORMATION_UTC_COMMAND, deviceTimestamp);
}
private void onScaleMeasurumentReceived(byte[] data) {
ScaleUser user = OpenScale.getInstance().getSelectedScaleUser();
ScaleMeasurement measurement = parseScaleMeasurementData(data, user);
if (measurement == null) {
Timber.e("Failed to parse scale measure measurement data: %s", byteInHex(data));
return;
}
addScaleMeasurement(measurement);
}
public ScaleMeasurement parseScaleMeasurementData(byte[] data, ScaleUser user) {
// data contains:
//
// 1 byte: info about presence of other fields:
// bit 0: timestamp
// bit 1: resistance1
// bit 2: resistance2
// (other bits aren't used here)
// 4 bytes: weight
// 4 bytes: timestamp (if info bit 0 is set)
// 4 bytes: resistance1 (if info bit 1 is set)
// 4 bytes: resistance2 (if info bit 2 is set)
// (following fields aren't used here)
// Check that we have at least weight & timestamp, which is the minimum information that
// ScaleMeasurement needs.
if (data.length < 9) {
return null; // data is too short
}
byte infoByte = data[0];
boolean hasTimestamp = (infoByte & 1) == 1;
boolean hasResistance1 = (infoByte & 2) == 2;
boolean hasResistance2 = (infoByte & 4) == 4;
if (!hasTimestamp) {
return null;
}
float weightKg = getBase10Float(data, 1);
int deviceTimestamp = Converters.fromSignedInt32Le(data, 5);
ScaleMeasurement measurement = new ScaleMeasurement();
measurement.setDateTime(new Date(convertDeviceTimestampToJava(deviceTimestamp)));
measurement.setWeight((float) weightKg);
// Only resistance 2 is used; resistance 1 is 0, even if it is present.
int resistance2Offset = 9 + (hasResistance1 ? 4 : 0);
if (hasResistance2 && resistance2Offset + 4 <= data.length && isValidUser(user)) {
// Calculate body composition statistics from measured weight & resistance, combined
// with age, height and sex from the user profile. The accuracy of the resulting figures
// is questionable, but it's better than nothing. Even if the absolute numbers aren't
// very meaningful, it might still be useful to track changes over time.
float resistance2 = getBase10Float(data, resistance2Offset);
float impedance = resistance2 < 410f ? 3.0f : 0.3f * (resistance2 - 400f);
TrisaBodyAnalyzeLib trisaBodyAnalyzeLib = new TrisaBodyAnalyzeLib(user.getGender().isMale() ? 1 : 0, user.getAge(), user.getBodyHeight());
measurement.setFat(trisaBodyAnalyzeLib.getFat(weightKg, impedance));
measurement.setWater(trisaBodyAnalyzeLib.getWater(weightKg, impedance));
measurement.setMuscle(trisaBodyAnalyzeLib.getMuscle(weightKg, impedance));
measurement.setBone(trisaBodyAnalyzeLib.getBone(weightKg, impedance));
}
return measurement;
}
/** Write a single command byte, without any arguments. */
private void writeCommand(byte commandByte) {
writeCommandBytes(new byte[]{commandByte});
}
/**
* Write a command with a 32-bit integer argument.
*
* <p>The command string consists of the command byte followed by 4 bytes: the argument
* encoded in little-endian byte order.</p>
*/
private void writeCommand(byte commandByte, int argument) {
byte[] bytes = new byte[5];
bytes[0] = commandByte;
Converters.toInt32Le(bytes, 1, argument);
writeCommandBytes(bytes);
}
private void writeCommandBytes(byte[] bytes) {
Timber.d("writeCommand bytes=%s", byteInHex(bytes));
writeBytes(WEIGHT_SCALE_SERVICE_UUID, DOWNLOAD_COMMAND_CHARACTERISTIC_UUID, bytes);
}
private static String getDevicePasswordKey(String deviceId) {
return SHARED_PREFERENCES_PASSWORD_KEY_PREFIX + deviceId;
}
@Nullable
private static Integer loadDevicePassword(Context context, String deviceId) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String key = getDevicePasswordKey(deviceId);
try {
// Strictly speaking, there is a race condition between the calls to contains() and
// getInt(), but it's not a problem because we never delete passwords.
return prefs.contains(key) ? Integer.valueOf(prefs.getInt(key, 0)) : null;
} catch (ClassCastException e) {
Timber.e(e, "Password preference value is not an integer.");
return null;
}
}
private static void saveDevicePassword(Context context, String deviceId, int password) {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
prefs.edit().putInt(getDevicePasswordKey(deviceId), password).apply();
}
/** Converts 4 bytes to a floating point number, starting from {@code offset}.
*
* <p>The first three little-endian bytes form the 24-bit mantissa. The last byte contains the
* signed exponent, applied in base 10.
*
* @throws IndexOutOfBoundsException if {@code offset < 0} or {@code offset + 4> data.length}
*/
public float getBase10Float(byte[] data, int offset) {
int mantissa = Converters.fromUnsignedInt24Le(data, offset);
int exponent = data[offset + 3]; // note: byte is signed.
return (float)(mantissa * Math.pow(10, exponent));
}
public int convertJavaTimestampToDevice(long javaTimestampMillis) {
return (int)((javaTimestampMillis + 500)/1000 - TIMESTAMP_OFFSET_SECONDS);
}
public long convertDeviceTimestampToJava(int deviceTimestampSeconds) {
return 1000 * (TIMESTAMP_OFFSET_SECONDS + (long)deviceTimestampSeconds);
}
private boolean isValidUser(@Nullable ScaleUser user) {
return user != null && user.getAge() > 0 && user.getBodyHeight() > 0;
}
}

View File

@@ -1,109 +0,0 @@
package com.health.openscale.core.bluetooth;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.SparseArray;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
import com.welie.blessed.BluetoothCentralManager;
import com.welie.blessed.BluetoothCentralManagerCallback;
import com.welie.blessed.BluetoothPeripheral;
import org.jetbrains.annotations.NotNull;
import java.util.LinkedList;
import java.util.List;
import timber.log.Timber;
public class BluetoothYoda1Scale extends BluetoothCommunication {
private BluetoothCentralManager central;
private final BluetoothCentralManagerCallback btCallback = new BluetoothCentralManagerCallback() {
@Override
public void onDiscoveredPeripheral(@NotNull BluetoothPeripheral peripheral, @NotNull ScanResult scanResult) {
SparseArray<byte[]> manufacturerSpecificData = scanResult.getScanRecord().getManufacturerSpecificData();
byte[] weightBytes = manufacturerSpecificData.valueAt(0);
//int featuresAndCounter = manufacturerSpecificData.keyAt(0);
//Timber.d("Feature: %d, Counter: %d", featuresAndCounter & 0xFF, featuresAndCounter >> 8);
final byte ctrlByte = weightBytes[6];
final boolean isStabilized = isBitSet(ctrlByte, 0);
final boolean isKgUnit = isBitSet(ctrlByte, 2);
final boolean isOneDecimal = isBitSet(ctrlByte, 3);
if (isStabilized) {
Timber.d("One digit decimal: %s", isOneDecimal);
Timber.d("Unit Kg: %s", isKgUnit);
float weight;
if (isKgUnit) {
weight = (float) (((weightBytes[0] & 0xFF) << 8) | (weightBytes[1] & 0xFF)) / 10.0f;
} else {
// catty
weight = (float) (((weightBytes[0] & 0xFF) << 8) | (weightBytes[1] & 0xFF)) / 20.0f;
}
if (!isOneDecimal) {
weight /= 10.0;
}
Timber.d("Got weight: %f", weight);
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
ScaleMeasurement scaleBtData = new ScaleMeasurement();
scaleBtData.setWeight(Converters.toKilogram(weight, selectedUser.getScaleUnit()));
addScaleMeasurement(scaleBtData);
disconnect();
}
}
};
public BluetoothYoda1Scale(Context context) {
super(context);
central = new BluetoothCentralManager(context, btCallback, new Handler(Looper.getMainLooper()));
}
@Override
public void connect(String macAddress) {
Timber.d("Mac address: %s", macAddress);
List<ScanFilter> filters = new LinkedList<ScanFilter>();
ScanFilter.Builder b = new ScanFilter.Builder();
b.setDeviceAddress(macAddress);
b.setDeviceName("Yoda1");
filters.add(b.build());
central.scanForPeripheralsUsingFilters(filters);
}
@Override
public void disconnect() {
if (central != null)
central.stopScan();
central = null;
super.disconnect();
}
@Override
public String driverName() {
return "Yoda1 Scale";
}
@Override
protected boolean onNextStep(int stepNr) {
return false;
}
}

View File

@@ -0,0 +1,135 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.bluetooth.data.ScaleUser
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Defines the events that can be emitted by a [ScaleCommunicator].
*/
sealed class BluetoothEvent {
/**
* Event triggered when a connection to a device has been successfully established.
* @param deviceName The name of the connected device.
* @param deviceAddress The MAC address of the connected device.
*/
data class Connected(val deviceName: String, val deviceAddress: String) : BluetoothEvent()
/**
* Event triggered when an existing connection to a device has been disconnected.
* @param deviceAddress The MAC address of the disconnected device.
* @param reason An optional reason for the disconnection (e.g., "Connection lost", "Manually disconnected").
*/
data class Disconnected(val deviceAddress: String, val reason: String? = null) : BluetoothEvent()
/**
* Event triggered when a connection attempt to a device has failed.
* @param deviceAddress The MAC address of the device to which the connection failed.
* @param error An error message describing the reason for the failure.
*/
data class ConnectionFailed(val deviceAddress: String, val error: String) : BluetoothEvent()
/**
* Event triggered when measurement data has been received from the scale.
* Uses [ScaleMeasurement] as the common data format.
* @param measurement The received [ScaleMeasurement] object.
* @param deviceAddress The MAC address of the device from which the measurement originated.
*/
data class MeasurementReceived(
val measurement: ScaleMeasurement,
val deviceAddress: String
) : BluetoothEvent()
/**
* Event triggered when a general error related to a device occurs.
* @param deviceAddress The MAC address of the device associated with the error.
* @param error An error message describing the issue.
*/
data class Error(val deviceAddress: String, val error: String) : BluetoothEvent()
/**
* Event triggered when a text message (e.g., status or instruction) is received from the device.
* @param message The received message.
* @param deviceAddress The MAC address of the device from which the message originated.
*/
data class DeviceMessage(val message: String, val deviceAddress: String) : BluetoothEvent()
/**
* Event triggered when user interaction is required to select a user on the scale.
* This is often used when a scale supports multiple users and the app needs to clarify
* which app user corresponds to the scale user.
* @param description A message describing why user selection is needed.
* @param deviceIdentifier The identifier (e.g., MAC address) of the device requiring user selection.
* @param userData Optional data associated with the event, potentially containing information about users on the scale.
* The exact type should be defined by the communicator implementation if more specific data is available.
*/
data class UserSelectionRequired(
val description: String,
val deviceIdentifier: String,
val userData: Any? // Consider a more specific type if the structure of eventData is known.
) : BluetoothEvent()
}
/**
* A generic interface for communication with a Bluetooth scale.
* This interface abstracts the specific Bluetooth implementation (e.g., legacy Bluetooth or BLE).
*/
interface ScaleCommunicator {
/**
* A [StateFlow] indicating whether a connection attempt to a device is currently in progress.
* `true` if a connection attempt is active, `false` otherwise.
*/
val isConnecting: StateFlow<Boolean>
/**
* A [StateFlow] indicating whether an active connection to a device currently exists.
* `true` if connected, `false` otherwise.
*/
val isConnected: StateFlow<Boolean>
/**
* Initiates a connection attempt to the device with the specified MAC address.
* @param address The MAC address of the target device.
* @param scaleUser The user to be selected or used on the scale (optional).
* @param appUserId The ID of the user in the application (optional, can be used for context).
*/
fun connect(address: String, scaleUser: ScaleUser?, appUserId: Int?)
/**
* Disconnects the existing connection to the currently connected device.
*/
fun disconnect()
/**
* Explicitly requests a new measurement from the connected device.
* (Note: Not always supported or required by all scale devices).
*/
fun requestMeasurement()
/**
* Provides a [SharedFlow] that emits [BluetoothEvent]s.
* Consumers can collect events from this flow to react to connection changes,
* received measurements, errors, and other device-related events.
* @return A [SharedFlow] of [BluetoothEvent]s.
*/
fun getEventsFlow(): SharedFlow<BluetoothEvent>
}

View File

@@ -0,0 +1,210 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth
import android.content.Context
import android.util.SparseArray
import com.health.openscale.core.bluetooth.scales.DummyScaleHandler
import com.health.openscale.core.bluetooth.scales.ScaleDeviceHandler
import com.health.openscale.core.bluetooth.scalesJava.BluetoothCommunication
import com.health.openscale.core.bluetooth.scalesJava.BluetoothYunmaiSE_Mini
import com.health.openscale.core.bluetooth.scalesJava.LegacyScaleAdapter
import com.health.openscale.core.database.DatabaseRepository
import com.health.openscale.core.utils.LogManager
import com.health.openscale.ui.screen.bluetooth.ScannedDeviceInfo
import java.util.UUID
/**
* Factory class responsible for creating appropriate [ScaleCommunicator] instances
* for different Bluetooth scale devices. It decides whether to use a modern Kotlin-based
* handler or a legacy Java-based adapter.
*/
class ScaleFactory(
private val applicationContext: Context,
private val databaseRepository: DatabaseRepository // Needed for LegacyScaleAdapter
) {
private val TAG = "ScaleHandlerFactory"
// List of modern Kotlin-based device handlers.
// These are checked first for device compatibility.
private val modernKotlinHandlers: List<ScaleDeviceHandler> = listOf(
DummyScaleHandler("Mi Scale"), // Recognizes devices with "Mi Scale" in their name
DummyScaleHandler("Beurer"), // Recognizes devices with "Beurer" in their name
DummyScaleHandler("BF700") // Recognizes devices with "BF700" in their name
)
/**
* Attempts to create a legacy Java Bluetooth driver instance based on the device name.
* This method contains the logic to map device names to specific Java driver classes.
*
* @param context The application context.
* @param deviceName The name of the Bluetooth device.
* @return A [BluetoothCommunication] instance if a matching driver is found, otherwise null.
*/
private fun createLegacyJavaDriver(context: Context?, deviceName: String): BluetoothCommunication? {
// val name = deviceName.lowercase() // deviceName is already used directly below, toLowerCase is not strictly needed if comparisons handle case.
// Currently, only Yunmai drivers are active examples.
// The extensive list of commented-out drivers can be re-enabled or migrated as needed.
if (deviceName.startsWith("YUNMAI-SIGNAL") || deviceName.startsWith("YUNMAI-ISM")) {
return BluetoothYunmaiSE_Mini(context, true)
}
if (deviceName.startsWith("YUNMAI-ISSE")) {
return BluetoothYunmaiSE_Mini(context, false)
}
// Add other legacy driver instantiations here based on deviceName.
// Example:
// if (name.startsWith("some_legacy_device")) {
// return SomeLegacyDeviceDriver(context)
// }
return null
}
/**
* Creates a [ScaleCommunicator] using the legacy Java driver approach.
* It wraps a [BluetoothCommunication] instance (Java driver) in a [LegacyScaleAdapter].
*
* @param identifier The device name or other identifier used to find a legacy Java driver.
* @return A [LegacyScaleAdapter] instance if a suitable Java driver is found, otherwise null.
*/
private fun createLegacyCommunicator(identifier: String): ScaleCommunicator? {
val javaDriverInstance = createLegacyJavaDriver(applicationContext, identifier)
return if (javaDriverInstance != null) {
LogManager.i(TAG, "Creating LegacyScaleAdapter with Java driver '${javaDriverInstance.javaClass.simpleName}'.")
LegacyScaleAdapter(
applicationContext = applicationContext,
bluetoothDriverInstance = javaDriverInstance,
databaseRepository = databaseRepository
)
} else {
LogManager.w(TAG, "Could not create LegacyScaleAdapter: No Java driver found for '$identifier'.")
null
}
}
/**
* Creates a [ScaleCommunicator] based on a modern [ScaleDeviceHandler].
* This method is conceptual for now, as the current DummyScaleHandlers are not full communicators.
* In a full implementation, this might return the handler itself if it's a ScaleCommunicator,
* or wrap it in a modern adapter.
*
* @param handler The [ScaleDeviceHandler] that can handle the device.
* @return A [ScaleCommunicator] instance if one can be provided by or for the handler, otherwise null.
*/
private fun createModernCommunicator(handler: ScaleDeviceHandler): ScaleCommunicator? {
LogManager.i(TAG, "Attempting to create modern communicator for handler '${handler.getDriverName()}'.")
// If the ScaleDeviceHandler itself is a ScaleCommunicator:
if (handler is ScaleCommunicator) {
return handler
} else {
// Placeholder: Logic to wrap the handler in a specific "ModernScaleCommunicator"
// if the handler itself isn't a ScaleCommunicator.
// e.g., return ModernScaleAdapter(applicationContext, handler, databaseRepository)
LogManager.w(TAG, "Modern handler '${handler.getDriverName()}' is not a ScaleCommunicator, and no wrapper is implemented.")
return null
}
}
/**
* Creates the most suitable [ScaleCommunicator] for the given scanned device.
* It prioritizes modern Kotlin-based handlers and falls back to legacy adapters if necessary.
*
* @param deviceInfo Information about the scanned Bluetooth device.
* @return A [ScaleCommunicator] instance if a suitable handler or adapter is found, otherwise null.
*/
fun createCommunicator(deviceInfo: ScannedDeviceInfo): ScaleCommunicator? {
// The `determinedHandlerDisplayName` from ScannedDeviceInfo can be useful here if it was
// specifically set by getSupportingHandlerInfo for a known handler.
// Otherwise, `deviceInfo.name` is the primary identifier for the logic here.
val primaryIdentifier = deviceInfo.name ?: "UnknownDevice"
LogManager.d(TAG, "createCommunicator: Searching for communicator for '${primaryIdentifier}' (${deviceInfo.address}). Handler hint: '${deviceInfo.determinedHandlerDisplayName}'")
// 1. Check if a modern Kotlin handler explicitly supports the device.
for (handler in modernKotlinHandlers) {
if (handler.canHandleDevice(
deviceName = deviceInfo.name,
deviceAddress = deviceInfo.address,
serviceUuids = deviceInfo.serviceUuids,
manufacturerData = deviceInfo.manufacturerData
)) {
LogManager.i(TAG, "Modern Kotlin handler '${handler.getDriverName()}' claims '${primaryIdentifier}'.")
val modernCommunicator = createModernCommunicator(handler)
if (modernCommunicator != null) {
LogManager.i(TAG, "Modern communicator '${modernCommunicator.javaClass.simpleName}' created for '${primaryIdentifier}'.")
return modernCommunicator
} else {
LogManager.w(TAG, "Modern handler '${handler.getDriverName()}' claimed '${primaryIdentifier}', but failed to create a communicator.")
}
}
}
LogManager.d(TAG, "No modern Kotlin handler actively claimed '${primaryIdentifier}' or could create a communicator.")
// 2. Fallback to legacy adapter if no modern handler matched or created a communicator.
// The device name (or a specific legacy handler name, if known from `determinedHandlerDisplayName`) is used.
val identifierForLegacy = deviceInfo.determinedHandlerDisplayName ?: primaryIdentifier
LogManager.i(TAG, "Attempting fallback to legacy adapter for identifier '${identifierForLegacy}'.")
val legacyCommunicator = createLegacyCommunicator(identifierForLegacy)
if (legacyCommunicator != null) {
LogManager.i(TAG, "Legacy communicator '${legacyCommunicator.javaClass.simpleName}' created for device (identifier: '${identifierForLegacy}').")
return legacyCommunicator
}
LogManager.w(TAG, "No suitable communicator (neither modern nor legacy) found for device (name: '${deviceInfo.name}', address: '${deviceInfo.address}', handler hint: '${deviceInfo.determinedHandlerDisplayName}').")
return null
}
/**
* Checks if any known handler (modern Kotlin or legacy Java-based) can theoretically support the given device.
* This can be used by the UI to indicate if a device is potentially recognizable.
*
* @param deviceName The name of the Bluetooth device.
* @param deviceAddress The MAC address of the device.
* @param serviceUuids A list of advertised service UUIDs.
* @param manufacturerData Manufacturer-specific data from the advertisement.
* @return A Pair where `first` is true if a handler is found, and `second` is the name of the handler/driver, or null.
*/
fun getSupportingHandlerInfo(
deviceName: String?,
deviceAddress: String,
serviceUuids: List<UUID>,
manufacturerData: SparseArray<ByteArray>?
): Pair<Boolean, String?> {
val primaryIdentifier = deviceName ?: "UnknownDevice"
// LogManager.d(TAG, "getSupportingHandlerInfo for: '$primaryIdentifier', Addr: $deviceAddress, UUIDs: ${serviceUuids.size}, ManuData: ${manufacturerData != null}")
// Check modern handlers first
for (handler in modernKotlinHandlers) {
if (handler.canHandleDevice(deviceName, deviceAddress, serviceUuids, manufacturerData)) {
// LogManager.d(TAG, "getSupportingHandlerInfo: Modern handler '${handler.getDriverName()}' matches '$primaryIdentifier'.")
return true to handler.getDriverName() // The "driver name" of the modern handler
}
}
// Then check if a legacy driver would exist based on the name
if (deviceName != null) {
val legacyJavaDriver = createLegacyJavaDriver(applicationContext, deviceName)
if (legacyJavaDriver != null) {
// LogManager.d(TAG, "getSupportingHandlerInfo: Legacy driver '${legacyJavaDriver.javaClass.simpleName}' matches '$deviceName'.")
// Return the driver name from the BluetoothCommunication interface if available and meaningful.
return true to legacyJavaDriver.driverName() // Assumes BluetoothCommunication has a driverName() method.
}
}
LogManager.d(TAG, "getSupportingHandlerInfo: No supporting handler found for '$primaryIdentifier'.")
return false to null
}
}

View File

@@ -0,0 +1,124 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.data;
import java.util.Date;
public class ScaleMeasurement implements Cloneable {
private int id;
private int userId;
private boolean enabled;
private Date dateTime;
private float weight;
private float fat;
private float water;
private float muscle;
private float visceralFat;
private float lbm;
private float bone;
public ScaleMeasurement() {
userId = -1;
enabled = true;
dateTime = new Date();
weight = 0.0f;
fat = 0.0f;
water = 0.0f;
muscle = 0.0f;
lbm = 0.0f;
bone = 0.0f;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getUserId() {
return userId;
}
public void setUserId(int user_id) {
this.userId = user_id;
}
public Date getDateTime() {
return dateTime;
}
public void setDateTime(Date date_time) {
this.dateTime = date_time;
}
public float getWeight() {
return weight;
}
public void setWeight(float weight) {
this.weight = weight;
}
public float getFat() {
return fat;
}
public void setFat(float fat) {
this.fat = fat;
}
public float getWater() {
return water;
}
public void setWater(float water) {
this.water = water;
}
public float getMuscle() {
return muscle;
}
public void setMuscle(float muscle) {
this.muscle = muscle;
}
public float getVisceralFat() {
return visceralFat;
}
public void setVisceralFat(float visceralFat) {
this.visceralFat = visceralFat;
}
public float getLbm() {
return lbm;
}
public void setLbm(float lbm) {
this.lbm = lbm;
}
public float getBone() { return bone; }
public void setBone(float bone) {this.bone = bone; }
}

View File

@@ -0,0 +1,134 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.data;
import com.health.openscale.core.data.ActivityLevel;
import com.health.openscale.core.data.GenderType;
import com.health.openscale.core.data.WeightUnit;
import java.util.Calendar;
import java.util.Date;
public class ScaleUser {
private int id;
private String userName;
private Date birthday;
private float bodyHeight;
private GenderType gender;
private WeightUnit scaleUnit;
private ActivityLevel activityLevel;
public ScaleUser() {
userName = "";
birthday = new Date();
bodyHeight = -1;
gender = GenderType.MALE;
activityLevel = ActivityLevel.SEDENTARY;
}
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public Date getBirthday() {
return birthday;
}
public void setBirthday(Date birthday) {
this.birthday = birthday;
}
public float getBodyHeight() {
return bodyHeight;
}
public void setBodyHeight(float bodyHeight) {
this.bodyHeight = bodyHeight;
}
public int getAge(Date todayDate) {
Calendar calToday = Calendar.getInstance();
if (todayDate != null) {
calToday.setTime(todayDate);
}
Calendar calBirthday = Calendar.getInstance();
calBirthday.setTime(birthday);
return yearsBetween(calBirthday, calToday);
}
public int getAge() {
return getAge(null);
}
public WeightUnit getScaleUnit() {
return scaleUnit;
}
public void setScaleUnit(WeightUnit scaleUnit) {
this.scaleUnit = scaleUnit;
}
public void setActivityLevel(ActivityLevel level) {
activityLevel = level;
}
public ActivityLevel getActivityLevel() {
return activityLevel;
}
private int yearsBetween(Calendar start, Calendar end) {
int years = end.get(Calendar.YEAR) - start.get(Calendar.YEAR);
final int startMonth = start.get(Calendar.MONTH);
final int endMonth = end.get(Calendar.MONTH);
if (endMonth < startMonth
|| (endMonth == startMonth
&& end.get(Calendar.DAY_OF_MONTH) < start.get(Calendar.DAY_OF_MONTH))) {
years -= 1;
}
return years;
}
public GenderType getGender() {
return gender;
}
public void setGender(GenderType gender) {
this.gender = gender;
}
}

View File

@@ -1,173 +0,0 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
/**
* based on https://github.com/prototux/MIBCS-reverse-engineering by prototux
*/
package com.health.openscale.core.bluetooth.lib;
public class MiScaleLib {
private int sex; // male = 1; female = 0
private int age;
private float height;
public MiScaleLib(int sex, int age, float height) {
this.sex = sex;
this.age = age;
this.height = height;
}
private float getLBMCoefficient(float weight, float impedance) {
float lbm = (height * 9.058f / 100.0f) * (height / 100.0f);
lbm += weight * 0.32f + 12.226f;
lbm -= impedance * 0.0068f;
lbm -= age * 0.0542f;
return lbm;
}
public float getBMI(float weight) {
return weight / (((height * height) / 100.0f) / 100.0f);
}
public float getLBM(float weight, float impedance) {
float leanBodyMass = weight - ((getBodyFat(weight, impedance) * 0.01f) * weight) - getBoneMass(weight, impedance);
if (sex == 0 && leanBodyMass >= 84.0f) {
leanBodyMass = 120.0f;
}
else if (sex == 1 && leanBodyMass >= 93.5f) {
leanBodyMass = 120.0f;
}
return leanBodyMass;
}
public float getMuscle(float weight, float impedance) {
return this.getLBM(weight,impedance); // this is wrong but coherent with MiFit app behaviour
}
public float getWater(float weight, float impedance) {
float coeff;
float water = (100.0f - getBodyFat(weight, impedance)) * 0.7f;
if (water < 50) {
coeff = 1.02f;
} else {
coeff = 0.98f;
}
return coeff * water;
}
public float getBoneMass(float weight, float impedance) {
float boneMass;
float base;
if (sex == 0) {
base = 0.245691014f;
}
else {
base = 0.18016894f;
}
boneMass = (base - (getLBMCoefficient(weight, impedance) * 0.05158f)) * -1.0f;
if (boneMass > 2.2f) {
boneMass += 0.1f;
}
else {
boneMass -= 0.1f;
}
if (sex == 0 && boneMass > 5.1f) {
boneMass = 8.0f;
}
else if (sex == 1 && boneMass > 5.2f) {
boneMass = 8.0f;
}
return boneMass;
}
public float getVisceralFat(float weight) {
float visceralFat = 0.0f;
if (sex == 0) {
if (weight > (13.0f - (height * 0.5f)) * -1.0f) {
float subsubcalc = ((height * 1.45f) + (height * 0.1158f) * height) - 120.0f;
float subcalc = weight * 500.0f / subsubcalc;
visceralFat = (subcalc - 6.0f) + (age * 0.07f);
}
else {
float subcalc = 0.691f + (height * -0.0024f) + (height * -0.0024f);
visceralFat = (((height * 0.027f) - (subcalc * weight)) * -1.0f) + (age * 0.07f) - age;
}
}
else {
if (height < weight * 1.6f) {
float subcalc = ((height * 0.4f) - (height * (height * 0.0826f))) * -1.0f;
visceralFat = ((weight * 305.0f) / (subcalc + 48.0f)) - 2.9f + (age * 0.15f);
}
else {
float subcalc = 0.765f + height * -0.0015f;
visceralFat = (((height * 0.143f) - (weight * subcalc)) * -1.0f) + (age * 0.15f) - 5.0f;
}
}
return visceralFat;
}
public float getBodyFat(float weight, float impedance) {
float bodyFat = 0.0f;
float lbmSub = 0.8f;
if (sex == 0 && age <= 49) {
lbmSub = 9.25f;
} else if (sex == 0 && age > 49) {
lbmSub = 7.25f;
}
float lbmCoeff = getLBMCoefficient(weight, impedance);
float coeff = 1.0f;
if (sex == 1 && weight < 61.0f) {
coeff = 0.98f;
}
else if (sex == 0 && weight > 60.0f) {
coeff = 0.96f;
if (height > 160.0f) {
coeff *= 1.03f;
}
} else if (sex == 0 && weight < 50.0f) {
coeff = 1.02f;
if (height > 160.0f) {
coeff *= 1.03f;
}
}
bodyFat = (1.0f - (((lbmCoeff - lbmSub) * coeff) / weight)) * 100.0f;
if (bodyFat > 63.0f) {
bodyFat = 75.0f;
}
return bodyFat;
}
}

View File

@@ -1,254 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth.lib;
public class OneByoneLib {
private int sex; // male = 1; female = 0
private int age;
private float height;
private int peopleType; // low activity = 0; medium activity = 1; high activity = 2
public OneByoneLib(int sex, int age, float height, int peopleType) {
this.sex = sex;
this.age = age;
this.height = height;
this.peopleType = peopleType;
}
public float getBMI(float weight) {
return weight / (((height * height) / 100.0f) / 100.0f);
}
public float getLBM(float weight, float bodyFat) {
return weight - (bodyFat / 100.0f * weight);
}
public float getMuscle(float weight, float impedanceValue){
return (float)((height * height / impedanceValue * 0.401) + (sex * 3.825) - (age * 0.071) + 5.102) / weight * 100.0f;
}
public float getWater(float bodyFat) {
float coeff;
float water = (100.0f - bodyFat) * 0.7f;
if (water < 50) {
coeff = 1.02f;
} else {
coeff = 0.98f;
}
return coeff * water;
}
public float getBoneMass(float weight, float impedanceValue) {
float boneMass, sexConst , peopleCoeff = 0.0f;
switch (peopleType) {
case 0:
peopleCoeff = 1.0f;
break;
case 1:
peopleCoeff = 1.0427f;
break;
case 2:
peopleCoeff = 1.0958f;
break;
}
boneMass = (9.058f * (height / 100.0f) * (height / 100.0f) + 12.226f + (0.32f * weight)) - (0.0068f * impedanceValue);
if (sex == 1) { // male
sexConst = 3.49305f;
} else {
sexConst = 4.76325f;
}
boneMass = boneMass - sexConst - (age * 0.0542f) * peopleCoeff;
if (boneMass <= 2.2f) {
boneMass = boneMass - 0.1f;
} else {
boneMass = boneMass + 0.1f;
}
boneMass = boneMass * 0.05158f;
if (0.5f > boneMass) {
return 0.5f;
} else if (boneMass > 8.0f) {
return 8.0f;
}
return boneMass;
}
public float getVisceralFat(float weight) {
float visceralFat;
if (sex == 1) {
if (height < ((1.6f * weight) + 63.0f)) {
visceralFat = (((weight * 305.0f) / (0.0826f * height * height - (0.4f * height) + 48.0f)) - 2.9f) + ((float)age * 0.15f);
if (peopleType == 0) {
return visceralFat;
} else {
return subVisceralFat_A(visceralFat);
}
} else {
visceralFat = (((float)age * 0.15f) + ((weight * (-0.0015f * height + 0.765f)) - height * 0.143f)) - 5.0f;
if (peopleType == 0) {
return visceralFat;
} else {
return subVisceralFat_A(visceralFat);
}
}
} else {
if (((0.5f * height) - 13.0f) > weight) {
visceralFat = (((float)age * 0.07f) + ((weight * (-0.0024f * height + 0.691f)) - (height * 0.027f))) - 10.5f;
if (peopleType != 0) {
return subVisceralFat_A(visceralFat);
} else {
return visceralFat;
}
} else {
visceralFat = (weight * 500.0f) / (((1.45f * height) + 0.1158f * height * height) - 120.0f) - 6.0f + ((float)age * 0.07f);
if (peopleType == 0) {
return visceralFat;
} else {
return subVisceralFat_A(visceralFat);
}
}
}
}
private float subVisceralFat_A(float visceralFat) {
if (peopleType != 0) {
if (10.0f <= visceralFat) {
return subVisceralFat_B(visceralFat);
} else {
visceralFat = visceralFat - 4.0f;
return visceralFat;
}
} else {
if (10.0f > visceralFat) {
visceralFat = visceralFat - 2.0f;
return visceralFat;
} else {
return subVisceralFat_B(visceralFat);
}
}
}
private float subVisceralFat_B(float visceralFat) {
if (visceralFat < 10.0f) {
visceralFat = visceralFat * 0.85f;
return visceralFat;
} else {
if (20.0f < visceralFat) {
visceralFat = visceralFat * 0.85f;
return visceralFat;
} else {
visceralFat = visceralFat * 0.8f;
return visceralFat;
}
}
}
public float getBodyFat(float weight, float impedanceValue) {
float bodyFatConst=0;
if (impedanceValue >= 1200.0f) bodyFatConst = 8.16f;
else if (impedanceValue >= 200.0f) bodyFatConst = 0.0068f * impedanceValue;
else if (impedanceValue >= 50.0f) bodyFatConst = 1.36f;
float peopleTypeCoeff, bodyVar, bodyFat;
if (peopleType == 0) {
peopleTypeCoeff = 1.0f;
} else {
if (peopleType == 1) {
peopleTypeCoeff = 1.0427f;
} else {
peopleTypeCoeff = 1.0958f;
}
}
bodyVar = (9.058f * height) / 100.0f;
bodyVar = bodyVar * height;
bodyVar = bodyVar / 100.0f + 12.226f;
bodyVar = bodyVar + 0.32f * weight;
bodyVar = bodyVar - bodyFatConst;
if (age > 0x31) {
bodyFatConst = 7.25f;
if (sex == 1) {
bodyFatConst = 0.8f;
}
} else {
bodyFatConst = 9.25f;
if (sex == 1) {
bodyFatConst = 0.8f;
}
}
bodyVar = bodyVar - bodyFatConst;
bodyVar = bodyVar - (age * 0.0542f);
bodyVar = bodyVar * peopleTypeCoeff;
if (sex != 0) {
if (61.0f > weight) {
bodyVar *= 0.98f;
}
} else {
if (50.0f > weight) {
bodyVar *= 1.02f;
}
if (weight > 60.0f) {
bodyVar *= 0.96f;
}
if (height > 160.0f) {
bodyVar *= 1.03f;
}
}
bodyVar = bodyVar / weight;
bodyFat = 100.0f * (1.0f - bodyVar);
if (1.0f > bodyFat) {
return 1.0f;
} else {
if (bodyFat > 45.0f) {
return 45.0f;
} else {
return bodyFat;
}
}
}
}

View File

@@ -1,201 +0,0 @@
package com.health.openscale.core.bluetooth.lib;
// This class is similar to OneByoneLib, but the way measures are computer are slightly different
public class OneByoneNewLib {
private int sex;
private int age;
private float height;
private int peopleType; // low activity = 0; medium activity = 1; high activity = 2
public OneByoneNewLib(int sex, int age, float height, int peopleType) {
this.sex = sex;
this.age = age;
this.height = height;
this.peopleType = peopleType;
}
public float getBMI(float weight) {
float bmi = weight / (((height * height) / 100.0f) / 100.0f);
return getBounded(bmi, 10, 90);
}
public float getLBM(float weight, int impedance) {
float lbmCoeff = height / 100 * height / 100 * 9.058F;
lbmCoeff += 12.226;
lbmCoeff += weight * 0.32;
lbmCoeff -= impedance * 0.0068;
lbmCoeff -= age * 0.0542;
return lbmCoeff;
}
public float getBMMRCoeff(float weight){
int bmmrCoeff = 20;
if(sex == 1){
bmmrCoeff = 21;
if(age < 0xd){
bmmrCoeff = 36;
} else if(age < 0x10){
bmmrCoeff = 30;
} else if(age < 0x12){
bmmrCoeff = 26;
} else if(age < 0x1e){
bmmrCoeff = 23;
} else if (age >= 0x32){
bmmrCoeff = 20;
}
} else {
if(age < 0xd){
bmmrCoeff = 34;
} else if(age < 0x10){
bmmrCoeff = 29;
} else if(age < 0x12){
bmmrCoeff = 24;
} else if(age < 0x1e){
bmmrCoeff = 22;
} else if (age >= 0x32){
bmmrCoeff = 19;
}
}
return bmmrCoeff;
}
public float getBMMR(float weight){
float bmmr;
if(sex == 1){
bmmr = (weight * 14.916F + 877.8F) - height * 0.726F;
bmmr -= age * 8.976;
} else {
bmmr = (weight * 10.2036F + 864.6F) - height * 0.39336F;
bmmr -= age * 6.204;
}
return getBounded(bmmr, 500, 1000);
}
public float getBodyFatPercentage(float weight, int impedance) {
float bodyFat = getLBM(weight, impedance);
float bodyFatConst;
if (sex == 0) {
if (age < 0x32) {
bodyFatConst = 9.25F;
} else {
bodyFatConst = 7.25F;
}
} else {
bodyFatConst = 0.8F;
}
bodyFat -= bodyFatConst;
if (sex == 0){
if (weight < 50){
bodyFat *= 1.02;
} else if(weight > 60){
bodyFat *= 0.96;
}
if(height > 160){
bodyFat *= 1.03;
}
} else {
if (weight < 61){
bodyFat *= 0.98;
}
}
return 100 * (1 - bodyFat / weight);
}
public float getBoneMass(float weight, int impedance){
float lbmCoeff = getLBM(weight, impedance);
float boneMassConst;
if(sex == 1){
boneMassConst = 0.18016894F;
} else {
boneMassConst = 0.245691014F;
}
boneMassConst = lbmCoeff * 0.05158F - boneMassConst;
float boneMass;
if(boneMassConst <= 2.2){
boneMass = boneMassConst - 0.1F;
} else {
boneMass = boneMassConst + 0.1F;
}
return getBounded(boneMass, 0.5F, 8);
}
public float getMuscleMass(float weight, int impedance){
float muscleMass = weight - getBodyFatPercentage(weight, impedance) * 0.01F * weight;
muscleMass -= getBoneMass(weight, impedance);
return getBounded(muscleMass, 10, 120);
}
public float getSkeletonMusclePercentage(float weight, int impedance){
float skeletonMuscleMass = getWaterPercentage(weight, impedance);
skeletonMuscleMass *= weight;
skeletonMuscleMass *= 0.8422F * 0.01F;
skeletonMuscleMass -= 2.9903;
skeletonMuscleMass /= weight;
return skeletonMuscleMass * 100;
}
public float getVisceralFat(float weight){
float visceralFat;
if (sex == 1) {
if (height < weight * 1.6 + 63.0) {
visceralFat =
age * 0.15F + ((weight * 305.0F) /((height * 0.0826F * height - height * 0.4F) + 48.0F) - 2.9F);
}
else {
visceralFat = age * 0.15F + (weight * (height * -0.0015F + 0.765F) - height * 0.143F) - 5.0F;
}
}
else {
if (weight <= height * 0.5 - 13.0) {
visceralFat = age * 0.07F + (weight * (height * -0.0024F + 0.691F) - height * 0.027F) - 10.5F;
}
else {
visceralFat = age * 0.07F + ((weight * 500.0F) / ((height * 1.45F + height * 0.1158F * height) - 120.0F) - 6.0F);
}
}
return getBounded(visceralFat, 1, 50);
}
public float getWaterPercentage(float weight, int impedance){
float waterPercentage = (100 - getBodyFatPercentage(weight, impedance)) * 0.7F;
if (waterPercentage > 50){
waterPercentage *= 0.98;
} else {
waterPercentage *= 1.02;
}
return getBounded(waterPercentage, 35, 75);
}
public float getProteinPercentage(float weight, int impedance){
return (
(100.0F - getBodyFatPercentage(weight, impedance))
- getWaterPercentage(weight, impedance) * 1.08F
)
- (getBoneMass(weight, impedance) / weight) * 100.0F;
}
private float getBounded(float value, float lowerBound, float upperBound){
if(value < lowerBound){
return lowerBound;
} else if (value > upperBound){
return upperBound;
}
return value;
}
}

View File

@@ -1,147 +0,0 @@
/* Copyright (C) 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth.lib;
public class SoehnleLib {
private boolean isMale; // male = 1; female = 0
private int age;
private float height;
private int activityLevel;
public SoehnleLib(boolean isMale, int age, float height, int activityLevel) {
this.isMale = isMale;
this.age = age;
this.height = height;
this.activityLevel = activityLevel;
}
public float getFat(final float weight, final float imp50) { // in %
float activityCorrFac = 0.0f;
switch (activityLevel) {
case 4: {
if (isMale) {
activityCorrFac = 2.5f;
}
else {
activityCorrFac = 2.3f;
}
break;
}
case 5: {
if (isMale) {
activityCorrFac = 4.3f;
}
else {
activityCorrFac = 4.1f;
}
break;
}
}
float sexCorrFac;
float activitySexDiv;
if (isMale) {
sexCorrFac = 0.250f;
activitySexDiv = 65.5f;
}
else {
sexCorrFac = 0.214f;
activitySexDiv = 55.1f;
}
return 1.847f * weight * 10000.0f / (height * height) + sexCorrFac * age + 0.062f * imp50 - (activitySexDiv - activityCorrFac);
}
public float computeBodyMassIndex(final float weight) {
return 10000.0f * weight / (height * height);
}
public float getWater(final float weight, final float imp50) { // in %
float activityCorrFac = 0.0f;
switch (activityLevel) {
case 1:
case 2:
case 3: {
if (isMale) {
activityCorrFac = 2.83f;
}
else {
activityCorrFac = 0.0f;
}
break;
}
case 4: {
if (isMale) {
activityCorrFac = 3.93f;
}
else {
activityCorrFac = 0.4f;
}
break;
}
case 5: {
if (isMale) {
activityCorrFac = 5.33f;
}
else {
activityCorrFac = 1.4f;
}
break;
}
}
return (0.3674f * height * height / imp50 + 0.17530f * weight - 0.11f * age + (6.53f + activityCorrFac)) / weight * 100.0f;
}
public float getMuscle(final float weight, final float imp50, final float imp5) { // in %
float activityCorrFac = 0.0f;
switch (activityLevel) {
case 1:
case 2:
case 3: {
if (isMale) {
activityCorrFac = 3.6224f;
}
else {
activityCorrFac = 0.0f;
}
break;
}
case 4: {
if (isMale) {
activityCorrFac = 4.3904f;
}
else {
activityCorrFac = 0.0f;
}
break;
}
case 5: {
if (isMale) {
activityCorrFac = 5.4144f;
}
else {
activityCorrFac = 1.664f;
}
break;
}
}
return ((0.47027f / imp50 - 0.24196f / imp5) * height * height + 0.13796f * weight - 0.1152f * age + (5.12f + activityCorrFac)) / weight * 100.0f;
}
}

View File

@@ -1,79 +0,0 @@
/* Copyright (C) 2018 Maks Verver <maks@verver.ch>
* 2019 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth.lib;
/**
* Class with static helper methods. This is a separate class for testing purposes.
*
* @see com.health.openscale.core.bluetooth.BluetoothTrisaBodyAnalyze
*/
public class TrisaBodyAnalyzeLib {
private boolean isMale;
private int ageYears;
private float heightCm;
public TrisaBodyAnalyzeLib(int sex, int age, float height) {
isMale = sex == 1 ? true : false; // male = 1; female = 0
ageYears = age;
heightCm = height;
}
public float getBMI(float weightKg) {
return weightKg * 1e4f / (heightCm * heightCm);
}
public float getWater(float weightKg, float impedance) {
float bmi = getBMI(weightKg);
float water = isMale
? 87.51f + (-1.162f * bmi - 0.00813f * impedance + 0.07594f * ageYears)
: 77.721f + (-1.148f * bmi - 0.00573f * impedance + 0.06448f * ageYears);
return water;
}
public float getFat(float weightKg, float impedance) {
float bmi = getBMI(weightKg);
float fat = isMale
? bmi * (1.479f + 4.4e-4f * impedance) + 0.1f * ageYears - 21.764f
: bmi * (1.506f + 3.908e-4f * impedance) + 0.1f * ageYears - 12.834f;
return fat;
}
public float getMuscle(float weightKg, float impedance) {
float bmi = getBMI(weightKg);
float muscle = isMale
? 74.627f + (-0.811f * bmi - 0.00565f * impedance - 0.367f * ageYears)
: 57.0f + (-0.694f * bmi - 0.00344f * impedance - 0.255f * ageYears);
return muscle;
}
public float getBone(float weightKg, float impedance) {
float bmi = getBMI(weightKg);
float bone = isMale
? 7.829f + (-0.0855f * bmi - 5.92e-4f * impedance - 0.0389f * ageYears)
: 7.98f + (-0.0973f * bmi - 4.84e-4f * impedance - 0.036f * ageYears);
return bone;
}
}

View File

@@ -1,22 +1,24 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 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.
* 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 <http://www.gnu.org/licenses/>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.libs;
package com.health.openscale.core.bluetooth.lib;
import com.health.openscale.core.utils.Converters.ActivityLevel;
import com.health.openscale.core.data.ActivityLevel;
public class YunmaiLib {
private int sex; // male = 1; female = 0

View File

@@ -0,0 +1,92 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.scales
import android.util.SparseArray
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.util.UUID
class DummyScaleHandler(private val driverName: String) : ScaleDeviceHandler {
override fun getDriverName(): String {
return driverName
}
// Updated signature to match the interface
override fun canHandleDevice(
deviceName: String?,
deviceAddress: String, // Added this parameter
serviceUuids: List<UUID>,
manufacturerData: SparseArray<ByteArray>?
): Boolean {
// Implement your logic to check if this handler can handle the device
// For example, based on the deviceName:
return deviceName?.contains(driverName, ignoreCase = true) == true
}
// --- Implement missing members ---
override val handlerId: String
get() = "dummy_handler_$driverName" // Example implementation
override fun connectAndReceiveEvents(
deviceAddress: String,
currentAppUserAttributes: Map<String, Any>?
): Flow<ScaleDeviceEvent> {
// Dummy implementation: return an empty flow or a flow that emits some dummy events
println("DummyScaleHandler: connectAndReceiveEvents called for $deviceAddress")
return flow {
// emit(DummyScaleEvent("Connected")) // Example
// emit(DummyScaleEvent("Weight: 70.5")) // Example
}
}
override suspend fun disconnect() {
// Dummy implementation
println("DummyScaleHandler: disconnect called")
// Add actual disconnection logic here
}
override suspend fun provideUserSelection(
selectedUser: ScaleUserListItem,
requestContext: Any?
): Boolean {
// Dummy implementation
println("DummyScaleHandler: provideUserSelection called for user ${selectedUser.displayData}")
return true // Or false based on logic
}
override suspend fun provideUserConsent(
consentType: String,
consented: Boolean,
details: Map<String, Any>?
): Boolean {
// Dummy implementation
println("DummyScaleHandler: provideUserConsent called for $consentType, consented: $consented")
return true // Or false
}
override suspend fun provideUserAttributes(
attributes: Map<String, Any>,
scaleUserIdentifier: Any?
): Boolean {
// Dummy implementation
println("DummyScaleHandler: provideUserAttributes called with $attributes")
return true // Or false
}
}

View File

@@ -0,0 +1,467 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.scales
import android.Manifest
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.le.ScanResult
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat
import com.health.openscale.core.bluetooth.BluetoothEvent
import com.health.openscale.core.bluetooth.ScaleCommunicator
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.bluetooth.data.ScaleUser
import com.health.openscale.core.utils.LogManager
import com.welie.blessed.BluetoothCentralManager
import com.welie.blessed.BluetoothCentralManagerCallback
import com.welie.blessed.BluetoothPeripheral
import com.welie.blessed.BluetoothPeripheralCallback
import com.welie.blessed.GattStatus
import com.welie.blessed.HciStatus
import com.welie.blessed.ScanFailure
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.Date
import java.util.UUID
// Beispielhafte UUIDs - DIESE MÜSSEN DURCH DIE KORREKTEN UUIDs IHRER ZIELGERÄTE ERSETZT WERDEN!
object ScaleGattAttributes {
// Beispiel: Body Composition Service
val BODY_COMPOSITION_SERVICE_UUID: UUID = UUID.fromString("0000181B-0000-1000-8000-00805F9B34FB")
// Beispiel: Body Composition Measurement Characteristic
val BODY_COMPOSITION_MEASUREMENT_UUID: UUID = UUID.fromString("00002A9C-0000-1000-8000-00805F9B34FB")
// Beispiel: Weight Scale Service
val WEIGHT_SCALE_SERVICE_UUID: UUID = UUID.fromString("0000181D-0000-1000-8000-00805F9B34FB")
// Beispiel: Weight Measurement Characteristic
val WEIGHT_MEASUREMENT_UUID: UUID = UUID.fromString("00002A9D-0000-1000-8000-00805F9B34FB")
// Beispiel: Current Time Service (oft für Bonding oder User-Setup verwendet)
val CURRENT_TIME_SERVICE_UUID: UUID = UUID.fromString("00001805-0000-1000-8000-00805F9B34FB")
val CURRENT_TIME_CHARACTERISTIC_UUID: UUID = UUID.fromString("00002A2B-0000-1000-8000-00805F9B34FB")
// Client Characteristic Configuration Descriptor
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}
class ModernScaleAdapter(
private val context: Context
) : ScaleCommunicator {
private companion object {
const val TAG = "ModernScaleAdapter"
}
private val adapterScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val mainHandler = Handler(Looper.getMainLooper())
private lateinit var central: BluetoothCentralManager // Initialisiert in init
private var currentPeripheral: BluetoothPeripheral? = null
private var targetAddress: String? = null
private var currentScaleUser: ScaleUser? = null // von der Schnittstelle
private var currentAppUserId: Int? = null
private val _eventsFlow = MutableSharedFlow<BluetoothEvent>(replay = 1, extraBufferCapacity = 5)
override fun getEventsFlow(): SharedFlow<BluetoothEvent> = _eventsFlow.asSharedFlow()
private val _isConnecting = MutableStateFlow(false)
override val isConnecting: StateFlow<Boolean> = _isConnecting.asStateFlow()
private val _isConnected = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val bluetoothCentralManagerCallback: BluetoothCentralManagerCallback =
object : BluetoothCentralManagerCallback() {
override fun onConnectedPeripheral(peripheral: BluetoothPeripheral) {
adapterScope.launch {
LogManager.i(TAG, "Verbunden mit ${peripheral.name} (${peripheral.address})")
currentPeripheral = peripheral
_isConnected.value = true
_isConnecting.value = false
_eventsFlow.tryEmit(BluetoothEvent.Connected(peripheral.name ?: "Unbekannt", peripheral.address))
// Nachdem verbunden, Services entdecken
LogManager.d(TAG, "Starte Service Discovery für ${peripheral.address}")
}
}
override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: HciStatus) {
adapterScope.launch {
LogManager.e(TAG, "Verbindung zu ${peripheral.address} fehlgeschlagen. Status: $status")
_eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(peripheral.address, "Verbindung fehlgeschlagen: $status"))
cleanupAfterDisconnect(peripheral.address)
}
}
override fun onDisconnectedPeripheral(peripheral: BluetoothPeripheral, status: HciStatus) {
adapterScope.launch {
LogManager.i(TAG, "Getrennt von ${peripheral.name} (${peripheral.address}). Status: $status")
val reason = "Getrennt: $status"
// Nur Event senden, wenn es das aktuell verbundene/verbindende Gerät war
if (targetAddress == peripheral.address) {
_eventsFlow.tryEmit(BluetoothEvent.Disconnected(peripheral.address, reason))
cleanupAfterDisconnect(peripheral.address)
} else {
LogManager.w(TAG, "Disconnected Event für nicht-Zielgerät ${peripheral.address} ignoriert (Ziel war $targetAddress).")
}
}
}
override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
// Wir scannen spezifisch nach Adresse, also sollte dies unser Gerät sein.
if (peripheral.address == targetAddress) {
LogManager.i(TAG, "Gerät ${peripheral.name} (${peripheral.address}) gefunden. Stoppe Scan und verbinde.")
central.stopScan()
central.connectPeripheral(peripheral, peripheralCallback)
// _isConnecting bleibt true, bis onConnectedPeripheral oder onConnectionFailed aufgerufen wird
} else {
LogManager.d(TAG, "Scan hat anderes Gerät gefunden: ${peripheral.address}. Ignoriere.")
}
}
override fun onScanFailed(scanFailure: ScanFailure) {
adapterScope.launch {
LogManager.e(TAG, "Scan fehlgeschlagen: $scanFailure")
if (targetAddress != null && _isConnecting.value) {
_eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(targetAddress!!, "Scan fehlgeschlagen: $scanFailure"))
cleanupAfterDisconnect(targetAddress) // targetAddress könnte null sein, wenn connect nie erfolgreich war
}
}
}
}
init {
if (!hasRequiredBluetoothPermissions()) {
LogManager.e(TAG, "Fehlende Bluetooth-Berechtigungen. Adapter kann nicht initialisiert werden.")
// Sende einen Fehler-Event oder werfe eine Exception, um das Problem anzuzeigen.
// _eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed("initialization", "Missing Bluetooth permissions"))
} else {
central = BluetoothCentralManager(
context,
bluetoothCentralManagerCallback,
mainHandler
)
LogManager.d(TAG, "BlessedScaleAdapter instanziiert und BluetoothCentralManager initialisiert.")
}
}
private fun hasRequiredBluetoothPermissions(): Boolean {
val requiredPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
listOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.ACCESS_FINE_LOCATION)
}
return requiredPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
override fun connect(address: String, scaleUser: ScaleUser?, appUserId: Int?) {
adapterScope.launch {
if (!::central.isInitialized) {
LogManager.e(TAG, "BluetoothCentralManager nicht initialisiert, wahrscheinlich aufgrund fehlender Berechtigungen.")
_eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(address, "Bluetooth nicht initialisiert (Berechtigungen?)"))
return@launch
}
if (_isConnecting.value || (_isConnected.value && targetAddress == address)) {
LogManager.d(TAG, "Verbindungsanfrage für $address ignoriert: Bereits verbunden oder Verbindungsaufbau läuft.")
if (_isConnected.value && targetAddress == address) {
val deviceName = currentPeripheral?.name ?: "Unbekanntes Gerät"
_eventsFlow.tryEmit(BluetoothEvent.Connected(deviceName, address))
}
return@launch
}
if ((_isConnected.value || _isConnecting.value) && targetAddress != address) {
LogManager.d(TAG, "Bestehende Verbindung/Versuch zu $targetAddress wird für neue Verbindung zu $address getrennt.")
disconnectLogic()
}
_isConnecting.value = true
_isConnected.value = false
targetAddress = address
currentScaleUser = scaleUser
currentAppUserId = appUserId
LogManager.i(TAG, "Verbindungsversuch zu $address mit Benutzer: ${scaleUser?.id}, AppUserID: $appUserId")
// Stoppe vorherige Scans, falls vorhanden
central.stopScan()
try {
// Versuche, direkt ein Peripheral-Objekt zu bekommen, falls die Adresse bekannt ist.
//Blessed erlaubt auch das Scannen nach Adresse, was oft robuster ist.
//central.getPeripheral(address) ist eine Option, aber scanForPeripheralsWithAddresses ist oft besser.
central.scanForPeripheralsWithAddresses(arrayOf(address))
LogManager.d(TAG, "Scan gestartet für Adresse: $address")
} catch (e: Exception) {
LogManager.e(TAG, "Fehler beim Starten des Scans für $address", e)
_eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(address, "Scan konnte nicht gestartet werden: ${e.message}"))
_isConnecting.value = false
targetAddress = null
}
}
}
private val peripheralCallback: BluetoothPeripheralCallback =
object : BluetoothPeripheralCallback() {
override fun onServicesDiscovered(peripheral: BluetoothPeripheral) {
LogManager.i(TAG, "Services entdeckt für ${peripheral.address}")
// HIER kommt die Logik, um die relevanten Characteristics zu abonnieren (Notifications/Indications)
// Beispiel für Weight Measurement und Body Composition Measurement:
enableNotifications(peripheral, ScaleGattAttributes.WEIGHT_SCALE_SERVICE_UUID, ScaleGattAttributes.WEIGHT_MEASUREMENT_UUID)
enableNotifications(peripheral, ScaleGattAttributes.BODY_COMPOSITION_SERVICE_UUID, ScaleGattAttributes.BODY_COMPOSITION_MEASUREMENT_UUID)
// Optional: Benutzerdaten schreiben oder andere Initialisierungssequenzen
// sendUserDataIfNeeded(peripheral)
}
override fun onCharacteristicUpdate(
peripheral: BluetoothPeripheral,
value: ByteArray,
characteristic: BluetoothGattCharacteristic,
status: GattStatus
) {
if (status == GattStatus.SUCCESS) {
LogManager.d(TAG, "Characteristic ${characteristic.uuid} Update von ${peripheral.address}: ${value.toHexString()}")
// HIER PARSEN SIE DIE `value` (ByteArray) basierend auf der `characteristic.uuid`
// und erstellen ein `com.health.openscale.core.datatypes.ScaleMeasurement`
val measurement = parseMeasurementData(characteristic.uuid, value, peripheral.address)
if (measurement != null) {
adapterScope.launch {
_eventsFlow.tryEmit(BluetoothEvent.MeasurementReceived(measurement, peripheral.address))
}
} else {
LogManager.w(TAG, "Konnte Daten von ${characteristic.uuid} nicht parsen.")
adapterScope.launch {
_eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Unbekannte Daten empfangen von ${characteristic.uuid}", peripheral.address))
}
}
} else {
LogManager.e(TAG, "Characteristic ${characteristic.uuid} Update Fehler: $status von ${peripheral.address}")
}
}
override fun onCharacteristicWrite(
peripheral: BluetoothPeripheral,
value: ByteArray,
characteristic: BluetoothGattCharacteristic,
status: GattStatus
) {
if (status == GattStatus.SUCCESS) {
LogManager.i(TAG, "Erfolgreich auf Characteristic ${characteristic.uuid} geschrieben: ${value.toHexString()}")
// Hier ggf. weitere Logik, falls ein Schreibvorgang Teil einer Sequenz ist
} else {
LogManager.e(TAG, "Fehler beim Schreiben auf Characteristic ${characteristic.uuid}: $status")
adapterScope.launch {
_eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Fehler beim Schreiben (${characteristic.uuid}): $status", peripheral.address))
}
}
}
override fun onNotificationStateUpdate(
peripheral: BluetoothPeripheral,
characteristic: BluetoothGattCharacteristic,
status: GattStatus
) {
if (status == GattStatus.SUCCESS) {
if (peripheral.isNotifying(characteristic)) {
LogManager.i(TAG, "Notifications erfolgreich aktiviert für ${characteristic.uuid} auf ${peripheral.address}")
} else {
LogManager.i(TAG, "Notifications erfolgreich deaktiviert für ${characteristic.uuid} auf ${peripheral.address}")
}
} else {
LogManager.e(TAG, "Fehler beim Aktualisieren des Notification Status für ${characteristic.uuid}: $status")
adapterScope.launch {
_eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Fehler bei Notif. für ${characteristic.uuid}: $status", peripheral.address))
}
}
}
}
private fun enableNotifications(peripheral: BluetoothPeripheral, serviceUUID: UUID, characteristicUUID: UUID) {
val characteristic = peripheral.getCharacteristic(serviceUUID, characteristicUUID)
if (characteristic != null) {
if (peripheral.setNotify(characteristic, true)) {
LogManager.d(TAG, "Versuche Notifications für ${characteristicUUID} zu aktivieren.")
} else {
LogManager.e(TAG, "Fehler beim Versuch, Notifications für ${characteristicUUID} zu aktivieren (setNotify gab false zurück).")
adapterScope.launch {
_eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Konnte Notif. nicht aktivieren für ${characteristic.uuid}", peripheral.address))
}
}
} else {
LogManager.w(TAG, "Characteristic ${characteristicUUID} nicht gefunden im Service ${serviceUUID}.")
}
}
/**
* Parst die Rohdaten einer BLE Characteristic und konvertiert sie in ein ScaleMeasurement Objekt.
* DIES IST EINE SEHR SPEZIFISCHE FUNKTION UND MUSS FÜR JEDE WAAGE/PROTOKOLL IMPLEMENTIERT WERDEN.
*
* @param characteristicUuid Die UUID der Characteristic, von der die Daten stammen.
* @param value Das ByteArray mit den Rohdaten.
* @param deviceAddress Die Adresse des Geräts.
* @return Ein [ScaleMeasurement] Objekt oder null, wenn das Parsen fehlschlägt.
*/
private fun parseMeasurementData(characteristicUuid: UUID, value: ByteArray, deviceAddress: String): ScaleMeasurement? {
// Beispielhafte, sehr vereinfachte Parsing-Logik.
// Die tatsächliche Implementierung hängt STARK vom jeweiligen Waagenprotokoll ab!
val measurement = ScaleMeasurement()
measurement.dateTime = Date() // Zeitstempel der App, Waage könnte eigenen haben
try {
when (characteristicUuid) {
ScaleGattAttributes.WEIGHT_MEASUREMENT_UUID -> {
// Annahme: Bluetooth SIG Weight Scale Characteristic
// Byte 0: Flags
// Byte 1-2: Gewicht (LSB, MSB)
// ... weitere Felder je nach Flags (Timestamp, UserID, BMI, Height)
val flags = value[0].toInt()
val isImperial = (flags and 0x01) != 0 // Bit 0: 0 für kg/m, 1 für lb/in
val hasTimestamp = (flags and 0x02) != 0 // Bit 1
val hasUserID = (flags and 0x04) != 0 // Bit 2
var offset = 1
var weight = ((value[offset++].toInt() and 0xFF) or ((value[offset++].toInt() and 0xFF) shl 8)) / if (isImperial) 100.0f else 200.0f
if (isImperial) {
weight *= 0.453592f // lb in kg umrechnen
}
measurement.weight = weight.takeIf { it.isFinite() } ?: 0.0f
LogManager.d(TAG, "Geparsed Weight: ${measurement.weight} kg")
if (hasTimestamp) {
// Hier Timestamp parsen (7 Bytes)
offset += 7
}
if (hasUserID) {
val userId = value[offset].toInt()
// measurement.scaleUserIndex = userId // oder ähnliches Feld
LogManager.d(TAG, "Geparsed UserID from scale: $userId")
}
return measurement
}
ScaleGattAttributes.BODY_COMPOSITION_MEASUREMENT_UUID -> {
// Annahme: Bluetooth SIG Body Composition Characteristic
// Ähnlich komplex wie Weight, mit vielen optionalen Feldern
// Byte 0-1: Flags
// Byte 2-3: Body Fat Percentage
// ... viele weitere Felder (Timestamp, UserID, Basal Metabolism, Muscle Percentage, etc.)
// Diese Implementierung ist nur ein Platzhalter!
LogManager.d(TAG, "Body Composition Data empfangen, Parsing noch nicht voll implementiert.")
val bodyFatPercentage = ((value[2].toInt() and 0xFF) or ((value[3].toInt() and 0xFF) shl 8)) / 10.0f
measurement.fat = bodyFatPercentage.takeIf { it.isFinite() } ?: 0.0f
// Setze ein beliebiges Gewicht, da Body Comp oft kein Gewicht enthält
// Besser wäre es, Messungen zu kombinieren oder auf eine vorherige Gewichtsmessung zu warten.
if (measurement.weight == 0.0f) measurement.weight = 70.0f // Platzhalter
return measurement
}
else -> {
LogManager.w(TAG, "Keine Parsing-Logik für UUID: $characteristicUuid")
return null
}
}
} catch (e: Exception) {
LogManager.e(TAG, "Fehler beim Parsen der Messdaten für $characteristicUuid: ${value.toHexString()}", e)
return null
}
}
override fun disconnect() {
adapterScope.launch {
LogManager.i(TAG, "Disconnect aufgerufen für $targetAddress")
disconnectLogic()
}
}
private fun disconnectLogic() {
currentPeripheral?.let {
// Notifications deaktivieren, bevor die Verbindung getrennt wird? Optional.
// disableNotifications(it, ScaleGattAttributes.WEIGHT_SCALE_SERVICE_UUID, ScaleGattAttributes.WEIGHT_MEASUREMENT_UUID)
// disableNotifications(it, ScaleGattAttributes.BODY_COMPOSITION_SERVICE_UUID, ScaleGattAttributes.BODY_COMPOSITION_MEASUREMENT_UUID)
central.cancelConnection(it)
}
// Cleanup wird im onDisconnectedPeripheral Callback oder hier als Fallback gemacht,
// falls der Callback aus irgendeinem Grund nicht kommt.
val addr = targetAddress
if (_isConnected.value || _isConnecting.value) {
if (addr != null) {
// Event wird in onDisconnectedPeripheral gesendet, aber als Fallback, falls der Callback nicht kommt
// _eventsFlow.tryEmit(BluetoothEvent.Disconnected(addr, "Manuell getrennt durch disconnectLogic"))
}
}
cleanupAfterDisconnect(addr) // Rufe cleanup auf, um sicherzustellen, dass die Zustände zurückgesetzt werden.
}
private fun cleanupAfterDisconnect(disconnectedAddress: String?) {
// Nur aufräumen, wenn die Adresse mit dem Ziel übereinstimmt oder wenn targetAddress null ist (z.B. nach fehlgeschlagenem Scan)
if (targetAddress == null || targetAddress == disconnectedAddress) {
_isConnected.value = false
_isConnecting.value = false
currentPeripheral = null // Referenz auf Peripheral entfernen
targetAddress = null
currentScaleUser = null
currentAppUserId = null
LogManager.d(TAG, "Blessed Communicator aufgeräumt für Adresse: $disconnectedAddress.")
} else {
LogManager.d(TAG, "Cleanup übersprungen: Disconnected Address ($disconnectedAddress) stimmt nicht mit Target ($targetAddress) überein.")
}
}
override fun requestMeasurement() {
adapterScope.launch {
if (!_isConnected.value || currentPeripheral == null) {
LogManager.w(TAG, "requestMeasurement: Nicht verbunden oder kein Peripheral.")
_eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Nicht verbunden für Messanfrage.", targetAddress ?: "unbekannt"))
return@launch
}
// Die meisten BLE-Waagen senden Daten automatisch nach Aktivierung der Notifications.
// Eine explizite "Anfrage" ist oft nicht nötig oder nicht standardisiert.
// Falls Ihr Gerät eine spezielle Characteristic zum Anfordern von Daten hat,
// könnten Sie hier darauf schreiben.
LogManager.d(TAG, "requestMeasurement aufgerufen. Für BLE typischerweise keine explizite Aktion nötig, Daten kommen über Notifications.")
_eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Messdaten werden erwartet (BLE Notifications).", targetAddress!!))
}
}
fun release() {
LogManager.d(TAG, "BlessedScaleAdapter wird freigegeben.")
disconnectLogic()
// Blessed CentralManager hat keine explizite close() oder release() Methode für sich selbst,
// die Verbindungen werden über cancelConnection() verwaltet.
// Der Handler wird implizit mit dem Context-Lifecycle verwaltet.
adapterScope.cancel()
}
}
// Hilfsfunktion zum Konvertieren eines ByteArrays in einen Hex-String (nützlich für Logging)
fun ByteArray.toHexString(): String = joinToString(separator = " ", prefix = "0x") { String.format("%02X", it) }

View File

@@ -0,0 +1,282 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.scales
import android.util.SparseArray
import com.health.openscale.core.data.MeasurementTypeKey // Required for DeviceValue
import kotlinx.coroutines.flow.Flow
import java.util.UUID
// ---- Data classes for abstracting measurement data provided by the handler ----
/**
* Represents a single value of a specific measurement type from the device.
* @param typeKey The key identifying the type of measurement (e.g., weight, body fat).
* @param value The actual value of the measurement.
*/
data class DeviceValue(
val typeKey: MeasurementTypeKey,
val value: Any
)
/**
* Represents a complete measurement reading from the device.
* @param timestamp The time the measurement was taken, in milliseconds since epoch. Defaults to current time.
* @param values A list of [DeviceValue] objects representing the different components of the measurement.
* @param deviceIdentifier An optional identifier for the device that produced the measurement (e.g., MAC address).
* @param scaleUserIndex Optional: Indicates if this measurement originates from a specific user profile on the scale.
* The value is the index/ID of the user on the scale, if known.
* Can be useful for later assignment or filtering of measurements.
* @param isStableMeasurement Optional: Indicates if this measurement is a "stable" or "final" reading.
* Some scales send intermediate values before the final weight is determined.
* Defaults to true if not otherwise specified.
*/
data class DeviceMeasurement(
val timestamp: Long = System.currentTimeMillis(),
val values: List<DeviceValue>,
val deviceIdentifier: String? = null,
val scaleUserIndex: Int? = null,
val isStableMeasurement: Boolean = true
)
// ---- End of data classes for abstraction ----
/**
* Represents the data provided by a handler for selecting a user on the scale.
* Handler implementations should derive a more specific data class or use this as a base.
*/
interface ScaleUserListItem {
/** Text to be displayed in the UI for this user item. */
val displayData: String
/** The internal ID that the handler requires to identify this user on the scale. */
val scaleInternalId: Any
}
/**
* Example implementation for a generic user list item.
* @param displayData Text to be displayed in the UI.
* @param scaleInternalId The internal ID used by the scale/handler.
*/
data class GenericScaleUserListItem(
override val displayData: String,
override val scaleInternalId: Any
) : ScaleUserListItem
/**
* Defines various events that a [ScaleDeviceHandler] can emit to communicate its state
* and data to the application.
*/
sealed class ScaleDeviceEvent {
/** The handler is starting to search for the device (advertising) or establishing a GATT connection. */
data object PreparingConnection : ScaleDeviceEvent() // Renamed from Connecting for more generality
/** The handler is actively scanning for advertising packets from the device. Only relevant for advertising-based handlers. */
data object ScanningForAdvertisement : ScaleDeviceEvent()
/**
* A connection to the device has been successfully established.
* @param message A descriptive message about the established connection.
*/
data class ConnectionEstablished(val message: String) : ScaleDeviceEvent()
/**
* The device-specific initialization sequence has been successfully completed.
* For GATT-based handlers, this usually means notifications are set up and initial checks are done.
* For advertising-based handlers, this might not be relevant or could be sent directly after the first data reception.
*/
data object InitializationComplete : ScaleDeviceEvent()
/** The connection to the device was lost unexpectedly, or the target device was no longer found (during scanning). */
data object ConnectionLost : ScaleDeviceEvent()
/** The connection to the device was actively disconnected, or the scan was stopped. */
data object Disconnected : ScaleDeviceEvent()
/**
* An error occurred during communication or device handling.
* @param message A descriptive error message.
* @param throwable An optional [Throwable] associated with the error.
* @param errorCode An optional, handler-specific error code.
*/
data class Error(val message: String, val throwable: Throwable? = null, val errorCode: Int? = null) : ScaleDeviceEvent()
/**
* A new measurement, parsed from the device, is available.
* @param measurement The [DeviceMeasurement] containing the data.
*/
data class DeviceMeasurementAvailable(val measurement: DeviceMeasurement) : ScaleDeviceEvent()
/**
* An informational message for the user.
* @param text The message text (can be null if `stringResId` is used).
* @param stringResId An optional string resource ID for localization.
* @param payload Optional structured data associated with the info message.
*/
data class InfoMessage(
val text: String? = null,
val stringResId: Int? = null,
val payload: Any? = null // For optionally more structured info data
) : ScaleDeviceEvent()
/**
* Emitted when the scale requires the user to be selected on the device.
* The UI should present this list to the user. The response is provided via [ScaleDeviceHandler.provideUserSelection].
* @param userList A list of [ScaleUserListItem] objects representing the user profiles available on the scale.
* @param requestContext An optional context object that the handler can send to correlate the response later.
*/
data class UserSelectionRequired(val userList: List<ScaleUserListItem>, val requestContext: Any? = null) : ScaleDeviceEvent()
/**
* Emitted when the scale requires user consent (e.g., in the app) to perform an action
* (e.g., user profile synchronization, data assignment, registration).
* The response is provided via [ScaleDeviceHandler.provideUserConsent].
* @param consentType An identifier for the type of consent requested (handler-specific, e.g., "register_new_user").
* @param messageToUser A user-readable message explaining the reason for the consent.
* @param details Optional additional details or data relevant to the consent
* (e.g., proposed scaleUserIndex if registering a new user: `mapOf("scaleUserIndexProposal" -> 3)`).
*/
data class UserConsentRequired(
val consentType: String, // e.g., "register_new_user", "confirm_user_match"
val messageToUser: String,
val details: Map<String, Any>? = null // e.g., mapOf("appUserId" -> 1, "scaleUserIndexProposal" -> 3)
) : ScaleDeviceEvent()
/**
* Emitted when the handler needs specific attributes of the app user to interact with the scale
* (e.g., to create or update a user on the scale).
* The response is provided via [ScaleDeviceHandler.provideUserAttributes].
* @param requestedAttributes A list of keys indicating which attributes are needed (e.g., "height", "birthdate", "gender").
* Example: `listOf("height_cm", "birth_date_epoch_ms", "gender_string")`
* @param scaleUserIdentifier The identifier of the scale user for whom the attributes are needed (if applicable).
*/
data class UserAttributesRequired(
val requestedAttributes: List<String>, // e.g., listOf("height_cm", "birth_date_epoch_ms", "gender_string")
val scaleUserIdentifier: Any? = null
) : ScaleDeviceEvent()
}
/**
* Interface for a device-specific handler that manages communication with a Bluetooth scale.
* It abstracts the low-level Bluetooth operations and provides a standardized way to interact
* with different types of scales.
*/
interface ScaleDeviceHandler {
/**
* @return The display name of this scale driver/handler (e.g., "Mi Scale v2 Handler").
*/
fun getDriverName(): String
/**
* A unique ID for this handler type. Can be, for example, the class name.
* Important for persistence and later retrieval of the correct handler.
*/
val handlerId: String
/**
* Indicates whether this handler primarily communicates via advertising data (`true`)
* or GATT connections (`false`).
* This can help the Bluetooth management layer optimize scans and connection attempts.
* Defaults to `false` (GATT-based).
*/
val communicatesViaAdvertising: Boolean
get() = false // Default is GATT-based
/**
* Checks if this handler can manage communication with the specified device.
*
* @param deviceName The advertised name of the Bluetooth device.
* @param deviceAddress The MAC address of the device.
* @param serviceUuids A list of advertised service UUIDs.
* @param manufacturerData Manufacturer-specific data from the advertisement.
* @return `true` if this handler can handle the device, `false` otherwise.
*/
fun canHandleDevice(
deviceName: String?,
deviceAddress: String,
serviceUuids: List<UUID>,
manufacturerData: SparseArray<ByteArray>?
): Boolean
/**
* Prepares communication, potentially scans for advertising data or establishes a GATT connection,
* performs the necessary initialization sequence, and starts receiving data.
*
* @param deviceAddress The MAC address of the Bluetooth device.
* @param currentAppUserAttributes Optional attributes of the current app user that the handler might
* need for initialization or user synchronization.
* The handler should explicitly request what it needs via [ScaleDeviceEvent.UserAttributesRequired]
* if these are insufficient or missing.
* @return A [Flow] of [ScaleDeviceEvent]s.
*/
fun connectAndReceiveEvents(
deviceAddress: String,
currentAppUserAttributes: Map<String, Any>? = null
): Flow<ScaleDeviceEvent>
/**
* Disconnects from the currently connected device and cleans up resources.
*/
suspend fun disconnect()
/**
* Called by the application to provide the user's selection in response to a
* [ScaleDeviceEvent.UserSelectionRequired] event.
*
* @param selectedUser The [ScaleUserListItem] object selected by the user.
* @param requestContext The context that was sent with the original `UserSelectionRequired` event.
* @return `true` if the selection was processed successfully, `false` otherwise.
*/
suspend fun provideUserSelection(selectedUser: ScaleUserListItem, requestContext: Any? = null): Boolean
/**
* Called by the application to provide the user's consent (or denial) in response to a
* [ScaleDeviceEvent.UserConsentRequired] event.
*
* @param consentType The type of consent, as specified in the original event.
* @param consented `true` if the user consented, `false` otherwise.
* @param details Additional details that were sent with the original `UserConsentRequired` event.
* @return `true` if the consent was processed successfully, `false` otherwise.
*/
suspend fun provideUserConsent(consentType: String, consented: Boolean, details: Map<String, Any>? = null): Boolean
/**
* Called by the application to provide the requested user attributes in response to a
* [ScaleDeviceEvent.UserAttributesRequired] event.
*
* @param attributes A map of the provided attributes (keys as requested in the event).
* @param scaleUserIdentifier The identifier of the scale user, as requested in the event.
* @return `true` if the attributes were processed successfully, `false` otherwise.
*/
suspend fun provideUserAttributes(attributes: Map<String, Any>, scaleUserIdentifier: Any? = null): Boolean
/**
* Sends a device-specific command to the scale.
* This is an escape-hatch function for handler-specific actions not covered by
* the standard events/methods.
* Its use should be minimized to maintain abstraction.
*
* @param commandId An identifier for the command.
* @param commandData Optional data for the command.
* @return A result object indicating success/failure and optional response data.
*/
// suspend fun sendRawCommand(commandId: String, commandData: Any? = null): CommandResult // Commented out for now as it increases complexity
}
// data class CommandResult(val success: Boolean, val responseData: Any? = null) // For sendRawCommand

View File

@@ -1,20 +1,21 @@
/* Copyright (C) 2014 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.scalesJava;
import static android.bluetooth.BluetoothGatt.GATT_SUCCESS;
import static android.content.Context.LOCATION_SERVICE;
@@ -30,8 +31,11 @@ import android.os.Looper;
import androidx.core.content.ContextCompat;
import com.health.openscale.R;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.bluetooth.data.ScaleMeasurement;
import com.health.openscale.core.bluetooth.data.ScaleUser;
import com.health.openscale.core.utils.LogManager;
import com.welie.blessed.BluetoothCentralManager;
import com.welie.blessed.BluetoothCentralManagerCallback;
import com.welie.blessed.BluetoothPeripheral;
@@ -43,8 +47,6 @@ import com.welie.blessed.WriteType;
import java.util.UUID;
import timber.log.Timber;
public abstract class BluetoothCommunication {
public enum BT_STATUS {
RETRIEVE_SCALE_DATA,
@@ -71,6 +73,9 @@ public abstract class BluetoothCommunication {
private BluetoothCentralManager central;
private BluetoothPeripheral btPeripheral;
private ScaleUser selectedScaleUser;
private int selectedScaleUserId;
public BluetoothCommunication(Context context)
{
this.context = context;
@@ -78,6 +83,27 @@ public abstract class BluetoothCommunication {
this.stepNr = 0;
this.stopped = false;
this.central = new BluetoothCentralManager(context, bluetoothCentralCallback, new Handler(Looper.getMainLooper()));
this.selectedScaleUser = new ScaleUser();
this.selectedScaleUserId = 0;
}
public void setSelectedScaleUser(ScaleUser user) {
selectedScaleUser = user;
}
public ScaleUser getSelectedScaleUser() {
return selectedScaleUser;
}
public void setSelectedScaleUserId(int userId) {
selectedScaleUserId = userId;
}
public int getSelectedScaleUserId() {
return selectedScaleUserId;
}
public BluetoothPeripheral getBtPeripheral() {
return btPeripheral;
}
protected boolean needReConnect() {
@@ -197,7 +223,7 @@ public abstract class BluetoothCommunication {
* Stopped current state machine
*/
protected synchronized void stopMachineState() {
Timber.d("Stop machine state");
LogManager.d("BluetoothCommunication","Stop machine state");
stopped = true;
}
@@ -205,7 +231,7 @@ public abstract class BluetoothCommunication {
* resume current state machine
*/
protected synchronized void resumeMachineState() {
Timber.d("Resume machine state");
LogManager.d("BluetoothCommunication","Resume machine state");
stopped = false;
nextMachineStep();
}
@@ -216,13 +242,13 @@ public abstract class BluetoothCommunication {
*/
protected synchronized boolean resumeMachineState( int curStep ) {
if( curStep == stepNr-1 ) {
Timber.d("curStep " + curStep + " matches stepNr " + stepNr + "-1, resume state machine.");
LogManager.d("BluetoothCommunication","curStep " + curStep + " matches stepNr " + stepNr + "-1, resume state machine.");
stopped = false;
nextMachineStep();
return true;
}
else {
Timber.d("curStep " + curStep + " does not match stepNr " + stepNr + "-1, not resuming state machine.");
LogManager.d("BluetoothCommunication","curStep " + curStep + " does not match stepNr " + stepNr + "-1, not resuming state machine.");
return false;
}
}
@@ -232,7 +258,7 @@ public abstract class BluetoothCommunication {
* @param nr the step number which the state machine should jump to.
*/
protected synchronized void jumpNextToStepNr(int nr) {
Timber.d("Jump next to step nr " + nr);
LogManager.d("BluetoothCommunication","Jump next to step nr " + nr);
stepNr = nr;
}
@@ -250,12 +276,12 @@ public abstract class BluetoothCommunication {
*/
protected synchronized boolean jumpNextToStepNr( int curStepNr, int newStepNr ) {
if( curStepNr == stepNr-1 ) {
Timber.d("curStepNr " + curStepNr + " matches stepNr " + stepNr + "-1, jumping next to step nr " + newStepNr);
LogManager.d("BluetoothCommunication","curStepNr " + curStepNr + " matches stepNr " + stepNr + "-1, jumping next to step nr " + newStepNr);
stepNr = newStepNr;
return true;
}
else {
Timber.d("curStepNr " + curStepNr + " does not match stepNr " + stepNr + "-1, keeping next at step nr " + stepNr);
LogManager.d("BluetoothCommunication","curStepNr " + curStepNr + " does not match stepNr " + stepNr + "-1, keeping next at step nr " + stepNr);
return false;
}
}
@@ -267,7 +293,7 @@ public abstract class BluetoothCommunication {
*/
protected synchronized void jumpBackOneStep() {
stepNr--;
Timber.d("Jumped back one step to " + stepNr);
LogManager.d("BluetoothCommunication","Jumped back one step to " + stepNr);
}
/**
@@ -299,7 +325,7 @@ public abstract class BluetoothCommunication {
* @param noResponse true if no response is required
*/
protected void writeBytes(UUID service, UUID characteristic, byte[] bytes, boolean noResponse) {
Timber.d("Invoke write bytes [" + byteInHex(bytes) + "] on " + BluetoothGattUuid.prettyPrint(characteristic));
LogManager.d("BluetoothCommunication","Invoke write bytes [" + byteInHex(bytes) + "] on " + BluetoothGattUuid.prettyPrint(characteristic));
btPeripheral.writeCharacteristic(btPeripheral.getCharacteristic(service, characteristic), bytes,
noResponse ? WriteType.WITHOUT_RESPONSE : WriteType.WITH_RESPONSE);
}
@@ -311,7 +337,7 @@ public abstract class BluetoothCommunication {
*@param characteristic the Bluetooth UUID characteristic
*/
void readBytes(UUID service, UUID characteristic) {
Timber.d("Invoke read bytes on " + BluetoothGattUuid.prettyPrint(characteristic));
LogManager.d("BluetoothCommunication","Invoke read bytes on " + BluetoothGattUuid.prettyPrint(characteristic));
btPeripheral.readCharacteristic(btPeripheral.getCharacteristic(service, characteristic));
}
@@ -322,7 +348,7 @@ public abstract class BluetoothCommunication {
* @param characteristic the Bluetooth UUID characteristic
*/
protected void setIndicationOn(UUID service, UUID characteristic) {
Timber.d("Invoke set indication on " + BluetoothGattUuid.prettyPrint(characteristic));
LogManager.d("BluetoothCommunication","Invoke set indication on " + BluetoothGattUuid.prettyPrint(characteristic));
if(btPeripheral.getService(service) != null) {
stopMachineState();
BluetoothGattCharacteristic currentTimeCharacteristic = btPeripheral.getCharacteristic(service, characteristic);
@@ -337,7 +363,7 @@ public abstract class BluetoothCommunication {
* @return true if the operation was enqueued, false if the characteristic doesn't support notification or indications or
*/
protected boolean setNotificationOn(UUID service, UUID characteristic) {
Timber.d("Invoke set notification on " + BluetoothGattUuid.prettyPrint(characteristic));
LogManager.d("BluetoothCommunication","Invoke set notification on " + BluetoothGattUuid.prettyPrint(characteristic));
if(btPeripheral.getService(service) != null) {
BluetoothGattCharacteristic currentTimeCharacteristic = btPeripheral.getCharacteristic(service, characteristic);
if (currentTimeCharacteristic != null) {
@@ -361,12 +387,12 @@ public abstract class BluetoothCommunication {
* Disconnect from a Bluetooth device
*/
public void disconnect() {
Timber.d("Bluetooth disconnect");
LogManager.d("BluetoothCommunication","Bluetooth disconnect");
setBluetoothStatus(BT_STATUS.CONNECTION_DISCONNECT);
try {
central.stopScan();
} catch (Exception ex) {
Timber.e("Error on Bluetooth disconnecting " + ex.getMessage());
LogManager.e("BluetoothCommunication", "Error on Bluetooth disconnecting " + ex.getMessage(), ex);
}
if (btPeripheral != null) {
@@ -377,11 +403,11 @@ public abstract class BluetoothCommunication {
}
public void selectScaleUserIndexForAppUserId(int appUserId, int scaleUserIndex, Handler uiHandler) {
Timber.d("Set scale user index for app user id: Not implemented!");
LogManager.d("BluetoothCommunication","Set scale user index for app user id: Not implemented!");
}
public void setScaleUserConsent(int appUserId, int scaleUserConsent, Handler uiHandler) {
Timber.d("Set scale user consent for app user id: Not implemented!");
LogManager.d("BluetoothCommunication","Set scale user consent for app user id: Not implemented!");
}
// +++
@@ -403,7 +429,7 @@ public abstract class BluetoothCommunication {
*/
protected String byteInHex(byte[] data) {
if (data == null) {
Timber.e("Data is null");
LogManager.e("BluetoothCommunication", "Data is null", null);
return "";
}
@@ -459,7 +485,7 @@ public abstract class BluetoothCommunication {
private final BluetoothPeripheralCallback peripheralCallback = new BluetoothPeripheralCallback() {
@Override
public void onServicesDiscovered(BluetoothPeripheral peripheral) {
Timber.d("Successful Bluetooth services discovered");
LogManager.d("BluetoothCommunication","Successful Bluetooth services discovered");
onBluetoothDiscovery(peripheral);
resumeMachineState();
}
@@ -468,22 +494,22 @@ public abstract class BluetoothCommunication {
public void onNotificationStateUpdate(BluetoothPeripheral peripheral, BluetoothGattCharacteristic characteristic, GattStatus status) {
if( status.value == GATT_SUCCESS) {
if(peripheral.isNotifying(characteristic)) {
Timber.d(String.format("SUCCESS: Notify set for %s", characteristic.getUuid()));
LogManager.d("BluetoothCommunication",String.format("SUCCESS: Notify set for %s", characteristic.getUuid()));
resumeMachineState();
}
} else {
Timber.e(String.format("ERROR: Changing notification state failed for %s", characteristic.getUuid()));
LogManager.e("BluetoothCommunication",String.format("ERROR: Changing notification state failed for %s", characteristic.getUuid()), null);
}
}
@Override
public void onCharacteristicWrite(BluetoothPeripheral peripheral, byte[] value, BluetoothGattCharacteristic characteristic, GattStatus status) {
if( status.value == GATT_SUCCESS) {
Timber.d(String.format("SUCCESS: Writing <%s> to <%s>", byteInHex(value), characteristic.getUuid().toString()));
LogManager.d("BluetoothCommunication",String.format("SUCCESS: Writing <%s> to <%s>", byteInHex(value), characteristic.getUuid().toString()));
nextMachineStep();
} else {
Timber.e(String.format("ERROR: Failed writing <%s> to <%s>", byteInHex(value), characteristic.getUuid().toString()));
LogManager.e("BluetoothCommunication",String.format("ERROR: Failed writing <%s> to <%s>", byteInHex(value), characteristic.getUuid().toString()), null);
}
}
@@ -499,7 +525,7 @@ public abstract class BluetoothCommunication {
@Override
public void onConnectedPeripheral(BluetoothPeripheral peripheral) {
Timber.d(String.format("connected to '%s'", peripheral.getName()));
LogManager.d("BluetoothCommunication",String.format("connected to '%s'", peripheral.getName()));
setBluetoothStatus(BT_STATUS.CONNECTION_ESTABLISHED);
btPeripheral = peripheral;
nextMachineStep();
@@ -508,7 +534,7 @@ public abstract class BluetoothCommunication {
@Override
public void onConnectionFailed(BluetoothPeripheral peripheral, HciStatus status) {
Timber.e(String.format("connection '%s' failed with status %d", peripheral.getName(), status.value));
LogManager.e("BluetoothCommunication", String.format("connection '%s' failed with status %d", peripheral.getName(), status.value),null);
setBluetoothStatus(BT_STATUS.CONNECTION_LOST);
if (status.value == 8) {
@@ -518,12 +544,12 @@ public abstract class BluetoothCommunication {
@Override
public void onDisconnectedPeripheral(final BluetoothPeripheral peripheral, HciStatus status) {
Timber.d(String.format("disconnected '%s' with status %d", peripheral.getName(), status.value));
LogManager.d("BluetoothCommunication",String.format("disconnected '%s' with status %d", peripheral.getName(), status.value));
}
@Override
public void onDiscoveredPeripheral(BluetoothPeripheral peripheral, ScanResult scanResult) {
Timber.d(String.format("Found peripheral '%s'", peripheral.getName()));
LogManager.d("BluetoothCommunication",String.format("Found peripheral '%s'", peripheral.getName()));
central.stopScan();
connectToDevice(peripheral);
}
@@ -549,12 +575,12 @@ public abstract class BluetoothCommunication {
(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) ||
(locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)))
) {
Timber.d("Do LE scan before connecting to device");
LogManager.d("BluetoothCommunication","Do LE scan before connecting to device");
central.scanForPeripheralsWithAddresses(new String[]{macAddress});
stopMachineState();
}
else {
Timber.d("No location permission, connecting without LE scan");
LogManager.d("BluetoothCommunication","No location permission, connecting without LE scan");
BluetoothPeripheral peripheral = central.getPeripheral(macAddress);
connectToDevice(peripheral);
}
@@ -566,7 +592,7 @@ public abstract class BluetoothCommunication {
handler.postDelayed(new Runnable() {
@Override
public void run() {
Timber.d("Try to connect to BLE device " + peripheral.getAddress());
LogManager.d("BluetoothCommunication","Try to connect to BLE device " + peripheral.getAddress());
stepNr = 0;
@@ -598,7 +624,7 @@ public abstract class BluetoothCommunication {
disconnectHandler.postDelayed(new Runnable() {
@Override
public void run() {
Timber.d("Timeout Bluetooth disconnect");
LogManager.d("BluetoothCommunication","Timeout Bluetooth disconnect");
disconnect();
}
}, 60000); // 60s timeout
@@ -606,12 +632,12 @@ public abstract class BluetoothCommunication {
private synchronized void nextMachineStep() {
if (!stopped) {
Timber.d("Step Nr " + stepNr);
LogManager.d("BluetoothCommunication","Step Nr " + stepNr);
if (onNextStep(stepNr)) {
stepNr++;
nextMachineStep();
} else {
Timber.d("Invoke delayed disconnect in 60s");
LogManager.d("BluetoothCommunication","Invoke delayed disconnect in 60s");
disconnectWithDelay();
}
}

View File

@@ -1,20 +1,21 @@
/* Copyright (C) 2018 Erik Johansson <erik@ejohansson.se>
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 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.
* 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 <http://www.gnu.org/licenses/>
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth;
package com.health.openscale.core.bluetooth.scalesJava;
import java.lang.reflect.Field;
import java.util.Locale;

View File

@@ -1,38 +1,40 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bluetooth;
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.scalesJava;
import android.content.Context;
import android.content.SharedPreferences;
import android.preference.PreferenceManager;
import com.health.openscale.R;
import com.health.openscale.core.OpenScale;
import com.health.openscale.core.bluetooth.lib.YunmaiLib;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.bluetooth.data.ScaleMeasurement;
import com.health.openscale.core.bluetooth.data.ScaleUser;
import com.health.openscale.core.bluetooth.libs.YunmaiLib;
import com.health.openscale.core.data.GenderType;
import com.health.openscale.core.data.WeightUnit;
import com.health.openscale.core.utils.LogManager;
import com.health.openscale.core.utils.Converters;
import java.util.Date;
import java.util.Locale;
import java.util.Random;
import java.util.UUID;
import timber.log.Timber;
public class BluetoothYunmaiSE_Mini extends BluetoothCommunication {
private final UUID WEIGHT_MEASUREMENT_SERVICE = BluetoothGattUuid.fromShortCode(0xffe0);
private final UUID WEIGHT_MEASUREMENT_CHARACTERISTIC = BluetoothGattUuid.fromShortCode(0xffe4);
@@ -57,9 +59,9 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication {
case 0:
byte[] userId = Converters.toInt16Be(getUniqueNumber());
final ScaleUser selectedUser = OpenScale.getInstance().getSelectedScaleUser();
final ScaleUser selectedUser = getSelectedScaleUser();
byte sex = selectedUser.getGender().isMale() ? (byte)0x01 : (byte)0x02;
byte display_unit = selectedUser.getScaleUnit() == Converters.WeightUnit.KG ? (byte) 0x01 : (byte) 0x02;
byte display_unit = selectedUser.getScaleUnit() == WeightUnit.KG ? (byte) 0x01 : (byte) 0x02;
byte body_type = (byte) YunmaiLib.toYunmaiActivityLevel(selectedUser.getActivityLevel());
byte[] user_add_or_query = new byte[]{
@@ -116,7 +118,7 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication {
}
private void parseBytes(byte[] weightBytes) {
final ScaleUser scaleUser = OpenScale.getInstance().getSelectedScaleUser();
final ScaleUser scaleUser = getSelectedScaleUser();
ScaleMeasurement scaleBtData = new ScaleMeasurement();
@@ -129,7 +131,7 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication {
if (isMini) {
int sex;
if (scaleUser.getGender() == Converters.Gender.MALE) {
if (scaleUser.getGender() == GenderType.MALE) {
sex = 1;
} else {
sex = 0;
@@ -139,10 +141,10 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication {
float bodyFat;
int resistance = Converters.fromUnsignedInt16Be(weightBytes, 15);
if (weightBytes[1] >= (byte)0x1E) {
Timber.d("Extract the fat value from received bytes");
LogManager.d("BluetoothYunmaiSE_Mini","Extract the fat value from received bytes");
bodyFat = Converters.fromUnsignedInt16Be(weightBytes, 17) / 100.0f;
} else {
Timber.d("Calculate the fat value using the Yunmai lib");
LogManager.d("BluetoothYunmaiSE_Mini","Calculate the fat value using the Yunmai lib");
bodyFat = yunmaiLib.getFat(scaleUser.getAge(), weight, resistance);
}
@@ -154,13 +156,14 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication {
scaleBtData.setLbm(yunmaiLib.getLeanBodyMass(weight, bodyFat));
scaleBtData.setVisceralFat(yunmaiLib.getVisceralFat(bodyFat, scaleUser.getAge()));
} else {
Timber.e("body fat is zero");
LogManager.e("BluetoothYunmaiSE_Mini","body fat is zero", null);
}
Timber.d("received bytes [%s]", byteInHex(weightBytes));
Timber.d("received decrypted bytes [weight: %.2f, fat: %.2f, resistance: %d]", weight, bodyFat, resistance);
Timber.d("user [%s]", scaleUser);
Timber.d("scale measurement [%s]", scaleBtData);
LogManager.d("BluetoothYunmaiSE_Mini", "received bytes [" + byteInHex(weightBytes) + "]");
String decryptedBytesLog = String.format(Locale.US, "received decrypted bytes [weight: %.2f, fat: %.2f, resistance: %d]", weight, bodyFat, resistance);
LogManager.d("BluetoothYunmaiSE_Mini", decryptedBytesLog);
LogManager.d("BluetoothYunmaiSE_Mini", "user [" + scaleUser + "]");
LogManager.d("BluetoothYunmaiSE_Mini", "scale measurement [" + scaleBtData + "]");
}
addScaleMeasurement(scaleBtData);
@@ -180,7 +183,7 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication {
prefs.edit().putInt("uniqueNumber", uniqueNumber).apply();
}
int userId = OpenScale.getInstance().getSelectedScaleUserId();
int userId = getSelectedScaleUserId();
return uniqueNumber + userId;
}

View File

@@ -0,0 +1,379 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.bluetooth.scalesJava
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.Message
import com.health.openscale.R
import com.health.openscale.core.bluetooth.BluetoothEvent
import com.health.openscale.core.bluetooth.ScaleCommunicator
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.bluetooth.data.ScaleUser
import com.health.openscale.core.database.DatabaseRepository
import com.health.openscale.core.utils.LogManager
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
/**
* Adapter that adapts a legacy `BluetoothCommunication` (Java driver) instance
* to the `ScaleCommunicator` interface (without getScaleInfo).
* The identity of the scale is determined by the passed `bluetoothDriverInstance`.
*
* @property applicationContext The application context, used for accessing string resources.
* @property bluetoothDriverInstance The specific legacy Java Bluetooth driver instance.
* @property databaseRepository Repository for database operations, currently unused in this adapter but kept for potential future use.
*/
class LegacyScaleAdapter(
private val applicationContext: Context,
private val bluetoothDriverInstance: BluetoothCommunication, // The specific driver instance
private val databaseRepository: DatabaseRepository // Maintained for potential future use, though not directly used in current logic
) : ScaleCommunicator {
companion object {
private const val TAG = "LegacyScaleAdapter"
}
private val adapterScope =
CoroutineScope(Dispatchers.IO + SupervisorJob() + CoroutineName("LegacyScaleAdapterScope"))
private val _eventsFlow =
MutableSharedFlow<BluetoothEvent>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/**
* A [SharedFlow] that emits [BluetoothEvent]s from the scale driver.
*/
val events: SharedFlow<BluetoothEvent> = _eventsFlow.asSharedFlow()
private val _isConnected = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _isConnecting = MutableStateFlow(false)
override val isConnecting: StateFlow<Boolean> = _isConnecting.asStateFlow()
private var currentTargetAddress: String? = null
private var currentInternalUser: ScaleUser? = null
private val driverEventHandler = DriverEventHandler(this)
init {
LogManager.i(TAG, "CONSTRUCTOR with driver instance: ${bluetoothDriverInstance.javaClass.name} (${bluetoothDriverInstance.driverName()})")
bluetoothDriverInstance.registerCallbackHandler(driverEventHandler)
}
/**
* Handles messages received from the legacy [BluetoothCommunication] driver.
* It translates these messages into [BluetoothEvent]s and updates the adapter's state.
*/
private inner class DriverEventHandler(adapter: LegacyScaleAdapter) : Handler(Looper.getMainLooper()) {
private val adapterRef: WeakReference<LegacyScaleAdapter> = WeakReference(adapter)
override fun handleMessage(msg: Message) {
val adapter = adapterRef.get() ?: return // Adapter instance might have been garbage collected
val status = BluetoothCommunication.BT_STATUS.values().getOrNull(msg.what)
val eventData = msg.obj
val arg1 = msg.arg1
val arg2 = msg.arg2
LogManager.d(TAG, "DriverEventHandler: Message received - what: ${msg.what} ($status), obj: $eventData, arg1: $arg1, arg2: $arg2")
val deviceIdentifier = adapter.currentTargetAddress ?: adapter.bluetoothDriverInstance.driverName()
when (status) {
BluetoothCommunication.BT_STATUS.RETRIEVE_SCALE_DATA -> {
if (eventData is ScaleMeasurement) {
LogManager.i(TAG, "RETRIEVE_SCALE_DATA: Weight: ${eventData.weight}")
adapter._eventsFlow.tryEmit(BluetoothEvent.MeasurementReceived(eventData, deviceIdentifier))
} else {
LogManager.w(TAG, "RETRIEVE_SCALE_DATA: Unexpected data type: $eventData")
// Optionally, emit an error or generic message event
adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(
applicationContext.getString(R.string.legacy_adapter_event_unexpected_data, eventData?.javaClass?.simpleName ?: "null"),
deviceIdentifier
))
}
}
BluetoothCommunication.BT_STATUS.INIT_PROCESS -> {
adapter._isConnecting.value = true
val infoText = eventData as? String ?: applicationContext.getString(R.string.legacy_adapter_event_initializing)
LogManager.d(TAG, "INIT_PROCESS: $infoText")
adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(infoText, deviceIdentifier))
}
BluetoothCommunication.BT_STATUS.CONNECTION_ESTABLISHED -> {
LogManager.i(TAG, "CONNECTION_ESTABLISHED to $deviceIdentifier (Target: ${adapter.currentTargetAddress})")
adapter._isConnected.value = true
adapter._isConnecting.value = false
// adapter.currentTargetAddress should not be null here if connection is established
adapter._eventsFlow.tryEmit(BluetoothEvent.Connected(deviceIdentifier, adapter.currentTargetAddress!!))
}
BluetoothCommunication.BT_STATUS.CONNECTION_DISCONNECT, BluetoothCommunication.BT_STATUS.CONNECTION_LOST -> {
val reasonKey = if (status == BluetoothCommunication.BT_STATUS.CONNECTION_LOST) R.string.legacy_adapter_event_connection_lost else R.string.legacy_adapter_event_connection_disconnected
val reasonString = applicationContext.getString(reasonKey)
val additionalInfo = eventData as? String ?: ""
val fullMessage = if (additionalInfo.isNotEmpty()) "$reasonString - $additionalInfo" else reasonString
LogManager.i(TAG, "$status for $deviceIdentifier: $fullMessage")
adapter._isConnected.value = false
adapter._isConnecting.value = false
adapter._eventsFlow.tryEmit(BluetoothEvent.Disconnected(deviceIdentifier, fullMessage))
adapter.cleanupAfterDisconnect()
}
BluetoothCommunication.BT_STATUS.NO_DEVICE_FOUND -> {
val additionalInfo = eventData as? String ?: ""
val message = applicationContext.getString(R.string.legacy_adapter_event_device_not_found, additionalInfo).trim()
LogManager.w(TAG, "NO_DEVICE_FOUND for $deviceIdentifier. Info: $additionalInfo")
adapter._isConnected.value = false
adapter._isConnecting.value = false
adapter._eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(deviceIdentifier, message))
adapter.cleanupAfterDisconnect()
}
BluetoothCommunication.BT_STATUS.UNEXPECTED_ERROR -> {
val additionalInfo = eventData as? String ?: ""
val message = applicationContext.getString(R.string.legacy_adapter_event_unexpected_error, additionalInfo).trim()
LogManager.e(TAG, "UNEXPECTED_ERROR for $deviceIdentifier. Info: $additionalInfo")
adapter._isConnected.value = false
adapter._isConnecting.value = false
adapter._eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(deviceIdentifier, message))
adapter.cleanupAfterDisconnect()
}
BluetoothCommunication.BT_STATUS.SCALE_MESSAGE -> {
try {
val messageResId = arg1
val messageArg = eventData
val messageText = if (messageArg != null) {
adapter.applicationContext.getString(messageResId, messageArg.toString())
} else {
adapter.applicationContext.getString(messageResId)
}
LogManager.d(TAG, "SCALE_MESSAGE: $messageText (ID: $messageResId)")
adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(messageText, deviceIdentifier))
} catch (e: Exception) {
LogManager.e(TAG, "Error retrieving SCALE_MESSAGE string resource (ID $arg1)", e)
val fallbackMessage = applicationContext.getString(R.string.legacy_adapter_event_scale_message_fallback, arg1, eventData?.toString() ?: "N/A")
adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(fallbackMessage, deviceIdentifier))
}
}
BluetoothCommunication.BT_STATUS.CHOOSE_SCALE_USER -> {
LogManager.d(TAG, "CHOOSE_SCALE_USER for $deviceIdentifier: Data: $eventData")
var userListDescription = applicationContext.getString(R.string.legacy_adapter_event_user_selection_required)
if (eventData is List<*>) {
val stringList = eventData.mapNotNull { item ->
if (item is ScaleUser) {
applicationContext.getString(R.string.legacy_adapter_event_user_details, item.id, item.age, item.bodyHeight)
} else {
item.toString()
}
}
if (stringList.isNotEmpty()) {
userListDescription = stringList.joinToString(separator = "\n")
}
} else if (eventData != null) {
userListDescription = eventData.toString()
}
adapter._eventsFlow.tryEmit(BluetoothEvent.UserSelectionRequired(userListDescription, deviceIdentifier, eventData))
}
BluetoothCommunication.BT_STATUS.ENTER_SCALE_USER_CONSENT -> {
val appScaleUserId = arg1
val scaleUserIndex = arg2
LogManager.d(TAG, "ENTER_SCALE_USER_CONSENT for $deviceIdentifier: AppUserID: $appScaleUserId, ScaleUserIndex: $scaleUserIndex. Data: $eventData")
val message = applicationContext.getString(R.string.legacy_adapter_event_user_consent_required, appScaleUserId, scaleUserIndex)
adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(message, deviceIdentifier))
}
else -> {
LogManager.w(TAG, "Unknown BT_STATUS ($status) or message (what=${msg.what}) from driver ${adapter.bluetoothDriverInstance.driverName()} received.")
adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(
applicationContext.getString(R.string.legacy_adapter_event_unknown_status, status?.name ?: msg.what.toString()),
deviceIdentifier
))
}
}
}
}
override fun connect(deviceAddress: String, uiScaleUser: ScaleUser?, appUserId: Int?) {
adapterScope.launch {
val currentDeviceName = currentTargetAddress ?: bluetoothDriverInstance.driverName()
if (_isConnected.value || _isConnecting.value) {
LogManager.w(TAG, "connect: Already connected/connecting to $currentDeviceName. Ignoring request for $deviceAddress.")
if (currentTargetAddress != deviceAddress && currentTargetAddress != null) {
val message = applicationContext.getString(R.string.legacy_adapter_connect_busy, currentTargetAddress)
_eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(deviceAddress, message))
} else if (currentTargetAddress == null) {
// This case implies isConnecting is true but currentTargetAddress is null,
// which might indicate a race condition or an incomplete previous cleanup.
// Allow proceeding with the new connection attempt.
LogManager.d(TAG, "connect: Retrying connection for $deviceAddress to ${bluetoothDriverInstance.driverName()} while isConnecting=true but currentTargetAddress=null")
} else {
// Already connecting to or connected to the same deviceAddress
return@launch
}
}
LogManager.i(TAG, "connect: REQUEST for address $deviceAddress to driver ${bluetoothDriverInstance.driverName()}, UI ScaleUser ID: ${uiScaleUser?.id}, AppUserID: $appUserId")
_isConnecting.value = true
_isConnected.value = false
currentTargetAddress = deviceAddress // Store the address being connected to
currentInternalUser = uiScaleUser
LogManager.d(TAG, "connect: Internal user for connection: ${currentInternalUser?.id}, AppUserID: $appUserId")
currentInternalUser?.let { bluetoothDriverInstance.setSelectedScaleUser(it) }
appUserId?.let { bluetoothDriverInstance.setSelectedScaleUserId(it) }
LogManager.d(TAG, "connect: Calling connect() on Java driver instance (${bluetoothDriverInstance.driverName()}) for $deviceAddress.")
try {
bluetoothDriverInstance.connect(deviceAddress)
} catch (e: Exception) {
LogManager.e(TAG, "connect: Exception while calling bluetoothDriverInstance.connect() for $deviceAddress to ${bluetoothDriverInstance.driverName()}", e)
val message = applicationContext.getString(R.string.legacy_adapter_connect_exception, bluetoothDriverInstance.driverName(), e.message)
_eventsFlow.tryEmit(BluetoothEvent.ConnectionFailed(deviceAddress, message))
cleanupAfterDisconnect() // Ensure state is reset
}
}
}
override fun disconnect() {
adapterScope.launch {
val deviceNameToLog = currentTargetAddress ?: bluetoothDriverInstance.driverName()
LogManager.i(TAG, "disconnect: REQUEST for $deviceNameToLog")
if (!_isConnected.value && !_isConnecting.value) {
LogManager.d(TAG, "disconnect: Neither connected nor connecting to $deviceNameToLog. No action.")
return@launch
}
bluetoothDriverInstance.disconnect()
// Status flags will be updated by handler events (CONNECTION_DISCONNECT),
// but we can set them here to inform the UI more quickly.
// However, this might lead to premature UI updates if the driver's disconnect is asynchronous
// and fails. Relying on the handler event is safer for final state.
// _isConnected.value = false // Consider removing if handler is reliable
// _isConnecting.value = false // Consider removing if handler is reliable
// cleanupAfterDisconnect() is called by the handler.
}
}
/**
* Cleans up internal state after a disconnection or connection failure.
* Resets connection flags and clears stored target address and user.
*/
private fun cleanupAfterDisconnect() {
val deviceName = currentTargetAddress ?: bluetoothDriverInstance.driverName()
LogManager.d(TAG, "cleanupAfterDisconnect: Cleaning up for $deviceName (address was $currentTargetAddress)")
_isConnected.value = false
_isConnecting.value = false
currentTargetAddress = null
currentInternalUser = null
LogManager.i(TAG, "cleanupAfterDisconnect: Cleanup completed for ${bluetoothDriverInstance.driverName()}.")
}
override fun requestMeasurement() {
val deviceNameToLog = currentTargetAddress ?: bluetoothDriverInstance.driverName()
LogManager.d(TAG, "requestMeasurement: CALLED for $deviceNameToLog")
adapterScope.launch {
if (!_isConnected.value || currentTargetAddress == null) { // Explicitly check currentTargetAddress for an active connection
LogManager.w(TAG, "requestMeasurement: Not connected or no active address for measurement request to $deviceNameToLog.")
_eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(applicationContext.getString(R.string.legacy_adapter_request_measurement_not_connected), deviceNameToLog))
return@launch
}
LogManager.i(TAG, "requestMeasurement: For legacy driver (${bluetoothDriverInstance.driverName()}), measurement is usually triggered automatically. No generic action here.")
_eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(applicationContext.getString(R.string.legacy_adapter_request_measurement_auto), deviceNameToLog))
}
}
/**
* Releases resources used by this adapter, including unregistering the callback
* from the Bluetooth driver and canceling the coroutine scope.
* This method should be called when the adapter is no longer needed to prevent memory leaks.
*/
fun release() {
val deviceName = bluetoothDriverInstance.driverName()
LogManager.i(TAG, "release: Adapter for driver $deviceName is being released. Current target address: $currentTargetAddress")
bluetoothDriverInstance.registerCallbackHandler(null) // Important to prevent leaks
// Ensures any ongoing connection is terminated.
if (_isConnected.value || _isConnecting.value) {
// Using a separate launch for disconnect to avoid issues if the scope is cancelling.
// However, the scope will be cancelled immediately after.
// The driver's disconnect should ideally be robust.
CoroutineScope(Dispatchers.IO).launch { // Use a temporary scope for this last operation if needed
bluetoothDriverInstance.disconnect()
}
}
adapterScope.cancel("LegacyScaleAdapter for $deviceName released")
LogManager.i(TAG, "release: AdapterScope for $deviceName cancelled.")
}
/**
* Informs the legacy driver about the user's selection for a scale user.
* This is typically called in response to a [BluetoothEvent.UserSelectionRequired] event.
*
* @param appUserId The application-specific user ID.
* @param scaleUserIndex The index of the user on the scale.
*/
fun selectLegacyScaleUserIndex(appUserId: Int, scaleUserIndex: Int) {
adapterScope.launch {
LogManager.i(TAG, "selectLegacyScaleUserIndex for ${bluetoothDriverInstance.driverName()}: AppUserID: $appUserId, ScaleUserIndex: $scaleUserIndex")
bluetoothDriverInstance.selectScaleUserIndexForAppUserId(appUserId, scaleUserIndex, driverEventHandler)
}
}
/**
* Sends the user's consent value to the legacy scale driver.
* This is typically called after the scale requests user consent.
*
* @param appUserId The application-specific user ID.
* @param consentValue The consent value (specific to the driver's protocol).
*/
fun setLegacyScaleUserConsent(appUserId: Int, consentValue: Int) {
adapterScope.launch {
LogManager.i(TAG, "setLegacyScaleUserConsent for ${bluetoothDriverInstance.driverName()}: AppUserID: $appUserId, ConsentValue: $consentValue")
bluetoothDriverInstance.setScaleUserConsent(appUserId, consentValue, driverEventHandler)
}
}
/**
* Retrieves the name of the managed Bluetooth driver/device.
* Can be used externally if the name is needed and only a reference to the adapter is available.
*
* @return The name of the driver or a fallback class name if an error occurs.
*/
fun getManagedDeviceName(): String {
return try {
bluetoothDriverInstance.driverName()
} catch (e: Exception) {
LogManager.w(TAG, "Error getting driverName() in getManagedDeviceName. Falling back to simple class name.", e)
bluetoothDriverInstance.javaClass.simpleName
}
}
override fun getEventsFlow(): SharedFlow<BluetoothEvent> {
return events
}
}

View File

@@ -1,36 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class BFDeurenberg extends EstimatedFatMetric {
@Override
public String getName() {
return "Deurenberg (1992)";
}
@Override
public float getFat(ScaleUser user, ScaleMeasurement data) {
final int gender = user.getGender().isMale() ? 1 : 0;
if (user.getAge(data.getDateTime()) >= 16) {
return (1.2f * data.getBMI(user.getBodyHeight())) + (0.23f*user.getAge(data.getDateTime())) - (10.8f * gender) - 5.4f;
}
return (1.294f * data.getBMI(user.getBodyHeight())) + (0.20f*user.getAge(data.getDateTime())) - (11.4f * gender) - 8.0f;
}
}

View File

@@ -1,35 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class BFDeurenbergII extends EstimatedFatMetric {
@Override
public String getName() {
return "Deurenberg et. al (1991)";
}
@Override
public float getFat(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
return (data.getBMI(user.getBodyHeight()) * 1.2f) + (user.getAge(data.getDateTime()) * 0.23f) - 16.2f;
}
return (data.getBMI(user.getBodyHeight()) * 1.2f) + (user.getAge(data.getDateTime()) * 0.23f) - 5.4f;
}
}

View File

@@ -1,35 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class BFEddy extends EstimatedFatMetric {
@Override
public String getName() {
return "Eddy et. al (1976)";
}
@Override
public float getFat(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
return (1.281f* data.getBMI(user.getBodyHeight())) - 10.13f;
}
return (1.48f* data.getBMI(user.getBodyHeight())) - 7.0f;
}
}

View File

@@ -1,37 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class BFGallagher extends EstimatedFatMetric {
@Override
public String getName() {
return "Gallagher et. al [non-asian] (2000)";
}
@Override
public float getFat(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
// non-asian male
return 64.5f - 848.0f * (1.0f / data.getBMI(user.getBodyHeight())) + 0.079f * user.getAge(data.getDateTime()) - 16.4f + 0.05f * user.getAge(data.getDateTime()) + 39.0f * (1.0f / data.getBMI(user.getBodyHeight()));
}
// non-asian female
return 64.5f - 848.0f * (1.0f / data.getBMI(user.getBodyHeight())) + 0.079f * user.getAge(data.getDateTime());
}
}

View File

@@ -1,37 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class BFGallagherAsian extends EstimatedFatMetric {
@Override
public String getName() {
return "Gallagher et. al [asian] (2000)";
}
@Override
public float getFat(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
// asian male
return 51.9f - 740.0f * (1.0f / data.getBMI(user.getBodyHeight())) + 0.029f * user.getAge(data.getDateTime());
}
// asian female
return 64.8f - 752.0f * (1.0f / data.getBMI(user.getBodyHeight())) + 0.016f * user.getAge(data.getDateTime());
}
}

View File

@@ -1,44 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public abstract class EstimatedFatMetric {
// Don't change enum names, they are stored persistent in preferences
public enum FORMULA { BF_DEURENBERG, BF_DEURENBERG_II, BF_EDDY, BF_GALLAGHER, BF_GALLAGHER_ASIAN }
public static EstimatedFatMetric getEstimatedMetric(FORMULA metric) {
switch (metric) {
case BF_DEURENBERG:
return new BFDeurenberg();
case BF_DEURENBERG_II:
return new BFDeurenbergII();
case BF_EDDY:
return new BFEddy();
case BF_GALLAGHER:
return new BFGallagher();
case BF_GALLAGHER_ASIAN:
return new BFGallagherAsian();
}
return null;
}
public abstract String getName();
public abstract float getFat(ScaleUser user, ScaleMeasurement data);
}

View File

@@ -1,42 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import android.content.Context;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public abstract class EstimatedLBMMetric {
// Don't change enum names, they are stored persistent in preferences
public enum FORMULA { LBW_HUME, LBW_BOER, LBW_WEIGHT_MINUS_FAT }
public static EstimatedLBMMetric getEstimatedMetric(FORMULA metric) {
switch (metric) {
case LBW_HUME:
return new LBMHume();
case LBW_BOER:
return new LBMBoer();
case LBW_WEIGHT_MINUS_FAT:
return new LBMWeightMinusFat();
}
return null;
}
public abstract String getName(Context context);
public abstract float getLBM(ScaleUser user, ScaleMeasurement data);
}

View File

@@ -1,42 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public abstract class EstimatedWaterMetric {
// Don't change enum names, they are stored persistent in preferences
public enum FORMULA { TBW_BEHNKE, TBW_DELWAIDECRENIER, TBW_HUMEWEYERS, TBW_LEESONGKIM }
public static EstimatedWaterMetric getEstimatedMetric(FORMULA metric) {
switch (metric) {
case TBW_BEHNKE:
return new TBWBehnke();
case TBW_DELWAIDECRENIER:
return new TBWDelwaideCrenier();
case TBW_HUMEWEYERS:
return new TBWHumeWeyers();
case TBW_LEESONGKIM:
return new TBWLeeSongKim();
}
return null;
}
public abstract String getName();
public abstract float getWater(ScaleUser user, ScaleMeasurement data);
}

View File

@@ -1,38 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import android.content.Context;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class LBMBoer extends EstimatedLBMMetric {
@Override
public String getName(Context context) {
return "Boer (1984)";
}
@Override
public float getLBM(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
return (0.4071f * data.getWeight()) + (0.267f * user.getBodyHeight()) - 19.2f;
}
return (0.252f * data.getWeight()) + (0.473f * user.getBodyHeight()) - 48.3f;
}
}

View File

@@ -1,38 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import android.content.Context;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class LBMHume extends EstimatedLBMMetric {
@Override
public String getName(Context context) {
return "Hume (1966)";
}
@Override
public float getLBM(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
return (0.32810f * data.getWeight()) + (0.33929f * user.getBodyHeight()) - 29.5336f;
}
return (0.29569f * data.getWeight()) + (0.41813f * user.getBodyHeight()) - 43.2933f;
}
}

View File

@@ -1,42 +0,0 @@
/* Copyright (C) 2018 Erik Johansson <erik@ejohansson.se>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import android.content.Context;
import com.health.openscale.R;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
class LBMWeightMinusFat extends EstimatedLBMMetric {
@Override
public String getName(Context context) {
return String.format("%s - %s",
context.getResources().getString(R.string.label_weight),
context.getResources().getString(R.string.label_fat));
}
@Override
public float getLBM(ScaleUser user, ScaleMeasurement data) {
if (data.getFat() == 0) {
return 0;
}
float absFat = data.getWeight() * data.getFat() / 100.0f;
return data.getWeight() - absFat;
}
}

View File

@@ -1,35 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class TBWBehnke extends EstimatedWaterMetric {
@Override
public String getName() {
return "Behnke (1963)";
}
@Override
public float getWater(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
return 0.72f * (0.204f * user.getBodyHeight() * user.getBodyHeight()) / 100.0f;
}
return 0.72f * (0.18f * user.getBodyHeight() * user.getBodyHeight()) / 100.0f;
}
}

View File

@@ -1,31 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class TBWDelwaideCrenier extends EstimatedWaterMetric {
@Override
public String getName() {
return "Delwaide-Crenier et. al (1973)";
}
@Override
public float getWater(ScaleUser user, ScaleMeasurement data) {
return 0.72f * (-1.976f + 0.907f * data.getWeight());
}
}

View File

@@ -1,35 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class TBWHumeWeyers extends EstimatedWaterMetric {
@Override
public String getName() {
return "Hume & Weyers (1971)";
}
@Override
public float getWater(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
return (0.194786f * user.getBodyHeight()) + (0.296785f * data.getWeight()) - 14.012934f;
}
return (0.34454f * user.getBodyHeight()) + (0.183809f * data.getWeight()) - 35.270121f;
}
}

View File

@@ -1,35 +0,0 @@
/* Copyright (C) 2017 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.bodymetric;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
public class TBWLeeSongKim extends EstimatedWaterMetric {
@Override
public String getName() {
return "Lee, Song, Kim, Lee et. al (2001)";
}
@Override
public float getWater(ScaleUser user, ScaleMeasurement data) {
if (user.getGender().isMale()) {
return -28.3497f + (0.243057f * user.getBodyHeight()) + (0.366248f * data.getWeight());
}
return -26.6224f + (0.262513f * user.getBodyHeight()) + (0.232948f * data.getWeight());
}
}

View File

@@ -0,0 +1,171 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.data
import androidx.annotation.StringRes
import com.health.openscale.R
import java.util.Locale
enum class SupportedLanguage(val code: String, val nativeDisplayName: String) {
ENGLISH("en", "English"),
GERMAN("de", "Deutsch"),
SPANISH("es", "Español"),
FRENCH("fr", "Français");
fun toLocale(): Locale {
return Locale.Builder().setLanguage(code).build()
}
companion object {
fun fromCode(code: String?): SupportedLanguage? {
return entries.find { it.code == code }
}
fun getDefault(): SupportedLanguage {
val systemLangCode = Locale.getDefault().language
return fromCode(systemLangCode) ?: ENGLISH
}
}
}
enum class GenderType {
MALE,
FEMALE;
fun isMale(): Boolean {
return this == MALE}
}
enum class ActivityLevel {
SEDENTARY, MILD, MODERATE, HEAVY, EXTREME;
fun toInt(): Int {
when (this) {
SEDENTARY -> return 0
MILD -> return 1
MODERATE -> return 2
HEAVY -> return 3
EXTREME -> return 4
}
}
companion object {
fun fromInt(unit: Int): ActivityLevel {
when (unit) {
0 -> return SEDENTARY
1 -> return MILD
2 -> return MODERATE
3 -> return HEAVY
4 -> return EXTREME
}
return SEDENTARY
}
}
}
enum class WeightUnit {
KG, LB, ST;
override fun toString(): String {
when (this) {
WeightUnit.LB -> return "lb"
WeightUnit.ST -> return "st"
WeightUnit.KG -> return "kg"
}
}
fun toInt(): Int {
when (this) {
WeightUnit.LB -> return 1
WeightUnit.ST -> return 2
WeightUnit.KG -> return 0
}
}
companion object {
fun fromInt(unit: Int): WeightUnit {
when (unit) {
1 -> return WeightUnit.LB
2 -> return WeightUnit.ST
}
return WeightUnit.KG
}
}
}
enum class MeasurementTypeKey(
val id: Int,
@StringRes val localizedNameResId: Int // Added: Nullable resource ID for the name
) {
WEIGHT(1, R.string.measurement_type_weight),
BMI(2, R.string.measurement_type_bmi),
BODY_FAT(3, R.string.measurement_type_body_fat),
WATER(4, R.string.measurement_type_water),
MUSCLE(5, R.string.measurement_type_muscle),
LBM(6, R.string.measurement_type_lbm),
BONE(7, R.string.measurement_type_bone),
WAIST(8, R.string.measurement_type_waist),
WHR(9, R.string.measurement_type_whr),
WHTR(10, R.string.measurement_type_whtr),
HIPS(11, R.string.measurement_type_hips),
VISCERAL_FAT(12, R.string.measurement_type_visceral_fat),
CHEST(13, R.string.measurement_type_chest),
THIGH(14, R.string.measurement_type_thigh),
BICEPS(15, R.string.measurement_type_biceps),
NECK(16, R.string.measurement_type_neck),
CALIPER_1(17, R.string.measurement_type_caliper1),
CALIPER_2(18, R.string.measurement_type_caliper2),
CALIPER_3(19, R.string.measurement_type_caliper3),
CALIPER(20, R.string.measurement_type_fat_caliper),
BMR(21, R.string.measurement_type_bmr),
TDEE(22, R.string.measurement_type_tdee),
CALORIES(23, R.string.measurement_type_calories),
DATE(24, R.string.measurement_type_date),
TIME(25, R.string.measurement_type_time),
COMMENT(26, R.string.measurement_type_comment),
CUSTOM(99, R.string.measurement_type_custom_default_name);
}
enum class UnitType(val displayName: String) {
KG("kg"),
PERCENT("%"),
CM("cm"),
KCAL("kcal"),
NONE("")
}
enum class InputFieldType {
FLOAT,
INT,
TEXT,
DATE,
TIME
}
enum class Trend {
UP, DOWN, NONE, NOT_APPLICABLE
}
enum class TimeRangeFilter(val displayName: String) {
ALL_DAYS("Alle Tage"),
LAST_7_DAYS("Letzte 7 Tage"),
LAST_30_DAYS("Letzte 30 Tage"),
LAST_365_DAYS("Letzte 365 Tage")
}

View File

@@ -0,0 +1,37 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.data
import androidx.room.*
@Entity(
foreignKeys = [
ForeignKey(
entity = User::class,
parentColumns = ["id"],
childColumns = ["userId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("userId")]
)
data class Measurement(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val userId: Int,
val timestamp: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,61 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.data
import android.content.Context
import androidx.room.Entity
import androidx.room.Ignore
import androidx.room.PrimaryKey
@Entity
data class MeasurementType(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val key: MeasurementTypeKey = MeasurementTypeKey.CUSTOM,
val name: String? = null,
val color: Int = 0,
val icon : String = "ic_weight",
val unit: UnitType = UnitType.NONE,
val inputType: InputFieldType = InputFieldType.FLOAT,
val displayOrder: Int = 0,
val isDerived: Boolean = false,
val isEnabled : Boolean = true,
val isPinned : Boolean = false
){
/**
* Gets the appropriate display name for UI purposes.
* If the key points to a predefined type with a localized resource ID, that resource is used
* to ensure the name is displayed in the current device language.
* Otherwise (e.g., for CUSTOM types or if no specific resource ID is set for the key),
* the stored 'name' property is returned.
*
* @param context The context needed to resolve string resources.
* @return The display name for this measurement type.
*/
@Ignore // Room should not try to map this helper function to a DB column
fun getDisplayName(context: Context): String {
return if (key == MeasurementTypeKey.CUSTOM) {
if (!name.isNullOrBlank()) {
name
} else {
context.getString(key.localizedNameResId)
}
} else {
context.getString(key.localizedNameResId)
}
}
}

View File

@@ -0,0 +1,50 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
foreignKeys = [
ForeignKey(
entity = Measurement::class,
parentColumns = ["id"],
childColumns = ["measurementId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = MeasurementType::class,
parentColumns = ["id"],
childColumns = ["typeId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("measurementId"), Index("typeId")]
)
data class MeasurementValue(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val measurementId: Int,
val typeId: Int,
val floatValue: Float? = null,
val intValue: Int? = null,
val textValue: String? = null,
val dateValue: Long? = null
)

View File

@@ -0,0 +1,31 @@
/*
* openScale
* Copyright (C) 2025 olie.xdev <olie.xdeveloper@googlemail.com>
*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.health.openscale.core.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class User(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
val name: String,
val birthDate: Long,
val gender: GenderType,
val heightCm: Float? = null,
val activityLevel: ActivityLevel
)

View File

@@ -1,213 +0,0 @@
/* Copyright (C) 2018 olie.xdev <olie.xdev@googlemail.com>
*
* 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 <http://www.gnu.org/licenses/>
*/
package com.health.openscale.core.database;
import androidx.room.Database;
import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
import androidx.room.migration.Migration;
import androidx.sqlite.db.SupportSQLiteDatabase;
import com.health.openscale.core.datatypes.ScaleMeasurement;
import com.health.openscale.core.datatypes.ScaleUser;
import com.health.openscale.core.utils.Converters;
@Database(entities = {ScaleMeasurement.class, ScaleUser.class}, version = 6)
@TypeConverters({Converters.class})
public abstract class AppDatabase extends RoomDatabase {
public abstract ScaleMeasurementDAO measurementDAO();
public abstract ScaleUserDAO userDAO();
public static final Migration MIGRATION_1_2 = new Migration(1, 2) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.beginTransaction();
try {
// Drop old index on datetime only
database.execSQL("DROP INDEX index_scaleMeasurements_datetime");
// Rename old table
database.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld");
// Create new table with foreign key
database.execSQL("CREATE TABLE scaleMeasurements"
+ " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,"
+ " userId INTEGER NOT NULL, enabled INTEGER NOT NULL,"
+ " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL,"
+ " water REAL NOT NULL, muscle REAL NOT NULL, lbw REAL NOT NULL,"
+ " waist REAL NOT NULL, hip REAL NOT NULL, bone REAL NOT NULL,"
+ " comment TEXT, FOREIGN KEY(userId) REFERENCES scaleUsers(id)"
+ " ON UPDATE NO ACTION ON DELETE CASCADE)");
// Create new index on datetime + userId
database.execSQL("CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime"
+ " ON scaleMeasurements (userId, datetime)");
// Copy data from the old table, ignoring those with invalid userId (if any)
database.execSQL("INSERT INTO scaleMeasurements"
+ " SELECT * FROM scaleMeasurementsOld"
+ " WHERE userId IN (SELECT id from scaleUsers)");
// Delete old table
database.execSQL("DROP TABLE scaleMeasurementsOld");
database.setTransactionSuccessful();
}
finally {
database.endTransaction();
}
}
};
public static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.beginTransaction();
try {
// Drop old index
database.execSQL("DROP INDEX index_scaleMeasurements_userId_datetime");
// Rename old table
database.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld");
database.execSQL("ALTER TABLE scaleUsers RENAME TO scaleUsersOld");
// Create new table with foreign key
database.execSQL("CREATE TABLE scaleMeasurements"
+ " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,"
+ " userId INTEGER NOT NULL, enabled INTEGER NOT NULL,"
+ " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL,"
+ " water REAL NOT NULL, muscle REAL NOT NULL, visceralFat REAL NOT NULL,"
+ " lbm REAL NOT NULL, waist REAL NOT NULL, hip REAL NOT NULL,"
+ " bone REAL NOT NULL, chest REAL NOT NULL, thigh REAL NOT NULL,"
+ " biceps REAL NOT NULL, neck REAL NOT NULL, caliper1 REAL NOT NULL,"
+ " caliper2 REAL NOT NULL, caliper3 REAL NOT NULL, comment TEXT,"
+ " FOREIGN KEY(userId) REFERENCES scaleUsers(id)"
+ " ON UPDATE NO ACTION ON DELETE CASCADE)");
database.execSQL("CREATE TABLE scaleUsers "
+ "(id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "username TEXT NOT NULL, birthday INTEGER NOT NULL, bodyHeight REAL NOT NULL, "
+ "scaleUnit INTEGER NOT NULL, gender INTEGER NOT NULL, initialWeight REAL NOT NULL, "
+ "goalWeight REAL NOT NULL, goalDate INTEGER, measureUnit INTEGER NOT NULL, activityLevel INTEGER NOT NULL)");
// Create new index on datetime + userId
database.execSQL("CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime"
+ " ON scaleMeasurements (userId, datetime)");
// Copy data from the old table
database.execSQL("INSERT INTO scaleMeasurements"
+ " SELECT id, userId, enabled, datetime, weight, fat, water, muscle,"
+ " 0 AS visceralFat, lbw AS lbm, waist, hip, bone, 0 AS chest,"
+ " 0 as thigh, 0 as biceps, 0 as neck, 0 as caliper1,"
+ " 0 as caliper2, 0 as caliper3, comment FROM scaleMeasurementsOld");
database.execSQL("INSERT INTO scaleUsers"
+ " SELECT id, username, birthday, bodyHeight, scaleUnit, gender, initialWeight, goalWeight,"
+ " goalDate, 0 AS measureUnit, 0 AS activityLevel FROM scaleUsersOld");
// Delete old table
database.execSQL("DROP TABLE scaleMeasurementsOld");
database.execSQL("DROP TABLE scaleUsersOld");
database.setTransactionSuccessful();
}
finally {
database.endTransaction();
}
}
};
public static final Migration MIGRATION_3_4 = new Migration(3, 4) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.beginTransaction();
try {
// Drop old index
database.execSQL("DROP INDEX index_scaleMeasurements_userId_datetime");
// Rename old table
database.execSQL("ALTER TABLE scaleMeasurements RENAME TO scaleMeasurementsOld");
// Create new table with foreign key
database.execSQL("CREATE TABLE scaleMeasurements"
+ " (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,"
+ " userId INTEGER NOT NULL, enabled INTEGER NOT NULL,"
+ " datetime INTEGER, weight REAL NOT NULL, fat REAL NOT NULL,"
+ " water REAL NOT NULL, muscle REAL NOT NULL, visceralFat REAL NOT NULL,"
+ " lbm REAL NOT NULL, waist REAL NOT NULL, hip REAL NOT NULL,"
+ " bone REAL NOT NULL, chest REAL NOT NULL, thigh REAL NOT NULL,"
+ " biceps REAL NOT NULL, neck REAL NOT NULL, caliper1 REAL NOT NULL,"
+ " caliper2 REAL NOT NULL, caliper3 REAL NOT NULL, calories REAL NOT NULL, comment TEXT,"
+ " FOREIGN KEY(userId) REFERENCES scaleUsers(id)"
+ " ON UPDATE NO ACTION ON DELETE CASCADE)");
// Create new index on datetime + userId
database.execSQL("CREATE UNIQUE INDEX index_scaleMeasurements_userId_datetime"
+ " ON scaleMeasurements (userId, datetime)");
// Copy data from the old table
database.execSQL("INSERT INTO scaleMeasurements"
+ " SELECT id, userId, enabled, datetime, weight, fat, water, muscle,"
+ " visceralFat, lbm, waist, hip, bone, chest,"
+ " thigh, biceps, neck, caliper1,"
+ " caliper2, caliper3, 0 as calories, comment FROM scaleMeasurementsOld");
// Delete old table
database.execSQL("DROP TABLE scaleMeasurementsOld");
database.setTransactionSuccessful();
}
finally {
database.endTransaction();
}
}
};
public static final Migration MIGRATION_4_5 = new Migration(4, 5) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.beginTransaction();
try {
// Add assisted weighing and left/right amputation level to table
database.execSQL("ALTER TABLE scaleUsers ADD assistedWeighing INTEGER NOT NULL default 0");
database.execSQL("ALTER TABLE scaleUsers ADD leftAmputationLevel INTEGER NOT NULL default 0");
database.execSQL("ALTER TABLE scaleUsers ADD rightAmputationLevel INTEGER NOT NULL default 0");
database.setTransactionSuccessful();
}
finally {
database.endTransaction();
}
}
};
public static final Migration MIGRATION_5_6 = new Migration(5, 6) {
@Override
public void migrate(SupportSQLiteDatabase database) {
database.beginTransaction();
try {
// Add goal enabled to scale user table
database.execSQL("ALTER TABLE scaleUsers ADD goalEnabled INTEGER NOT NULL default 0");
database.setTransactionSuccessful();
}
finally {
database.endTransaction();
}
}
};
}

Some files were not shown because too many files have changed in this diff Show More