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:
@@ -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>
|
@@ -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}")
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user