diff --git a/android_app/app/src/main/AndroidManifest.xml b/android_app/app/src/main/AndroidManifest.xml index 4c06deda..70cc50b6 100644 --- a/android_app/app/src/main/AndroidManifest.xml +++ b/android_app/app/src/main/AndroidManifest.xml @@ -3,7 +3,20 @@ xmlns:tools="http://schemas.android.com/tools"> - + + + + + + + + + + - + + + \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/MainActivity.kt b/android_app/app/src/main/java/com/health/openscale/MainActivity.kt index bbef7e29..ee1773a5 100644 --- a/android_app/app/src/main/java/com/health/openscale/MainActivity.kt +++ b/android_app/app/src/main/java/com/health/openscale/MainActivity.kt @@ -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 create(modelClass: Class): 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}") } diff --git a/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt new file mode 100644 index 00000000..8b61747f --- /dev/null +++ b/android_app/app/src/main/java/com/health/openscale/core/database/DatabaseProvider.kt @@ -0,0 +1,536 @@ +/* + * openScale + * Copyright (C) 2025 olie.xdev + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.health.openscale.core.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?, + selection: String?, + selectionArgs: Array?, + 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(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() + 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() + + 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() + 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>> + 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? + ): 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?): Int { + LogManager.w(TAG, "Delete operation is not supported by this provider.") + // To implement delete: + // 1. Identify user from URI (MATCH_TYPE_MEASUREMENT_LIST_FOR_USER implies user ID in URI) + // 2. Identify specific measurement to delete. This usually requires more than just user ID. + // Commonly, the `selection` and `selectionArgs` would specify criteria like `DATETIME = ?`. + // Or, you'd have a URI like "measurements//" (MATCH_TYPE_SINGLE_MEASUREMENT). + // Example (conceptual): + /* + if (uriMatcher.match(uri) == MATCH_TYPE_MEASUREMENT_LIST_FOR_USER) { + val userId = ContentUris.parseId(uri).toInt() + if (selection != null && selectionArgs != null) { + // Parse selection to find the measurement (e.g., by datetime) + // val measurementToDelete = databaseRepository.findMeasurementByCriteria(userId, selection, selectionArgs) + // if (measurementToDelete != null) { + // databaseRepository.deleteMeasurementWithValues(measurementToDelete) + // rowsAffected = 1 + // context!!.contentResolver.notifyChange(uri, null) + // } + } + } + */ + 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:///users + // content:///measurements/ (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) + } + } +} diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt index df8eee2a..294b2c83 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/SharedViewModel.kt @@ -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, + 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, + 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") + } } /** diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt index 4a06e3fd..dd4fbe84 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothViewModel.kt @@ -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 diff --git a/android_app/app/src/main/res/values-de/strings.xml b/android_app/app/src/main/res/values-de/strings.xml index 02a17dfc..2e6058f5 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -372,4 +372,7 @@ Bitte stellen Sie sich barfuß auf die Waage für Referenzmessungen. Gewichtsmessung: %.2f Maximale Anzahl gleichzeitiger Waagenbenutzer erreicht. + + Auslesen der openScale Daten, inkl. Benutzerinformationen und aller gespeicherten Messungen + Lese- und Schreibzugriff auf openScale Daten diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 3acc80b2..4f623ed4 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -374,4 +374,7 @@ Measuring weight: %.2f Max. number of concurrent scale users reached + read/write openScale data, including user information and all saved measurements + Read and Write openScale data +