1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-29 19:20:36 +02:00

Add DatabaseProvider for data sharing and add real-time sync triggers with openScale sync

This commit is contained in:
oliexdev
2025-08-06 15:41:52 +02:00
parent 9e045553ef
commit 8b77750ff0
7 changed files with 721 additions and 16 deletions

View File

@@ -3,7 +3,20 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" tools:targetApi="s"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<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"/>
<queries>
<package android:name="com.health.openscale.sync.oss" />
<package android:name="com.health.openscale.sync" />
</queries>
<application
android:allowBackup="true"
@@ -27,13 +40,20 @@
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:authorities="${applicationId}.file_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</provider>
<provider
android:name=".core.database.DatabaseProvider"
android:authorities="${applicationId}.provider"
android:enabled="true"
android:exported="true"
android:permission="${applicationId}.READ_WRITE_DATA">
</provider>
</application>
</manifest>

View File

@@ -17,6 +17,7 @@
*/
package com.health.openscale
import android.app.Application
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -165,7 +166,7 @@ class MainActivity : ComponentActivity() {
setContent {
OpenScaleTheme {
val sharedViewModel: SharedViewModel = viewModel(
factory = provideSharedViewModelFactory(databaseRepository, userSettingsRepository)
factory = provideSharedViewModelFactory(application, databaseRepository, userSettingsRepository)
)
val view = LocalView.current
@@ -193,13 +194,14 @@ class MainActivity : ComponentActivity() {
* @return A [ViewModelProvider.Factory] for [SharedViewModel].
*/
private fun provideSharedViewModelFactory(
application : Application,
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
return SharedViewModel(application, databaseRepository, userSettingsRepository) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
}

View File

@@ -0,0 +1,536 @@
/*
* 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.database
import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
import android.content.UriMatcher
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import com.health.openscale.BuildConfig
import com.health.openscale.core.data.Measurement
import com.health.openscale.core.data.MeasurementTypeKey
import com.health.openscale.core.data.MeasurementValue
import com.health.openscale.core.utils.LogManager
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
/**
* Exposes the user and measurement data from openScale via
* [Android Content Providers](https://developer.android.com/guide/topics/providers/content-providers).
* This version is adapted to use DatabaseRepository internally while maintaining the external interface.
*/
class DatabaseProvider : ContentProvider() {
private val TAG = "DatabaseProvider"
private lateinit var databaseRepository: DatabaseRepository
object UserColumns {
const val _ID = "_ID"
const val NAME = "username"
}
object MeasurementColumns {
const val _ID = "_ID"
const val DATETIME = "datetime"
const val WEIGHT = "weight"
const val BODY_FAT = "fat"
const val WATER = "water"
const val MUSCLE = "muscle"
}
override fun onCreate(): Boolean {
val appContext = context!!.applicationContext
return try {
LogManager.init(appContext, false)
val appDbInstance = AppDatabase.getInstance(appContext)
databaseRepository = DatabaseRepository(
database = appDbInstance,
userDao = appDbInstance.userDao(),
measurementDao = appDbInstance.measurementDao(),
measurementTypeDao = appDbInstance.measurementTypeDao(),
measurementValueDao = appDbInstance.measurementValueDao()
)
LogManager.i(TAG, "DatabaseProvider initialized successfully.")
true
} catch (e: Exception) {
LogManager.e(TAG, "Failed to initialize DatabaseProvider: ${e.message}", e)
false
}
}
override fun getType(uri: Uri): String? {
return when (uriMatcher.match(uri)) {
MATCH_TYPE_META -> "vnd.android.cursor.item/vnd.$AUTHORITY.meta"
MATCH_TYPE_USER_LIST -> "vnd.android.cursor.dir/vnd.$AUTHORITY.user"
MATCH_TYPE_MEASUREMENT_LIST_FOR_USER -> "vnd.android.cursor.dir/vnd.$AUTHORITY.measurement"
else -> null
}
}
override fun query(
uri: Uri,
projection: Array<String?>?,
selection: String?,
selectionArgs: Array<String?>?,
sortOrder: String?
): Cursor? {
if (!::databaseRepository.isInitialized) {
LogManager.e(TAG, "DatabaseRepository not initialized in query.")
return null
}
val cursor: Cursor = when (uriMatcher.match(uri)) {
MATCH_TYPE_META -> {
MatrixCursor(arrayOf("apiVersion", "versionCode"), 1).apply {
addRow(arrayOf<Any>(API_VERSION, BuildConfig.VERSION_CODE))
}
}
MATCH_TYPE_USER_LIST -> {
runBlocking {
try {
val users = databaseRepository.getAllUsers().first()
val currentProjection = projection ?: arrayOf(UserColumns._ID, UserColumns.NAME)
val matrixCursor = MatrixCursor(currentProjection)
users.forEach { user ->
val rowData = mutableListOf<Any?>()
if (currentProjection.contains(UserColumns._ID)) rowData.add(user.id.toLong())
if (currentProjection.contains(UserColumns.NAME)) rowData.add(user.name)
matrixCursor.addRow(rowData.toTypedArray())
}
matrixCursor
} catch (e: Exception) {
LogManager.e(TAG, "Error querying users: ${e.message}", e)
MatrixCursor(projection ?: arrayOf(UserColumns._ID), 0)
}
}
}
MATCH_TYPE_MEASUREMENT_LIST_FOR_USER -> {
val userIdFromUri = try { ContentUris.parseId(uri).toInt() } catch (e: NumberFormatException) {
LogManager.e(TAG, "Invalid User ID in URI for measurement query: $uri", e)
return null
}
runBlocking {
try {
val measurementsWithValuesList =
databaseRepository.getMeasurementsWithValuesForUser(userIdFromUri).first()
val defaultMeasurementProjection = arrayOf(
MeasurementColumns._ID,
MeasurementColumns.DATETIME,
MeasurementColumns.WEIGHT,
MeasurementColumns.BODY_FAT,
MeasurementColumns.WATER,
MeasurementColumns.MUSCLE
)
val currentProjection = projection ?: defaultMeasurementProjection
val matrixCursor = MatrixCursor(currentProjection)
val allMeasurementTypes = databaseRepository.getAllMeasurementTypes().first()
val typeIdMap = allMeasurementTypes.associate { it.key to it.id }
measurementsWithValuesList.forEachIndexed { index, mcv -> // mcv is MeasurementWithValues
val measurement = mcv.measurement
val rowData = mutableListOf<Any?>()
fun findValue(key: MeasurementTypeKey): Float? {
val typeId = typeIdMap[key] ?: return null
return mcv.values.find { it.type.id == typeId }?.value?.floatValue
}
if (currentProjection.contains(MeasurementColumns._ID)) rowData.add(measurement.id)
if (currentProjection.contains(MeasurementColumns.DATETIME)) rowData.add(measurement.timestamp)
if (currentProjection.contains(MeasurementColumns.WEIGHT)) rowData.add(findValue(MeasurementTypeKey.WEIGHT))
if (currentProjection.contains(MeasurementColumns.BODY_FAT)) rowData.add(findValue(MeasurementTypeKey.BODY_FAT) ?: 0.0f)
if (currentProjection.contains(MeasurementColumns.WATER)) rowData.add(findValue(MeasurementTypeKey.WATER) ?: 0.0f)
if (currentProjection.contains(MeasurementColumns.MUSCLE)) rowData.add(findValue(MeasurementTypeKey.MUSCLE) ?: 0.0f)
LogManager.d(TAG, "Query Row #${index + 1} for user $userIdFromUri (MeasID: ${measurement.id}): ${
currentProjection.zip(rowData).joinToString { "${it.first}=${it.second}" }
}")
matrixCursor.addRow(rowData.toTypedArray())
}
matrixCursor
} catch (e: Exception) {
LogManager.e(TAG, "Error querying measurements for user $userIdFromUri: ${e.message}", e)
MatrixCursor(projection ?: arrayOf(MeasurementColumns.DATETIME), 0)
}
}
}
else -> throw IllegalArgumentException("Unknown URI for query: $uri")
}
cursor.setNotificationUri(context!!.contentResolver, uri)
return cursor
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
if (!::databaseRepository.isInitialized) {
LogManager.e(TAG, "DatabaseRepository not initialized in insert.")
return null
}
if (values == null) {
LogManager.w(TAG, "Attempted to insert null ContentValues.")
return null
}
when (uriMatcher.match(uri)) {
MATCH_TYPE_MEASUREMENT_LIST_FOR_USER -> {
val userIdFromUri = try { ContentUris.parseId(uri).toInt() } catch (e: NumberFormatException) {
LogManager.e(TAG, "Invalid User ID in URI for measurement insert: $uri", e)
return null
}
if (values.containsKey(MeasurementColumns._ID)) {
val idFromValues = values.getAsInteger(MeasurementColumns._ID)
if (idFromValues != null && idFromValues != userIdFromUri) {
LogManager.w(TAG, "User ID in ContentValues ($idFromValues) mismatches User ID in URI ($userIdFromUri) for insert. Using User ID from URI.")
}
}
val datetime = values.getAsLong(MeasurementColumns.DATETIME)
if (datetime == null) {
LogManager.e(TAG, "Cannot insert measurement: '${MeasurementColumns.DATETIME}' is missing. userId=$userIdFromUri")
return null
}
// Weight is considered mandatory for this provider's insert operation
val weight = values.getAsFloat(MeasurementColumns.WEIGHT)
if (weight == null) {
LogManager.e(TAG, "Cannot insert measurement: '${MeasurementColumns.WEIGHT}' is missing. datetime=$datetime, userId=$userIdFromUri")
return null
}
val measurement = Measurement(
// id = 0, // Room will generate this if it's an AutoGenerate PrimaryKey
userId = userIdFromUri,
timestamp = datetime
)
val measurementValuesToInsert = mutableListOf<MeasurementValue>()
var weightTypeIdFound: Int? = null // To ensure weight type exists
runBlocking {
val allMeasurementTypes = databaseRepository.getAllMeasurementTypes().first()
val typeIdMap = allMeasurementTypes.associate { it.key to it.id }
weightTypeIdFound = typeIdMap[MeasurementTypeKey.WEIGHT]
fun addValueIfPresent(cvKey: String, typeKey: MeasurementTypeKey, isMandatory: Boolean = false) {
if (values.containsKey(cvKey)) {
val floatValue = values.getAsFloat(cvKey)
if (floatValue != null) {
typeIdMap[typeKey]?.let { typeId ->
measurementValuesToInsert.add(
MeasurementValue(
measurementId = 0,
typeId = typeId,
floatValue = floatValue
)
)
} ?: LogManager.w(TAG, "$typeKey MeasurementTypeKey not found. Cannot insert $cvKey value for key $cvKey.")
} else {
if (isMandatory) {
LogManager.e(TAG, "Mandatory value for $cvKey ($typeKey) is missing and null in ContentValues.")
}
}
} else {
if (isMandatory) {
LogManager.e(TAG, "Mandatory key $cvKey ($typeKey) is not present in ContentValues.")
}
}
}
// Add weight (already checked for nullability)
if (weightTypeIdFound != null) {
measurementValuesToInsert.add(
MeasurementValue(
measurementId = 0,
typeId = weightTypeIdFound!!,
floatValue = weight
)
)
} else {
// This case should be caught by the mandatory weight check, but as a safeguard:
LogManager.e(TAG, "Weight MeasurementTypeKey not found internally, though weight value was provided.")
}
addValueIfPresent(MeasurementColumns.BODY_FAT, MeasurementTypeKey.BODY_FAT)
addValueIfPresent(MeasurementColumns.WATER, MeasurementTypeKey.WATER)
addValueIfPresent(MeasurementColumns.MUSCLE, MeasurementTypeKey.MUSCLE)
}
if (weightTypeIdFound == null) { // Double check if weight type ID was resolved
LogManager.e(TAG, "Weight MeasurementTypeKey system configuration issue. Cannot insert essential weight value.")
return null
}
if (measurementValuesToInsert.isEmpty()) {
LogManager.w(TAG, "No valid measurement values to insert (after mandatory weight). userId=$userIdFromUri, datetime=$datetime")
return null // Or decide to insert a measurement with no values if that's valid
}
try {
val insertedMeasurementId: Long? = runBlocking {
val pair = Pair(measurement, measurementValuesToInsert)
val ids = databaseRepository.insertMeasurementsWithValues(listOf(pair)) // Expects List<Pair<Measurement, List<MeasurementValue>>>
ids.firstOrNull() // Returns list of inserted measurement IDs
}
if (insertedMeasurementId != null && insertedMeasurementId > 0) {
// Notify change on the general list URI for this user
context!!.contentResolver.notifyChange(uri, null)
LogManager.d(TAG, "Measurement inserted with ID: $insertedMeasurementId. Old API compatibility: returning null.")
return null
} else {
LogManager.e(TAG, "Failed to insert measurement via Room or no ID returned.")
return null
}
} catch (e: Exception) {
LogManager.e(TAG, "Error during Room insert operation: ${e.message}", e)
return null
}
}
else -> throw IllegalArgumentException("Unknown URI for insert: $uri.")
}
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<String?>?
): Int {
if (!::databaseRepository.isInitialized) {
LogManager.e(TAG, "DatabaseRepository not initialized in update.")
return 0 // Return 0 rows affected
}
if (values == null || values.isEmpty) {
LogManager.w(TAG, "Attempted to update with null or empty ContentValues.")
return 0 // Return 0 rows affected
}
var rowsAffected = 0
when (uriMatcher.match(uri)) {
MATCH_TYPE_MEASUREMENT_LIST_FOR_USER -> {
val targetUserIdFromUri = try {
ContentUris.parseId(uri).toInt()
} catch (e: NumberFormatException) {
LogManager.e(TAG, "Invalid User ID in URI for measurement update: $uri", e)
return 0 // Return 0 rows affected
}
// DATETIME from ContentValues is used to identify the measurement to update.
// It should NOT be used to change the timestamp of an existing measurement,
// as that changes its identity. If you need to change a measurement's time,
// it's a more complex operation (delete old, insert new, or a dedicated function).
val datetimeToUpdate = values.getAsLong(MeasurementColumns.DATETIME)
if (datetimeToUpdate == null) {
LogManager.e(
TAG,
"Cannot update measurement: '${MeasurementColumns.DATETIME}' is missing from ContentValues. " +
"This field is used to identify the measurement to update."
)
return 0
}
// Check if _ID in ContentValues mismatches the one in URI
if (values.containsKey(MeasurementColumns._ID)) {
val idFromCV = values.getAsInteger(MeasurementColumns._ID)
if (idFromCV != null && idFromCV != targetUserIdFromUri) {
LogManager.w(
TAG,
"_ID in ContentValues ($idFromCV) mismatches _ID in URI ($targetUserIdFromUri) for update. " +
"Operation will proceed for _ID from URI."
)
}
}
// Perform database operations within runBlocking
rowsAffected = runBlocking {
try {
val allMeasurementTypes = databaseRepository.getAllMeasurementTypes().first()
val typeIdMap = allMeasurementTypes.associate { it.key to it.id }
// Find the existing measurement with its values
val existingMeasurementWithValues = databaseRepository
.getMeasurementsWithValuesForUser(targetUserIdFromUri)
.first()
.find { it.measurement.timestamp == datetimeToUpdate }
if (existingMeasurementWithValues == null) {
LogManager.d(
TAG,
"No measurement found to update for user $targetUserIdFromUri at datetime $datetimeToUpdate."
)
return@runBlocking 0 // Return 0 from runBlocking
}
val measurementToUpdate = existingMeasurementWithValues.measurement
var anyChangeMadeToValues = false
// Helper function to process updates for a specific measurement type
suspend fun processValueUpdate(cvKey: String, typeKey: MeasurementTypeKey) {
if (values.containsKey(cvKey)) {
val newValue = values.getAsFloat(cvKey) // Can be null if key exists but value is to be cleared/deleted
val typeId = typeIdMap[typeKey]
if (typeId == null) {
LogManager.w(
TAG,
"MeasurementTypeKey '$typeKey' (for CV key '$cvKey') not found in typeIdMap. Cannot update/delete value."
)
return // Skip this value
}
val existingValueWithType =
existingMeasurementWithValues.values.find { it.type.id == typeId }
if (newValue != null) { // New value is provided (update or insert)
if (existingValueWithType != null) { // Value exists, try to update
if (existingValueWithType.value.floatValue != newValue) {
val updatedDbValue = existingValueWithType.value.copy(floatValue = newValue)
databaseRepository.updateMeasurementValue(updatedDbValue)
anyChangeMadeToValues = true
LogManager.d(TAG, "Updated $typeKey for measurement ${measurementToUpdate.id} to $newValue")
}
} else { // Value doesn't exist for this type, insert new
val newDbValue = MeasurementValue(
// id = 0, // Room will generate
measurementId = measurementToUpdate.id,
typeId = typeId,
floatValue = newValue
)
databaseRepository.insertMeasurementValue(newDbValue)
anyChangeMadeToValues = true
LogManager.d(TAG, "Inserted new $typeKey for measurement ${measurementToUpdate.id} with value $newValue")
}
} else { // New value is null (ContentValues has the key, but its value is null) - implies delete
existingValueWithType?.value?.let { valueToDelete ->
// Special handling for essential values like WEIGHT if deletion is unintended by API design
if (typeKey == MeasurementTypeKey.WEIGHT) {
LogManager.w(
TAG,
"Attempt to delete WEIGHT value via update (by passing null for '${MeasurementColumns.WEIGHT}'). " +
"Weight value for measurement ID ${valueToDelete.measurementId} will be removed. " +
"Ensure this is intended as a measurement usually requires a weight."
)
}
databaseRepository.deleteMeasurementValueById(valueToDelete.id) // Assuming delete by ID
anyChangeMadeToValues = true
LogManager.d(TAG, "Deleted $typeKey (ID: ${valueToDelete.id}) for measurement ${measurementToUpdate.id}")
}
}
}
}
// Process updates for all relevant measurement types
processValueUpdate(MeasurementColumns.WEIGHT, MeasurementTypeKey.WEIGHT)
processValueUpdate(MeasurementColumns.BODY_FAT, MeasurementTypeKey.BODY_FAT)
processValueUpdate(MeasurementColumns.WATER, MeasurementTypeKey.WATER)
processValueUpdate(MeasurementColumns.MUSCLE, MeasurementTypeKey.MUSCLE)
if (anyChangeMadeToValues) {
// If any MeasurementValue changed, recalculate derived values
databaseRepository.recalculateDerivedValuesForMeasurement(measurementToUpdate.id)
LogManager.d(
TAG,
"Measurement values changed for user ${measurementToUpdate.userId}, " +
"original timestamp: ${existingMeasurementWithValues.measurement.timestamp}. Derived values recalculated."
)
return@runBlocking 1 // Return 1 row affected from runBlocking
} else {
LogManager.d(
TAG,
"No actual changes detected for measurement values for user $targetUserIdFromUri at datetime $datetimeToUpdate."
)
return@runBlocking 0 // Return 0 from runBlocking
}
} catch (e: Exception) {
LogManager.e(
TAG,
"Error updating measurement for user $targetUserIdFromUri: ${e.message}",
e
)
return@runBlocking 0 // Return 0 from runBlocking on error
}
}
}
else -> {
LogManager.w(TAG, "Update operation not supported for URI: $uri")
throw IllegalArgumentException("Unknown URI for update: $uri")
}
}
if (rowsAffected > 0) {
context!!.contentResolver.notifyChange(uri, null)
}
return rowsAffected
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String?>?): Int {
LogManager.w(TAG, "Delete operation is not supported by this provider.")
// To implement delete:
// 1. Identify user from URI (MATCH_TYPE_MEASUREMENT_LIST_FOR_USER implies user ID in URI)
// 2. Identify specific measurement to delete. This usually requires more than just user ID.
// Commonly, the `selection` and `selectionArgs` would specify criteria like `DATETIME = ?`.
// Or, you'd have a URI like "measurements/<user_id>/<measurement_id>" (MATCH_TYPE_SINGLE_MEASUREMENT).
// Example (conceptual):
/*
if (uriMatcher.match(uri) == MATCH_TYPE_MEASUREMENT_LIST_FOR_USER) {
val userId = ContentUris.parseId(uri).toInt()
if (selection != null && selectionArgs != null) {
// Parse selection to find the measurement (e.g., by datetime)
// val measurementToDelete = databaseRepository.findMeasurementByCriteria(userId, selection, selectionArgs)
// if (measurementToDelete != null) {
// databaseRepository.deleteMeasurementWithValues(measurementToDelete)
// rowsAffected = 1
// context!!.contentResolver.notifyChange(uri, null)
// }
}
}
*/
throw UnsupportedOperationException("Delete not supported by this provider")
}
companion object {
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
private const val API_VERSION = 1
val AUTHORITY = BuildConfig.APPLICATION_ID + ".provider"
private const val MATCH_TYPE_META = 1
private const val MATCH_TYPE_USER_LIST = 2 // content://<authority>/users
// content://<authority>/measurements/<user_id> (for list of measurements for a user)
private const val MATCH_TYPE_MEASUREMENT_LIST_FOR_USER = 3
init {
uriMatcher.addURI(AUTHORITY, "meta", MATCH_TYPE_META)
uriMatcher.addURI(AUTHORITY, "users", MATCH_TYPE_USER_LIST)
// The '#' wildcard matches a number (user ID in this case)
uriMatcher.addURI(AUTHORITY, "measurements/#", MATCH_TYPE_MEASUREMENT_LIST_FOR_USER)
}
}
}

View File

@@ -17,17 +17,25 @@
*/
package com.health.openscale.ui.screen
import android.app.Application
import android.content.ComponentName
import android.content.Intent
import androidx.annotation.StringRes
import androidx.compose.material3.SnackbarDuration
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.core.content.ContextCompat
import androidx.core.graphics.values
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.application
import androidx.lifecycle.viewModelScope
import com.health.openscale.R
import com.health.openscale.core.bluetooth.data.ScaleMeasurement
import com.health.openscale.core.data.InputFieldType
import com.health.openscale.core.data.Measurement
import com.health.openscale.core.data.MeasurementType
import com.health.openscale.core.data.MeasurementTypeKey
import com.health.openscale.core.data.MeasurementValue
import com.health.openscale.core.data.TimeRangeFilter
import com.health.openscale.core.data.Trend
@@ -57,6 +65,7 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.Date
private const val TAG = "SharedViewModel"
@@ -121,6 +130,7 @@ data class EnrichedMeasurement(
*/
@OptIn(ExperimentalCoroutinesApi::class)
class SharedViewModel(
private val application: Application,
val databaseRepository: DatabaseRepository,
val userSettingRepository: UserSettingsRepository
) : ViewModel() {
@@ -313,6 +323,9 @@ class SharedViewModel(
databaseRepository.insertMeasurementValue(value.copy(measurementId = measurementToSave.id))
}
}
triggerSyncUpdateMeasurement(measurementToSave, valuesToSave, "com.health.openscale.sync")
triggerSyncUpdateMeasurement(measurementToSave, valuesToSave,"com.health.openscale.sync.oss")
LogManager.i(TAG, "Measurement ID ${measurementToSave.id} and its values update process completed by ViewModel. (ViewModel Result)")
showSnackbar(messageResId = R.string.success_measurement_updated)
} else {
@@ -321,6 +334,8 @@ class SharedViewModel(
valuesToSave.forEach { value ->
databaseRepository.insertMeasurementValue(value.copy(measurementId = newMeasurementId))
}
triggerSyncInsertMeasurement(measurementToSave, valuesToSave,"com.health.openscale.sync")
triggerSyncInsertMeasurement(measurementToSave, valuesToSave,"com.health.openscale.sync.oss")
LogManager.i(TAG, "New measurement insertion process completed by ViewModel with ID: $newMeasurementId. (ViewModel Result)")
showSnackbar(messageResId = R.string.success_measurement_saved)
}
@@ -337,6 +352,8 @@ class SharedViewModel(
try {
LogManager.d(TAG, "Preparing to delete measurement ID: ${measurement.id}. (ViewModel Logic)")
databaseRepository.deleteMeasurement(measurement)
triggerSyncDeleteMeasurement(Date(measurement.timestamp), "com.health.openscale.sync")
triggerSyncDeleteMeasurement(Date(measurement.timestamp), "com.health.openscale.sync.oss")
LogManager.i(TAG, "Measurement ID ${measurement.id} deletion process completed by ViewModel. (ViewModel Result)")
showSnackbar(messageResId = R.string.success_measurement_deleted)
if (_currentMeasurementId.value == measurement.id) {
@@ -600,6 +617,130 @@ class SharedViewModel(
}
LogManager.i(TAG, "ViewModel initialization complete. (Lifecycle Event)")
}
private fun triggerSyncInsertMeasurement(
measurementToSave: Measurement,
valuesToSave: List<MeasurementValue>,
pkgName: String
) {
val intent = Intent()
intent.setComponent(
ComponentName(
pkgName,
"com.health.openscale.sync.core.service.SyncService"
)
)
intent.putExtra("mode", "insert")
intent.putExtra("userId", measurementToSave.userId)
intent.putExtra("date", measurementToSave.timestamp)
var weightValue: Float? = null
var fatValue: Float? = null
var waterValue: Float? = null
var muscleValue: Float? = null
val idToTypeKeyMap = MeasurementTypeKey.values().associateBy { it.id }
for (valueEntry in valuesToSave) {
when (idToTypeKeyMap[valueEntry.typeId]) {
MeasurementTypeKey.WEIGHT -> weightValue = valueEntry.floatValue
MeasurementTypeKey.BODY_FAT -> fatValue = valueEntry.floatValue
MeasurementTypeKey.WATER -> waterValue = valueEntry.floatValue
MeasurementTypeKey.MUSCLE -> muscleValue = valueEntry.floatValue
else -> { }
}
}
weightValue?.let { intent.putExtra("weight", it) }
fatValue?.let { intent.putExtra("fat", it) }
waterValue?.let { intent.putExtra("water", it) }
muscleValue?.let { intent.putExtra("muscle", it) }
LogManager.d(
TAG, "SyncService for INSERT started for pkg: $pkgName. " +
"UserId: ${measurementToSave.userId}, Date: ${measurementToSave.timestamp}, " +
"Weight: $weightValue, Fat: $fatValue, Water: $waterValue, Muscle: $muscleValue"
)
ContextCompat.startForegroundService(application.applicationContext, intent)
}
private fun triggerSyncUpdateMeasurement(
measurementToSave: Measurement,
valuesToSave: List<MeasurementValue>,
pkgName: String
) {
val intent = Intent()
intent.setComponent(
ComponentName(
pkgName,
"com.health.openscale.sync.core.service.SyncService"
)
)
intent.putExtra("mode", "update")
intent.putExtra("userId", measurementToSave.userId)
intent.putExtra("date", measurementToSave.timestamp)
var weightValue: Float? = null
var fatValue: Float? = null
var waterValue: Float? = null
var muscleValue: Float? = null
val idToTypeKeyMap = MeasurementTypeKey.values().associateBy { it.id }
for (valueEntry in valuesToSave) {
when (idToTypeKeyMap[valueEntry.typeId]) {
MeasurementTypeKey.WEIGHT -> weightValue = valueEntry.floatValue
MeasurementTypeKey.BODY_FAT -> fatValue = valueEntry.floatValue
MeasurementTypeKey.WATER -> waterValue = valueEntry.floatValue
MeasurementTypeKey.MUSCLE -> muscleValue = valueEntry.floatValue
else -> { }
}
}
weightValue?.let { intent.putExtra("weight", it) }
fatValue?.let { intent.putExtra("fat", it) }
waterValue?.let { intent.putExtra("water", it) }
muscleValue?.let { intent.putExtra("muscle", it) }
LogManager.d(
TAG, "SyncService for UPDATE started for pkg: $pkgName. " +
"UserId: ${measurementToSave.userId}, Date: ${measurementToSave.timestamp}, " +
"Weight: $weightValue, Fat: $fatValue, Water: $waterValue, Muscle: $muscleValue"
)
ContextCompat.startForegroundService(application.applicationContext, intent)
}
private fun triggerSyncDeleteMeasurement(date: Date, pkgName: String) {
val intent = Intent()
intent.setComponent(
ComponentName(
pkgName,
"com.health.openscale.sync.core.service.SyncService"
)
)
intent.putExtra("mode", "delete")
intent.putExtra("date", date.getTime())
ContextCompat.startForegroundService(application.applicationContext, intent)
LogManager.d(TAG, "SyncService for DELETE started for pkg: $pkgName")
}
private fun triggerSyncClearMeasurements(pkgName: String) {
val intent = Intent()
intent.setComponent(
ComponentName(
pkgName,
"com.health.openscale.sync.core.service.SyncService"
)
)
intent.putExtra("mode", "clear")
ContextCompat.startForegroundService(application.applicationContext, intent)
LogManager.d(TAG, "SyncService for CLEAR started for pkg: $pkgName")
}
}
/**

View File

@@ -79,12 +79,12 @@ enum class ConnectionStatus {
* This ViewModel also manages user context relevant to Bluetooth operations and exposes
* StateFlows for UI observation.
*
* @param context The application context.
* @param application The application context.
* @param sharedViewModel A [SharedViewModel] instance for accessing shared resources like
* repositories and for displaying global UI messages (e.g., Snackbars).
*/
class BluetoothViewModel(
private val context: Application,
private val application: Application,
val sharedViewModel: SharedViewModel
) : ViewModel() {
@@ -103,14 +103,14 @@ class BluetoothViewModel(
private var currentAppUserId: Int = 0
// --- Dependencies (ScaleFactory is passed to managers) ---
private val scaleFactory = ScaleFactory(context.applicationContext, databaseRepository)
private val scaleFactory = ScaleFactory(application.applicationContext, databaseRepository)
// --- BluetoothScannerManager (manages device scanning) ---
private val bluetoothScannerManager = BluetoothScannerManager(context, viewModelScope, scaleFactory)
private val bluetoothScannerManager = BluetoothScannerManager(application, viewModelScope, scaleFactory)
// --- BluetoothConnectionManager (manages device connection and data events) ---
private val bluetoothConnectionManager = BluetoothConnectionManager(
context = context.applicationContext,
context = application.applicationContext,
scope = viewModelScope,
scaleFactory = scaleFactory,
databaseRepository = databaseRepository,
@@ -483,13 +483,13 @@ class BluetoothViewModel(
*/
private fun checkInitialPermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
} else {
// For older Android versions (below S)
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(application, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
}
}
@@ -510,7 +510,7 @@ class BluetoothViewModel(
* @return `true` if Bluetooth is enabled, `false` otherwise.
*/
fun isBluetoothEnabled(): Boolean {
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
val bluetoothManager = application.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager?
val isEnabled = bluetoothManager?.adapter?.isEnabled ?: false
// LogManager.v(TAG, "Bluetooth enabled status check: $isEnabled") // Potentially too verbose for frequent checks
return isEnabled

View File

@@ -372,4 +372,7 @@
<string name="bluetooth_scale_info_step_on_for_reference">Bitte stellen Sie sich barfuß auf die Waage für Referenzmessungen.</string>
<string name="bluetooth_scale_info_measuring_weight">Gewichtsmessung: %.2f</string>
<string name="bluetooth_scale_error_max_users_reached">Maximale Anzahl gleichzeitiger Waagenbenutzer erreicht.</string>
<string name="permission_read_write_data_description">Auslesen der openScale Daten, inkl. Benutzerinformationen und aller gespeicherten Messungen</string>
<string name="permission_read_write_data_label">Lese- und Schreibzugriff auf openScale Daten</string>
</resources>

View File

@@ -374,4 +374,7 @@
<string name="bluetooth_scale_info_measuring_weight">Measuring weight: %.2f</string>
<string name="bluetooth_scale_error_max_users_reached">Max. number of concurrent scale users reached</string>
<string name="permission_read_write_data_description">read/write openScale data, including user information and all saved measurements</string>
<string name="permission_read_write_data_label">Read and Write openScale data</string>
</resources>