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">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -27,13 +40,20 @@
|
|||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.file_provider"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:grantUriPermissions="true">
|
android:grantUriPermissions="true">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
@@ -17,6 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.health.openscale
|
package com.health.openscale
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -165,7 +166,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
setContent {
|
setContent {
|
||||||
OpenScaleTheme {
|
OpenScaleTheme {
|
||||||
val sharedViewModel: SharedViewModel = viewModel(
|
val sharedViewModel: SharedViewModel = viewModel(
|
||||||
factory = provideSharedViewModelFactory(databaseRepository, userSettingsRepository)
|
factory = provideSharedViewModelFactory(application, databaseRepository, userSettingsRepository)
|
||||||
)
|
)
|
||||||
|
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
@@ -193,13 +194,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
* @return A [ViewModelProvider.Factory] for [SharedViewModel].
|
* @return A [ViewModelProvider.Factory] for [SharedViewModel].
|
||||||
*/
|
*/
|
||||||
private fun provideSharedViewModelFactory(
|
private fun provideSharedViewModelFactory(
|
||||||
|
application : Application,
|
||||||
databaseRepository: DatabaseRepository,
|
databaseRepository: DatabaseRepository,
|
||||||
userSettingsRepository: UserSettingsRepository
|
userSettingsRepository: UserSettingsRepository
|
||||||
): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
): ViewModelProvider.Factory = object : ViewModelProvider.Factory {
|
||||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
if (modelClass.isAssignableFrom(SharedViewModel::class.java)) {
|
if (modelClass.isAssignableFrom(SharedViewModel::class.java)) {
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
return SharedViewModel(databaseRepository, userSettingsRepository) as T
|
return SharedViewModel(application, databaseRepository, userSettingsRepository) as T
|
||||||
}
|
}
|
||||||
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
|
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
|
package com.health.openscale.ui.screen
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Intent
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.material3.SnackbarDuration
|
import androidx.compose.material3.SnackbarDuration
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.values
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.application
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.health.openscale.R
|
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.InputFieldType
|
||||||
import com.health.openscale.core.data.Measurement
|
import com.health.openscale.core.data.Measurement
|
||||||
import com.health.openscale.core.data.MeasurementType
|
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.MeasurementValue
|
||||||
import com.health.openscale.core.data.TimeRangeFilter
|
import com.health.openscale.core.data.TimeRangeFilter
|
||||||
import com.health.openscale.core.data.Trend
|
import com.health.openscale.core.data.Trend
|
||||||
@@ -57,6 +65,7 @@ import kotlinx.coroutines.flow.onStart
|
|||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
private const val TAG = "SharedViewModel"
|
private const val TAG = "SharedViewModel"
|
||||||
|
|
||||||
@@ -121,6 +130,7 @@ data class EnrichedMeasurement(
|
|||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class SharedViewModel(
|
class SharedViewModel(
|
||||||
|
private val application: Application,
|
||||||
val databaseRepository: DatabaseRepository,
|
val databaseRepository: DatabaseRepository,
|
||||||
val userSettingRepository: UserSettingsRepository
|
val userSettingRepository: UserSettingsRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -313,6 +323,9 @@ class SharedViewModel(
|
|||||||
databaseRepository.insertMeasurementValue(value.copy(measurementId = measurementToSave.id))
|
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)")
|
LogManager.i(TAG, "Measurement ID ${measurementToSave.id} and its values update process completed by ViewModel. (ViewModel Result)")
|
||||||
showSnackbar(messageResId = R.string.success_measurement_updated)
|
showSnackbar(messageResId = R.string.success_measurement_updated)
|
||||||
} else {
|
} else {
|
||||||
@@ -321,6 +334,8 @@ class SharedViewModel(
|
|||||||
valuesToSave.forEach { value ->
|
valuesToSave.forEach { value ->
|
||||||
databaseRepository.insertMeasurementValue(value.copy(measurementId = newMeasurementId))
|
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)")
|
LogManager.i(TAG, "New measurement insertion process completed by ViewModel with ID: $newMeasurementId. (ViewModel Result)")
|
||||||
showSnackbar(messageResId = R.string.success_measurement_saved)
|
showSnackbar(messageResId = R.string.success_measurement_saved)
|
||||||
}
|
}
|
||||||
@@ -337,6 +352,8 @@ class SharedViewModel(
|
|||||||
try {
|
try {
|
||||||
LogManager.d(TAG, "Preparing to delete measurement ID: ${measurement.id}. (ViewModel Logic)")
|
LogManager.d(TAG, "Preparing to delete measurement ID: ${measurement.id}. (ViewModel Logic)")
|
||||||
databaseRepository.deleteMeasurement(measurement)
|
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)")
|
LogManager.i(TAG, "Measurement ID ${measurement.id} deletion process completed by ViewModel. (ViewModel Result)")
|
||||||
showSnackbar(messageResId = R.string.success_measurement_deleted)
|
showSnackbar(messageResId = R.string.success_measurement_deleted)
|
||||||
if (_currentMeasurementId.value == measurement.id) {
|
if (_currentMeasurementId.value == measurement.id) {
|
||||||
@@ -600,6 +617,130 @@ class SharedViewModel(
|
|||||||
}
|
}
|
||||||
LogManager.i(TAG, "ViewModel initialization complete. (Lifecycle Event)")
|
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
|
* This ViewModel also manages user context relevant to Bluetooth operations and exposes
|
||||||
* StateFlows for UI observation.
|
* StateFlows for UI observation.
|
||||||
*
|
*
|
||||||
* @param context The application context.
|
* @param application The application context.
|
||||||
* @param sharedViewModel A [SharedViewModel] instance for accessing shared resources like
|
* @param sharedViewModel A [SharedViewModel] instance for accessing shared resources like
|
||||||
* repositories and for displaying global UI messages (e.g., Snackbars).
|
* repositories and for displaying global UI messages (e.g., Snackbars).
|
||||||
*/
|
*/
|
||||||
class BluetoothViewModel(
|
class BluetoothViewModel(
|
||||||
private val context: Application,
|
private val application: Application,
|
||||||
val sharedViewModel: SharedViewModel
|
val sharedViewModel: SharedViewModel
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -103,14 +103,14 @@ class BluetoothViewModel(
|
|||||||
private var currentAppUserId: Int = 0
|
private var currentAppUserId: Int = 0
|
||||||
|
|
||||||
// --- Dependencies (ScaleFactory is passed to managers) ---
|
// --- Dependencies (ScaleFactory is passed to managers) ---
|
||||||
private val scaleFactory = ScaleFactory(context.applicationContext, databaseRepository)
|
private val scaleFactory = ScaleFactory(application.applicationContext, databaseRepository)
|
||||||
|
|
||||||
// --- BluetoothScannerManager (manages device scanning) ---
|
// --- 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) ---
|
// --- BluetoothConnectionManager (manages device connection and data events) ---
|
||||||
private val bluetoothConnectionManager = BluetoothConnectionManager(
|
private val bluetoothConnectionManager = BluetoothConnectionManager(
|
||||||
context = context.applicationContext,
|
context = application.applicationContext,
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
scaleFactory = scaleFactory,
|
scaleFactory = scaleFactory,
|
||||||
databaseRepository = databaseRepository,
|
databaseRepository = databaseRepository,
|
||||||
@@ -483,13 +483,13 @@ class BluetoothViewModel(
|
|||||||
*/
|
*/
|
||||||
private fun checkInitialPermissions(): Boolean {
|
private fun checkInitialPermissions(): Boolean {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
|
||||||
} else {
|
} else {
|
||||||
// For older Android versions (below S)
|
// For older Android versions (below S)
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED &&
|
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH) == PackageManager.PERMISSION_GRANTED &&
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED &&
|
ContextCompat.checkSelfPermission(application, Manifest.permission.BLUETOOTH_ADMIN) == PackageManager.PERMISSION_GRANTED &&
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == 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.
|
* @return `true` if Bluetooth is enabled, `false` otherwise.
|
||||||
*/
|
*/
|
||||||
fun isBluetoothEnabled(): Boolean {
|
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
|
val isEnabled = bluetoothManager?.adapter?.isEnabled ?: false
|
||||||
// LogManager.v(TAG, "Bluetooth enabled status check: $isEnabled") // Potentially too verbose for frequent checks
|
// LogManager.v(TAG, "Bluetooth enabled status check: $isEnabled") // Potentially too verbose for frequent checks
|
||||||
return isEnabled
|
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_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_info_measuring_weight">Gewichtsmessung: %.2f</string>
|
||||||
<string name="bluetooth_scale_error_max_users_reached">Maximale Anzahl gleichzeitiger Waagenbenutzer erreicht.</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>
|
</resources>
|
||||||
|
@@ -374,4 +374,7 @@
|
|||||||
<string name="bluetooth_scale_info_measuring_weight">Measuring weight: %.2f</string>
|
<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="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>
|
</resources>
|
||||||
|
Reference in New Issue
Block a user