From 54b168d8794e8852b55d62346942228d39c27db1 Mon Sep 17 00:00:00 2001 From: oliexdev Date: Sat, 9 Aug 2025 21:01:13 +0200 Subject: [PATCH] Implemented Bluetooth user interaction handling --- android_app/app/build.gradle.kts | 4 +- .../core/bluetooth/ScaleCommnuicator.kt | 28 ++- .../bluetooth/scales/DummyScaleHandler.kt | 28 --- .../bluetooth/scales/ModernScaleAdapter.kt | 17 +- .../bluetooth/scales/ScaleDeviceHandler.kt | 71 +------ .../scalesJava/BluetoothCommunication.java | 100 ++++++---- .../scalesJava/BluetoothMiScale.java | 2 +- .../scalesJava/BluetoothMiScale2.java | 2 +- .../scalesJava/BluetoothOneByoneNew.java | 7 +- .../scalesJava/BluetoothSanitasSBF72.java | 4 +- .../BluetoothStandardWeightProfile.java | 77 ++++---- .../scalesJava/BluetoothYunmaiSE_Mini.java | 2 +- .../scalesJava/LegacyScaleAdapter.kt | 171 +++++++++++------ .../openscale/ui/navigation/AppNavigation.kt | 179 ++++++++++++++++++ .../bluetooth/BluetoothConnectionManager.kt | 68 ++++--- .../ui/screen/bluetooth/BluetoothViewModel.kt | 53 ++++-- .../app/src/main/res/values-de/strings.xml | 20 ++ .../app/src/main/res/values/strings.xml | 17 ++ 18 files changed, 560 insertions(+), 290 deletions(-) diff --git a/android_app/app/build.gradle.kts b/android_app/app/build.gradle.kts index 573785ea..66749a86 100644 --- a/android_app/app/build.gradle.kts +++ b/android_app/app/build.gradle.kts @@ -88,8 +88,8 @@ android { } create("beta") { - initWith(getByName("release")) - signingConfig = signingConfigs.getByName("release") + initWith(getByName("debug")) + signingConfig = signingConfigs.getByName("debug") applicationIdSuffix = ".beta" versionNameSuffix = "-beta" manifestPlaceholders["appName"] = "openScale beta" diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommnuicator.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommnuicator.kt index c290cf7a..b2bc1a08 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommnuicator.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/ScaleCommnuicator.kt @@ -17,6 +17,7 @@ */ package com.health.openscale.core.bluetooth +import android.os.Handler import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser import kotlinx.coroutines.flow.SharedFlow @@ -26,6 +27,11 @@ import kotlinx.coroutines.flow.StateFlow * Defines the events that can be emitted by a [ScaleCommunicator]. */ sealed class BluetoothEvent { + enum class UserInteractionType { + CHOOSE_USER, + ENTER_CONSENT + } + /** * Event triggered when a connection to a device has been successfully established. * @param deviceName The name of the connected device. @@ -76,15 +82,14 @@ sealed class BluetoothEvent { * Event triggered when user interaction is required to select a user on the scale. * This is often used when a scale supports multiple users and the app needs to clarify * which app user corresponds to the scale user. - * @param description A message describing why user selection is needed. * @param deviceIdentifier The identifier (e.g., MAC address) of the device requiring user selection. - * @param userData Optional data associated with the event, potentially containing information about users on the scale. + * @param data Optional data associated with the event, potentially containing information about users on the scale. * The exact type should be defined by the communicator implementation if more specific data is available. */ - data class UserSelectionRequired( - val description: String, + data class UserInteractionRequired( val deviceIdentifier: String, - val userData: Any? // Consider a more specific type if the structure of eventData is known. + val data: Any?, + val interactionType: UserInteractionType, ) : BluetoothEvent() } @@ -110,9 +115,8 @@ interface ScaleCommunicator { * Initiates a connection attempt to the device with the specified MAC address. * @param address The MAC address of the target device. * @param scaleUser The user to be selected or used on the scale (optional). - * @param appUserId The ID of the user in the application (optional, can be used for context). */ - fun connect(address: String, scaleUser: ScaleUser?, appUserId: Int?) + fun connect(address: String, scaleUser: ScaleUser?) /** * Disconnects the existing connection to the currently connected device. @@ -132,4 +136,14 @@ interface ScaleCommunicator { * @return A [SharedFlow] of [BluetoothEvent]s. */ fun getEventsFlow(): SharedFlow + + /** + * Processes feedback received from the user for a previously requested interaction. + */ + fun processUserInteractionFeedback( + interactionType: BluetoothEvent.UserInteractionType, + appUserId: Int, + feedbackData: Any, + uiHandler: Handler + ) } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DummyScaleHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DummyScaleHandler.kt index ea346968..ac9aa28e 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DummyScaleHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/DummyScaleHandler.kt @@ -61,32 +61,4 @@ class DummyScaleHandler(private val driverName: String) : ScaleDeviceHandler { println("DummyScaleHandler: disconnect called") // Add actual disconnection logic here } - - override suspend fun provideUserSelection( - selectedUser: ScaleUserListItem, - requestContext: Any? - ): Boolean { - // Dummy implementation - println("DummyScaleHandler: provideUserSelection called for user ${selectedUser.displayData}") - return true // Or false based on logic - } - - override suspend fun provideUserConsent( - consentType: String, - consented: Boolean, - details: Map? - ): Boolean { - // Dummy implementation - println("DummyScaleHandler: provideUserConsent called for $consentType, consented: $consented") - return true // Or false - } - - override suspend fun provideUserAttributes( - attributes: Map, - scaleUserIdentifier: Any? - ): Boolean { - // Dummy implementation - println("DummyScaleHandler: provideUserAttributes called with $attributes") - return true // Or false - } } \ No newline at end of file diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt index 69e83d6e..258928dd 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ModernScaleAdapter.kt @@ -28,8 +28,11 @@ import android.os.Looper import androidx.core.content.ContextCompat import com.health.openscale.core.bluetooth.BluetoothEvent import com.health.openscale.core.bluetooth.ScaleCommunicator +import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.scalesJava.BluetoothCommunication +import com.health.openscale.core.bluetooth.scalesJava.LegacyScaleAdapter import com.health.openscale.core.utils.LogManager import com.welie.blessed.BluetoothCentralManager import com.welie.blessed.BluetoothCentralManagerCallback @@ -73,6 +76,7 @@ object ScaleGattAttributes { class ModernScaleAdapter( private val context: Context ) : ScaleCommunicator { + private val TAG = "ModernScaleAdapter" private companion object { const val TAG = "ModernScaleAdapter" @@ -89,6 +93,14 @@ class ModernScaleAdapter( private val _eventsFlow = MutableSharedFlow(replay = 1, extraBufferCapacity = 5) override fun getEventsFlow(): SharedFlow = _eventsFlow.asSharedFlow() + override fun processUserInteractionFeedback( + interactionType: UserInteractionType, + appUserId: Int, + feedbackData: Any, + uiHandler: Handler + ) { + LogManager.w(TAG, "Error not implemented processUserInteractionFeedback received: Type=$interactionType, UserID=$appUserId, Data=$feedbackData", null) + } private val _isConnecting = MutableStateFlow(false) override val isConnecting: StateFlow = _isConnecting.asStateFlow() @@ -183,7 +195,7 @@ class ModernScaleAdapter( } } - override fun connect(address: String, scaleUser: ScaleUser?, appUserId: Int?) { + override fun connect(address: String, scaleUser: ScaleUser?) { adapterScope.launch { if (!::central.isInitialized) { LogManager.e(TAG, "BluetoothCentralManager nicht initialisiert, wahrscheinlich aufgrund fehlender Berechtigungen.") @@ -209,9 +221,8 @@ class ModernScaleAdapter( _isConnected.value = false targetAddress = address currentScaleUser = scaleUser - currentAppUserId = appUserId - LogManager.i(TAG, "Verbindungsversuch zu $address mit Benutzer: ${scaleUser?.id}, AppUserID: $appUserId") + LogManager.i(TAG, "Verbindungsversuch zu $address mit Benutzer: ${scaleUser?.id}") // Stoppe vorherige Scans, falls vorhanden central.stopScan() diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt index 4ecb5c34..39561246 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scales/ScaleDeviceHandler.kt @@ -18,6 +18,8 @@ package com.health.openscale.core.bluetooth.scales import android.util.SparseArray +import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType +import com.health.openscale.core.bluetooth.scalesJava.BluetoothCommunication import com.health.openscale.core.data.MeasurementTypeKey // Required for DeviceValue import kotlinx.coroutines.flow.Flow import java.util.UUID @@ -134,40 +136,10 @@ sealed class ScaleDeviceEvent { val payload: Any? = null // For optionally more structured info data ) : ScaleDeviceEvent() - /** - * Emitted when the scale requires the user to be selected on the device. - * The UI should present this list to the user. The response is provided via [ScaleDeviceHandler.provideUserSelection]. - * @param userList A list of [ScaleUserListItem] objects representing the user profiles available on the scale. - * @param requestContext An optional context object that the handler can send to correlate the response later. - */ - data class UserSelectionRequired(val userList: List, val requestContext: Any? = null) : ScaleDeviceEvent() - - /** - * Emitted when the scale requires user consent (e.g., in the app) to perform an action - * (e.g., user profile synchronization, data assignment, registration). - * The response is provided via [ScaleDeviceHandler.provideUserConsent]. - * @param consentType An identifier for the type of consent requested (handler-specific, e.g., "register_new_user"). - * @param messageToUser A user-readable message explaining the reason for the consent. - * @param details Optional additional details or data relevant to the consent - * (e.g., proposed scaleUserIndex if registering a new user: `mapOf("scaleUserIndexProposal" -> 3)`). - */ - data class UserConsentRequired( - val consentType: String, // e.g., "register_new_user", "confirm_user_match" - val messageToUser: String, - val details: Map? = null // e.g., mapOf("appUserId" -> 1, "scaleUserIndexProposal" -> 3) - ) : ScaleDeviceEvent() - - /** - * Emitted when the handler needs specific attributes of the app user to interact with the scale - * (e.g., to create or update a user on the scale). - * The response is provided via [ScaleDeviceHandler.provideUserAttributes]. - * @param requestedAttributes A list of keys indicating which attributes are needed (e.g., "height", "birthdate", "gender"). - * Example: `listOf("height_cm", "birth_date_epoch_ms", "gender_string")` - * @param scaleUserIdentifier The identifier of the scale user for whom the attributes are needed (if applicable). - */ - data class UserAttributesRequired( - val requestedAttributes: List, // e.g., listOf("height_cm", "birth_date_epoch_ms", "gender_string") - val scaleUserIdentifier: Any? = null + data class UserInteractionRequired( + val interactionType: UserInteractionType, + val data: Any? = null, // Daten vom Handler an die UI + val requestContext: Any? = null ) : ScaleDeviceEvent() } @@ -235,37 +207,6 @@ interface ScaleDeviceHandler { */ suspend fun disconnect() - /** - * Called by the application to provide the user's selection in response to a - * [ScaleDeviceEvent.UserSelectionRequired] event. - * - * @param selectedUser The [ScaleUserListItem] object selected by the user. - * @param requestContext The context that was sent with the original `UserSelectionRequired` event. - * @return `true` if the selection was processed successfully, `false` otherwise. - */ - suspend fun provideUserSelection(selectedUser: ScaleUserListItem, requestContext: Any? = null): Boolean - - /** - * Called by the application to provide the user's consent (or denial) in response to a - * [ScaleDeviceEvent.UserConsentRequired] event. - * - * @param consentType The type of consent, as specified in the original event. - * @param consented `true` if the user consented, `false` otherwise. - * @param details Additional details that were sent with the original `UserConsentRequired` event. - * @return `true` if the consent was processed successfully, `false` otherwise. - */ - suspend fun provideUserConsent(consentType: String, consented: Boolean, details: Map? = null): Boolean - - /** - * Called by the application to provide the requested user attributes in response to a - * [ScaleDeviceEvent.UserAttributesRequired] event. - * - * @param attributes A map of the provided attributes (keys as requested in the event). - * @param scaleUserIdentifier The identifier of the scale user, as requested in the event. - * @return `true` if the attributes were processed successfully, `false` otherwise. - */ - suspend fun provideUserAttributes(attributes: Map, scaleUserIdentifier: Any? = null): Boolean - /** * Sends a device-specific command to the scale. * This is an escape-hatch function for handler-specific actions not covered by diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java index 95fad6e8..e7102536 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothCommunication.java @@ -28,13 +28,16 @@ import android.content.pm.PackageManager; import android.location.LocationManager; import android.os.Handler; import android.os.Looper; +import android.os.Message; import androidx.core.content.ContextCompat; import com.health.openscale.R; +import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType; import com.health.openscale.core.bluetooth.data.ScaleMeasurement; import com.health.openscale.core.bluetooth.data.ScaleUser; +import com.health.openscale.core.data.User; import com.health.openscale.core.utils.LogManager; import com.welie.blessed.BluetoothCentralManager; import com.welie.blessed.BluetoothCentralManagerCallback; @@ -45,10 +48,12 @@ import com.welie.blessed.GattStatus; import com.welie.blessed.HciStatus; import com.welie.blessed.WriteType; +import java.util.Collections; import java.util.List; import java.util.UUID; public abstract class BluetoothCommunication { + private final String TAG = "BluetoothCommunication"; public enum BT_STATUS { RETRIEVE_SCALE_DATA, INIT_PROCESS, @@ -59,8 +64,7 @@ public abstract class BluetoothCommunication { NO_DEVICE_FOUND, UNEXPECTED_ERROR, SCALE_MESSAGE, - CHOOSE_SCALE_USER, - ENTER_SCALE_USER_CONSENT, + USER_INTERACTION_REQUIRED } private int stepNr; @@ -75,7 +79,9 @@ public abstract class BluetoothCommunication { private BluetoothPeripheral btPeripheral; private ScaleUser selectedScaleUser; - private int selectedScaleUserId; + + private List cachedAppUserList; + protected ScaleMeasurement cachedLastMeasurementForSelectedUser; public BluetoothCommunication(Context context) { @@ -85,7 +91,6 @@ public abstract class BluetoothCommunication { this.stopped = false; this.central = new BluetoothCentralManager(context, bluetoothCentralCallback, new Handler(Looper.getMainLooper())); this.selectedScaleUser = new ScaleUser(); - this.selectedScaleUserId = 0; } public void setSelectedScaleUser(ScaleUser user) { @@ -96,24 +101,60 @@ public abstract class BluetoothCommunication { return selectedScaleUser; } - public List getScaleUserList() { return null; } // TODO Not implemented - - public void setSelectedScaleUserId(int userId) { - selectedScaleUserId = userId; - } - public int getSelectedScaleUserId() { - return selectedScaleUserId; + public void setScaleUserList(List userList) { + cachedAppUserList = userList; } - public void updateScaleUser(ScaleUser user) {} // TODO Not implemented + public List getScaleUserList() { return cachedAppUserList; } - public int getAssignableUser(float weight) { return 0; } // TODO Not implemented + public void setCachedLastMeasurementForSelectedUser(ScaleMeasurement measurement) { + this.cachedLastMeasurementForSelectedUser = measurement; + if (measurement != null) { + LogManager.d(TAG, "Cached last measurement for selected user (ID: " + getSelectedScaleUser().getId() + ") set."); + } else { + LogManager.d(TAG, "Cached last measurement for selected user (ID: " + getSelectedScaleUser().getId() + ") cleared."); + } + } + public ScaleMeasurement getLastScaleMeasurement(int userId) { + if (getSelectedScaleUser().getId() == userId && this.cachedLastMeasurementForSelectedUser != null) { + LogManager.d(TAG, "Returning cached last measurement for user ID: " + userId); + return this.cachedLastMeasurementForSelectedUser; + } + if (getSelectedScaleUser().getId() != userId) { + LogManager.w(TAG, "Requested last measurement for user ID " + userId + + ", but cached data is for selected user ID " + getSelectedScaleUser().getId() + ". Returning null.", null); + } else { // cachedLastMeasurementForSelectedUser is null + LogManager.d(TAG, "No cached last measurement available for user ID: " + userId + ". Returning null."); + } + return null; + } - public ScaleUser getScaleUser(int userId) { return null; } // TODO Not implemented + protected void requestUserInteraction(UserInteractionType interactionType, Object data) { + if (callbackBtHandler != null) { + Message msg = callbackBtHandler.obtainMessage(BT_STATUS.USER_INTERACTION_REQUIRED.ordinal()); - public ScaleMeasurement getLastScaleMeasurement(int userId) { return null; } // TODO Not implemented - public BluetoothPeripheral getBtPeripheral() { - return btPeripheral; + Object[] payload = new Object[]{interactionType, data}; + msg.obj = payload; + + LogManager.d(TAG, "Sending USER_INTERACTION_REQUIRED (" + interactionType + ") to handler. Data: " + (data != null ? data.toString() : "null")); + msg.sendToTarget(); + } + } + + /** + * Processes feedback received from the user in response to a requestUserInteraction call. + * The specific implementation in subclasses will handle the data based on the original interaction type. + * + * @param interactionType The type of interaction this feedback corresponds to. + * @param appUserId The ID of the application user this feedback is for. + * @param feedbackData Data provided by the user (e.g., selected scaleUserIndex, entered consent code). + * The type and structure depend on the interactionType. + * For CHOOSE_USER, this would be the selected scaleUserIndex (Integer). + * For ENTER_CONSENT, this would be the consent code (Integer). + * @param uiHandler Handler for potential further UI updates or operations within the communicator. + */ + public void processUserInteractionFeedback(UserInteractionType interactionType, int appUserId, Object feedbackData, Handler uiHandler) { + LogManager.d(TAG, "processUserInteractionFeedback for " + interactionType + " not implemented in base class. AppUserId: " + appUserId); } protected boolean needReConnect() { @@ -172,27 +213,13 @@ public abstract class BluetoothCommunication { } } - protected void chooseScaleUserUi(Object userList) { - if (callbackBtHandler != null) { - callbackBtHandler.obtainMessage( - BT_STATUS.CHOOSE_SCALE_USER.ordinal(), userList).sendToTarget(); - } - } - - protected void enterScaleUserConsentUi(int appScaleUserId, int scaleUserIndex) { - if (callbackBtHandler != null) { - callbackBtHandler.obtainMessage( - BT_STATUS.ENTER_SCALE_USER_CONSENT.ordinal(), appScaleUserId, scaleUserIndex).sendToTarget(); - } - } - /** * Send message to openScale user * * @param msg the string id to be send * @param value the value to be used */ - protected void sendMessage(int msg, Object value) { // TODO implement in openScale 3.0 + protected void sendMessage(int msg, Object value) { if (callbackBtHandler != null) { callbackBtHandler.obtainMessage( BT_STATUS.SCALE_MESSAGE.ordinal(), msg, 0, value).sendToTarget(); @@ -412,15 +439,6 @@ public abstract class BluetoothCommunication { disconnectHandler.removeCallbacksAndMessages(null); } - public void selectScaleUserIndexForAppUserId(int appUserId, int scaleUserIndex, Handler uiHandler) { - LogManager.d("BluetoothCommunication","Set scale user index for app user id: Not implemented!"); - } - - public void setScaleUserConsent(int appUserId, int scaleUserConsent, Handler uiHandler) { - LogManager.d("BluetoothCommunication","Set scale user consent for app user id: Not implemented!"); - } - - // +++ public byte[] getScaleMacAddress() { String[] mac = btPeripheral.getAddress().split(":"); byte[] macAddress = new byte[6]; diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale.java index b421fab1..e51586fb 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale.java @@ -238,7 +238,7 @@ public class BluetoothMiScale extends BluetoothCommunication { prefs.edit().putInt("uniqueNumber", uniqueNumber).apply(); } - int userId = getSelectedScaleUserId(); + int userId = getSelectedScaleUser().getId(); return uniqueNumber + userId; } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale2.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale2.java index 0108f458..3aef3984 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale2.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothMiScale2.java @@ -232,7 +232,7 @@ public class BluetoothMiScale2 extends BluetoothCommunication { prefs.edit().putInt("uniqueNumber", uniqueNumber).apply(); } - int userId = getSelectedScaleUserId(); + int userId = getSelectedScaleUser().getId(); return uniqueNumber + userId; } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothOneByoneNew.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothOneByoneNew.java index 84796848..77614ae9 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothOneByoneNew.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothOneByoneNew.java @@ -73,7 +73,7 @@ public class BluetoothOneByoneNew extends BluetoothCommunication{ impedance = Converters.fromUnsignedInt16Be(data, 15); ScaleMeasurement historicMeasurement = new ScaleMeasurement(); - int assignableUserId = getAssignableUser(weight); + int assignableUserId = getSelectedScaleUser().getId(); // TODO old implementation was getAssignableUser(weight); if(assignableUserId == -1){ LogManager.i(TAG, "Discarding historic measurement: no user found with intelligent user recognition"); break; @@ -117,10 +117,9 @@ public class BluetoothOneByoneNew extends BluetoothCommunication{ LogManager.e(TAG, "Discarding measurement population since invalid user", null); return; } - ScaleUser user = getScaleUser(userId); + ScaleUser user = getSelectedScaleUser(); float cmHeight = Converters.fromCentimeter(user.getBodyHeight(), user.getMeasureUnit()); OneByoneNewLib onebyoneLib = new OneByoneNewLib(getUserGender(user), user.getAge(), cmHeight, user.getActivityLevel().toInt()); - measurement.setUserId(userId); measurement.setWeight(weight); measurement.setDateTime(Calendar.getInstance().getTime()); measurement.setFat(onebyoneLib.getBodyFatPercentage(weight, impedance)); @@ -156,7 +155,7 @@ public class BluetoothOneByoneNew extends BluetoothCommunication{ break; case 2: // After the measurement took place, we store the data and send back to the scale - sendUsersHistory(getSelectedScaleUserId()); + sendUsersHistory(getSelectedScaleUser().getId()); break; default: return false; diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothSanitasSBF72.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothSanitasSBF72.java index 63c35aeb..d3c28089 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothSanitasSBF72.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothSanitasSBF72.java @@ -58,7 +58,7 @@ public class BluetoothSanitasSBF72 extends BluetoothStandardWeightProfile { } @Override - protected void enterScaleUserConsentUi(int appScaleUserId, int scaleUserIndex) { + protected void requestScaleUserConsent(int appScaleUserId, int scaleUserIndex) { //Requests the scale to display the pin for the user in it's display. //As parameter we need to send a pin-index to the custom user-list characteristic. //For user with index 1 the pin-index is 0x11, for user with index 2 it is 0x12 and so on. @@ -68,7 +68,7 @@ public class BluetoothSanitasSBF72 extends BluetoothStandardWeightProfile { writeBytes(SERVICE_SBF72_CUSTOM, CHARACTERISTIC_USER_LIST, parser.getValue()); //opens the input screen for the pin in the app - super.enterScaleUserConsentUi(appScaleUserId, scaleUserIndex); + super.requestScaleUserConsent(appScaleUserId, scaleUserIndex); } @Override diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothStandardWeightProfile.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothStandardWeightProfile.java index 8c18132a..20a77844 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothStandardWeightProfile.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothStandardWeightProfile.java @@ -30,6 +30,7 @@ import android.preference.PreferenceManager; import android.util.Pair; import com.health.openscale.R; +import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType; import com.health.openscale.core.bluetooth.data.ScaleMeasurement; import com.health.openscale.core.bluetooth.data.ScaleUser; import com.health.openscale.core.data.ActivityLevel; @@ -295,7 +296,7 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat resumeMachineState(); } else if (value[2] == UDS_CP_RESP_USER_NOT_AUTHORIZED) { LogManager.e(TAG, "UDS_CP_CONSENT: Not authorized", null); - enterScaleUserConsentUi(this.selectedUser.getId(), getUserScaleIndex(this.selectedUser.getId())); + requestScaleUserConsent(selectedUser.getId(), getUserScaleIndex(selectedUser.getId())); } else { LogManager.e(TAG, "UDS_CP_CONSENT: unhandled, code: " + value[2], null); @@ -355,12 +356,6 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat } if (registerNewUser) { - LogManager.d(TAG, String.format(prefix + "Setting initial weight for user %s to: %s and registerNewUser to false", userID, - weightValue)); - if (selectedUser.getId() == userID) { - this.selectedUser.setInitialWeight(weightValue); - updateScaleUser(selectedUser); - } registerNewUser = false; resumeMachineState(); } @@ -721,32 +716,50 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat resumeMachineState(); } - @Override - public void selectScaleUserIndexForAppUserId(int appUserId, int scaleUserIndex, Handler uiHandler) { - LogManager.d(TAG, "Select scale user index from UI: user id: " + appUserId + ", scale user index: " + scaleUserIndex); - if (scaleUserIndex == -1) { - reconnectOrSetSmState(SM_STEPS.REGISTER_NEW_SCALE_USER, SM_STEPS.REGISTER_NEW_SCALE_USER, uiHandler); - } - else { - storeUserScaleIndex(appUserId, scaleUserIndex); - if (getUserScaleConsent(appUserId) == -1) { - enterScaleUserConsentUi(appUserId, scaleUserIndex); - } - else { - reconnectOrSetSmState(SM_STEPS.SELECT_SCALE_USER, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); - } - } + protected void requestScaleUserConsent(int appScaleUserId, int scaleUserIndex) { + Object[] consentRequestData = new Object[]{appScaleUserId, scaleUserIndex}; + requestUserInteraction(UserInteractionType.ENTER_CONSENT, consentRequestData); } - @Override - public void setScaleUserConsent(int appUserId, int scaleUserConsent, Handler uiHandler) { - LogManager.d(TAG, "set scale user consent from UI: user id: " + appUserId + ", scale user consent: " + scaleUserConsent); - storeUserScaleConsentCode(appUserId, scaleUserConsent); - if (scaleUserConsent == -1) { - reconnectOrSetSmState(SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); - } - else { - reconnectOrSetSmState(SM_STEPS.SELECT_SCALE_USER, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); + @Override + public void processUserInteractionFeedback(UserInteractionType interactionType, int appUserId, Object feedbackData, Handler uiHandler) { + LogManager.d(TAG, "Processing UserInteractionFeedback: " + interactionType + " for appUserId: " + appUserId); + switch (interactionType) { + case CHOOSE_USER: + if (feedbackData instanceof Integer) { + int scaleUserIndex = (Integer) feedbackData; + LogManager.d(TAG, "CHOOSE_USER Feedback: scaleUserIndex = " + scaleUserIndex); + if (scaleUserIndex == -1) { // User wants to create a new user + reconnectOrSetSmState(SM_STEPS.REGISTER_NEW_SCALE_USER, SM_STEPS.REGISTER_NEW_SCALE_USER, uiHandler); + } else { // User selected an existing scale user + storeUserScaleIndex(appUserId, scaleUserIndex); + if (getUserScaleConsent(appUserId) == -1) { + requestScaleUserConsent(appUserId, scaleUserIndex); + } else { + reconnectOrSetSmState(SM_STEPS.SELECT_SCALE_USER, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); + } + } + } else { + LogManager.e(TAG, "CHOOSE_USER feedbackData is not an Integer: " + feedbackData, null); + } + break; + case ENTER_CONSENT: + if (feedbackData instanceof Integer) { + int scaleUserConsent = (Integer) feedbackData; + LogManager.d(TAG, "ENTER_CONSENT Feedback: scaleUserConsent = " + scaleUserConsent); + storeUserScaleConsentCode(appUserId, scaleUserConsent); + if (scaleUserConsent == -1) { // User cancelled or denied consent + reconnectOrSetSmState(SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); + } else { // User provided consent + reconnectOrSetSmState(SM_STEPS.SELECT_SCALE_USER, SM_STEPS.REQUEST_VENDOR_SPECIFIC_USER_LIST, uiHandler); + } + } else { + LogManager.e(TAG, "ENTER_CONSENT feedbackData is not an Integer: " + feedbackData, null); + } + break; + default: + LogManager.w(TAG, "Unhandled UserInteractionType in processUserInteractionFeedback: " + interactionType, null); + break; } } @@ -841,7 +854,7 @@ public abstract class BluetoothStandardWeightProfile extends BluetoothCommunicat indexArray[userList.size()] = -1; } Pair choices = new Pair(choiceStrings, indexArray); - chooseScaleUserUi(choices); + requestUserInteraction(UserInteractionType.CHOOSE_USER, choices); } protected String getInitials(String fullName) { diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothYunmaiSE_Mini.java b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothYunmaiSE_Mini.java index ec739929..a609e4ec 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothYunmaiSE_Mini.java +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/BluetoothYunmaiSE_Mini.java @@ -184,7 +184,7 @@ public class BluetoothYunmaiSE_Mini extends BluetoothCommunication { prefs.edit().putInt("uniqueNumber", uniqueNumber).apply(); } - int userId = getSelectedScaleUserId(); + int userId = getSelectedScaleUser().getId(); return uniqueNumber + userId; } diff --git a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/LegacyScaleAdapter.kt b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/LegacyScaleAdapter.kt index cb32522f..fd6450fa 100644 --- a/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/LegacyScaleAdapter.kt +++ b/android_app/app/src/main/java/com/health/openscale/core/bluetooth/scalesJava/LegacyScaleAdapter.kt @@ -17,15 +17,20 @@ */ package com.health.openscale.core.bluetooth.scalesJava +import android.R.attr.description import android.content.Context import android.os.Handler import android.os.Looper import android.os.Message +import androidx.compose.foundation.layout.size +import androidx.core.graphics.values import com.health.openscale.R import com.health.openscale.core.bluetooth.BluetoothEvent import com.health.openscale.core.bluetooth.ScaleCommunicator +import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.database.DatabaseRepository import com.health.openscale.core.utils.LogManager import kotlinx.coroutines.CoroutineName @@ -40,8 +45,13 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.lang.ref.WeakReference +import java.util.Date +import kotlin.text.find +import kotlin.text.toDouble /** * Adapter that adapts a legacy `BluetoothCommunication` (Java driver) instance @@ -89,6 +99,75 @@ class LegacyScaleAdapter( bluetoothDriverInstance.registerCallbackHandler(driverEventHandler) } + private suspend fun provideUserDataToLegacyDriver() { + try { + LogManager.d(TAG, "Attempting to load user data for legacy driver: ${bluetoothDriverInstance.driverName()}") + val userListFromDb = databaseRepository.getAllUsers().first() + + val legacyScaleUserList = userListFromDb.map { kotlinUser -> + ScaleUser().apply { + setId(kotlinUser.id) + setUserName(kotlinUser.name) + setBirthday(Date(kotlinUser.birthDate)) + setBodyHeight(if (kotlinUser.heightCm == null) 0f else kotlinUser.heightCm) + setGender(kotlinUser.gender) + setActivityLevel(kotlinUser.activityLevel) + + // TODO setInitialWeight(kotlinUser.initialWeight) + // TODO setGoalWeight(kotlinUser.goalWeight) + // TODO usw. + } + } + + bluetoothDriverInstance.setScaleUserList(legacyScaleUserList) + + LogManager.i(TAG, "Successfully provided ${userListFromDb.size} users to legacy driver ${bluetoothDriverInstance.driverName()}.") + + this.currentInternalUser?.let { userId -> + if (userId.id != -1) { + LogManager.d(TAG, "Attempting to load last measurement for user ID: $userId using existing repository methods.") + val lastMeasurementWithValues: com.health.openscale.core.model.MeasurementWithValues? = + databaseRepository.getMeasurementsWithValuesForUser(userId.id) + .map { measurements -> + measurements.maxByOrNull { it.measurement.timestamp } + } + .first() + + if (lastMeasurementWithValues != null) { + val legacyScaleMeasurement = ScaleMeasurement().apply { + setDateTime(Date(lastMeasurementWithValues.measurement.timestamp)) + setUserId(lastMeasurementWithValues.measurement.userId) + setWeight(lastMeasurementWithValues.values.find { it.type.key == MeasurementTypeKey.WEIGHT }?.value?.floatValue ?: 0.0f) + setFat(lastMeasurementWithValues.values.find { it.type.key == MeasurementTypeKey.BODY_FAT }?.value?.floatValue ?: 0.0f) + setWater(lastMeasurementWithValues.values.find { it.type.key == MeasurementTypeKey.WATER }?.value?.floatValue ?: 0.0f) + setMuscle(lastMeasurementWithValues.values.find { it.type.key == MeasurementTypeKey.MUSCLE }?.value?.floatValue ?: 0.0f) + setBone(lastMeasurementWithValues.values.find { it.type.key == MeasurementTypeKey.BONE }?.value?.floatValue ?: 0.0f) + setVisceralFat(lastMeasurementWithValues.values.find { it.type.key == MeasurementTypeKey.VISCERAL_FAT }?.value?.floatValue ?: 0.0f) + setLbm(lastMeasurementWithValues.values.find { it.type.key == MeasurementTypeKey.LBM }?.value?.floatValue ?: 0.0f) + } + bluetoothDriverInstance.setCachedLastMeasurementForSelectedUser(legacyScaleMeasurement) + LogManager.i(TAG, "Successfully provided last measurement for user $userId to legacy driver.") + } else { + bluetoothDriverInstance.setCachedLastMeasurementForSelectedUser(null) + LogManager.d(TAG, "No last measurement found for user $userId.") + } + } else { + bluetoothDriverInstance.setCachedLastMeasurementForSelectedUser(null) + } + } ?: run { + bluetoothDriverInstance.setCachedLastMeasurementForSelectedUser(null) + LogManager.d(TAG, "No current app user ID set, cannot load last measurement.") + } + } catch (e: Exception) { + LogManager.e(TAG, "Error providing user data to legacy driver ${bluetoothDriverInstance.driverName()}", e) + _eventsFlow.tryEmit(BluetoothEvent.DeviceMessage("Fehler beim Laden der Benutzerdaten für ${bluetoothDriverInstance.driverName()}", + bluetoothDriverInstance.scaleMacAddress.toString() + )) + bluetoothDriverInstance.setCachedLastMeasurementForSelectedUser(null) + LogManager.d(TAG, "No current app user ID set, cannot load last measurement.") + } + } + /** * Handles messages received from the legacy [BluetoothCommunication] driver. * It translates these messages into [BluetoothEvent]s and updates the adapter's state. @@ -182,31 +261,29 @@ class LegacyScaleAdapter( adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(fallbackMessage, deviceIdentifier)) } } - BluetoothCommunication.BT_STATUS.CHOOSE_SCALE_USER -> { - LogManager.d(TAG, "CHOOSE_SCALE_USER for $deviceIdentifier: Data: $eventData") - var userListDescription = applicationContext.getString(R.string.legacy_adapter_event_user_selection_required) - if (eventData is List<*>) { - val stringList = eventData.mapNotNull { item -> - if (item is ScaleUser) { - applicationContext.getString(R.string.legacy_adapter_event_user_details, item.id, item.age, item.bodyHeight) - } else { - item.toString() - } - } - if (stringList.isNotEmpty()) { - userListDescription = stringList.joinToString(separator = "\n") - } - } else if (eventData != null) { - userListDescription = eventData.toString() + BluetoothCommunication.BT_STATUS.USER_INTERACTION_REQUIRED -> { + val rawPayload = msg.obj + if (rawPayload is Array<*> && rawPayload.size == 2 && rawPayload[0] is UserInteractionType) { + val interactionType = rawPayload[0] as UserInteractionType + val eventDataForUi = rawPayload[1] + + LogManager.d(TAG, "USER_INTERACTION_REQUIRED ($interactionType) for $deviceIdentifier. Forwarding data: $eventDataForUi") + + adapter._eventsFlow.tryEmit( + BluetoothEvent.UserInteractionRequired( + deviceIdentifier = deviceIdentifier, + data = eventDataForUi, + interactionType = interactionType + ) + ) + + } else { + LogManager.w(TAG, "Received USER_INTERACTION_REQUIRED with invalid or incomplete payload structure: $rawPayload") + adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage( + applicationContext.getString(R.string.legacy_adapter_event_invalid_interaction_payload), + deviceIdentifier + )) } - adapter._eventsFlow.tryEmit(BluetoothEvent.UserSelectionRequired(userListDescription, deviceIdentifier, eventData)) - } - BluetoothCommunication.BT_STATUS.ENTER_SCALE_USER_CONSENT -> { - val appScaleUserId = arg1 - val scaleUserIndex = arg2 - LogManager.d(TAG, "ENTER_SCALE_USER_CONSENT for $deviceIdentifier: AppUserID: $appScaleUserId, ScaleUserIndex: $scaleUserIndex. Data: $eventData") - val message = applicationContext.getString(R.string.legacy_adapter_event_user_consent_required, appScaleUserId, scaleUserIndex) - adapter._eventsFlow.tryEmit(BluetoothEvent.DeviceMessage(message, deviceIdentifier)) } else -> { LogManager.w(TAG, "Unknown BT_STATUS ($status) or message (what=${msg.what}) from driver ${adapter.bluetoothDriverInstance.driverName()} received.") @@ -219,7 +296,7 @@ class LegacyScaleAdapter( } } - override fun connect(address: String, scaleUser: ScaleUser?, appUserId: Int?) { + override fun connect(address: String, scaleUser: ScaleUser?) { adapterScope.launch { val currentDeviceName = currentTargetAddress ?: bluetoothDriverInstance.driverName() if (_isConnected.value || _isConnecting.value) { @@ -238,16 +315,17 @@ class LegacyScaleAdapter( } } - LogManager.i(TAG, "connect: REQUEST for address $address to driver ${bluetoothDriverInstance.driverName()}, UI ScaleUser ID: ${scaleUser?.id}, AppUserID: $appUserId") + LogManager.i(TAG, "connect: REQUEST for address $address to driver ${bluetoothDriverInstance.driverName()}") _isConnecting.value = true _isConnected.value = false currentTargetAddress = address // Store the address being connected to currentInternalUser = scaleUser - LogManager.d(TAG, "connect: Internal user for connection: ${currentInternalUser?.id}, AppUserID: $appUserId") + LogManager.d(TAG, "connect: Internal user for connection: ${currentInternalUser?.id}") + + provideUserDataToLegacyDriver() currentInternalUser?.let { bluetoothDriverInstance.setSelectedScaleUser(it) } - appUserId?.let { bluetoothDriverInstance.setSelectedScaleUserId(it) } LogManager.d(TAG, "connect: Calling connect() on Java driver instance (${bluetoothDriverInstance.driverName()}) for $address.") try { @@ -330,34 +408,6 @@ class LegacyScaleAdapter( LogManager.i(TAG, "release: AdapterScope for $deviceName cancelled.") } - /** - * Informs the legacy driver about the user's selection for a scale user. - * This is typically called in response to a [BluetoothEvent.UserSelectionRequired] event. - * - * @param appUserId The application-specific user ID. - * @param scaleUserIndex The index of the user on the scale. - */ - fun selectLegacyScaleUserIndex(appUserId: Int, scaleUserIndex: Int) { - adapterScope.launch { - LogManager.i(TAG, "selectLegacyScaleUserIndex for ${bluetoothDriverInstance.driverName()}: AppUserID: $appUserId, ScaleUserIndex: $scaleUserIndex") - bluetoothDriverInstance.selectScaleUserIndexForAppUserId(appUserId, scaleUserIndex, driverEventHandler) - } - } - - /** - * Sends the user's consent value to the legacy scale driver. - * This is typically called after the scale requests user consent. - * - * @param appUserId The application-specific user ID. - * @param consentValue The consent value (specific to the driver's protocol). - */ - fun setLegacyScaleUserConsent(appUserId: Int, consentValue: Int) { - adapterScope.launch { - LogManager.i(TAG, "setLegacyScaleUserConsent for ${bluetoothDriverInstance.driverName()}: AppUserID: $appUserId, ConsentValue: $consentValue") - bluetoothDriverInstance.setScaleUserConsent(appUserId, consentValue, driverEventHandler) - } - } - /** * Retrieves the name of the managed Bluetooth driver/device. * Can be used externally if the name is needed and only a reference to the adapter is available. @@ -376,4 +426,13 @@ class LegacyScaleAdapter( override fun getEventsFlow(): SharedFlow { return events } + + override fun processUserInteractionFeedback( + interactionType: UserInteractionType, + appUserId: Int, + feedbackData: Any, + uiHandler: Handler + ) { + bluetoothDriverInstance.processUserInteractionFeedback(interactionType, appUserId, feedbackData, uiHandler) + } } diff --git a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt index 6a4f2fa5..c41f6927 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/navigation/AppNavigation.kt @@ -34,14 +34,23 @@ import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.BluetoothSearching import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.BluetoothSearching import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.HowToReg import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog import androidx.compose.material3.DrawerValue import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -55,22 +64,28 @@ import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItemDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -79,6 +94,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavType @@ -89,6 +105,8 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.health.openscale.BuildConfig import com.health.openscale.R +import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType +import com.health.openscale.core.bluetooth.scalesJava.BluetoothCommunication import com.health.openscale.core.data.User import com.health.openscale.ui.navigation.Routes.getIconForRoute import com.health.openscale.ui.screen.SharedViewModel @@ -219,6 +237,167 @@ fun AppNavigation(sharedViewModel: SharedViewModel) { sharedViewModel.setTopBarAction(null) } + val pendingInteractionEvent by bluetoothViewModel.pendingUserInteractionEvent.collectAsState() + + pendingInteractionEvent?.let { interactionEvent -> + val dialogTitle: String + val dialogIcon: @Composable (() -> Unit) + + var consentCodeInput by rememberSaveable { mutableStateOf("") } + var selectedUserIndexState by rememberSaveable { mutableIntStateOf(Int.MIN_VALUE) } + + when (interactionEvent.interactionType) { + UserInteractionType.CHOOSE_USER -> { + dialogTitle = stringResource(R.string.dialog_bt_interaction_title_choose_user) + dialogIcon = { Icon(Icons.Filled.People, contentDescription = stringResource(R.string.dialog_bt_icon_desc_choose_user)) } + // Reset state when dialog becomes visible for CHOOSE_USER + if (interactionEvent.interactionType == UserInteractionType.CHOOSE_USER) { + selectedUserIndexState = Int.MIN_VALUE + } + } + UserInteractionType.ENTER_CONSENT -> { + dialogTitle = stringResource(R.string.dialog_bt_interaction_title_enter_consent) + dialogIcon = { Icon(Icons.Filled.HowToReg, contentDescription = stringResource(R.string.dialog_bt_icon_desc_enter_consent)) } + // Reset state when dialog becomes visible for ENTER_CONSENT + if (interactionEvent.interactionType == UserInteractionType.ENTER_CONSENT) { + consentCodeInput = "" + } + } + // else -> { /* Handle unknown types or provide defaults */ } // Optional + } + + val isConsentInputValid = remember(consentCodeInput) { // Recalculate only when consentCodeInput changes + consentCodeInput.isNotEmpty() && consentCodeInput.all { it.isDigit() } + } + + AlertDialog( + onDismissRequest = { + if (interactionEvent.interactionType == UserInteractionType.ENTER_CONSENT) { + bluetoothViewModel.processUserInteraction(interactionEvent.interactionType, -1) // -1 abort signal + } + bluetoothViewModel.clearPendingUserInteraction() + }, + icon = dialogIcon, + title = { Text(text = dialogTitle) }, + text = { + Column { + Text( + text = stringResource( + if (interactionEvent.interactionType == UserInteractionType.CHOOSE_USER) + R.string.dialog_bt_interaction_desc_choose_user_default + else + R.string.dialog_bt_interaction_desc_enter_consent_default + ) + ) + Spacer(modifier = Modifier.height(16.dp)) + + when (interactionEvent.interactionType) { + UserInteractionType.CHOOSE_USER -> { + val choicesData = interactionEvent.data + // Wir erwarten Pair, IntArray> oder Pair, IntArray> + if (choicesData is Pair<*, *> && choicesData.first is Array<*> && choicesData.second is IntArray) { + @Suppress("UNCHECKED_CAST") + val choices = choicesData as Pair, IntArray> + val choiceDisplayNames = choices.first + val choiceIndices = choices.second + + if (choiceDisplayNames.isNotEmpty() && choiceDisplayNames.size == choiceIndices.size) { + LazyColumn(modifier = Modifier.padding(top = 8.dp)) { + itemsIndexed(choiceDisplayNames) { itemIndex, choiceName -> + Row( + Modifier + .fillMaxWidth() + .selectable( + selected = (choiceIndices[itemIndex] == selectedUserIndexState), + onClick = { selectedUserIndexState = choiceIndices[itemIndex] } + ) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = (choiceIndices[itemIndex] == selectedUserIndexState), + onClick = { selectedUserIndexState = choiceIndices[itemIndex] } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = choiceName.toString()) + } + } + } + } else { + Text(stringResource(R.string.dialog_bt_error_loading_user_list_empty)) + } + } else { + Text(stringResource(R.string.dialog_bt_error_loading_user_list_format)) + } + } + UserInteractionType.ENTER_CONSENT -> { + OutlinedTextField( + value = consentCodeInput, + onValueChange = { consentCodeInput = it.filter { char -> char.isDigit() } }, + label = { Text(stringResource(R.string.dialog_bt_label_consent_code)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + // else -> { /* Handle unknown types or provide defaults */ } // Optional + } + } + }, + confirmButton = { + TextButton( + onClick = { + val interactionType = interactionEvent.interactionType + var feedbackData: Any? = null // Any, da der Typ variieren kann + + when (interactionType) { + UserInteractionType.CHOOSE_USER -> { + if (selectedUserIndexState != Int.MIN_VALUE) { + feedbackData = selectedUserIndexState // Ist ein Int + } else { + sharedViewModel.showSnackbar(R.string.dialog_bt_select_user_prompt) + } + } + UserInteractionType.ENTER_CONSENT -> { + if (isConsentInputValid) { + feedbackData = consentCodeInput.toInt() + } else { + sharedViewModel.showSnackbar(R.string.dialog_bt_enter_valid_code_prompt) + } + } + // else -> { /* Handle unknown types or provide defaults */ } // Optional + } + + if (feedbackData != null) { + bluetoothViewModel.processUserInteraction(interactionType, feedbackData) + } + }, + enabled = when (interactionEvent.interactionType) { + UserInteractionType.CHOOSE_USER -> selectedUserIndexState != Int.MIN_VALUE + UserInteractionType.ENTER_CONSENT -> isConsentInputValid + } + ) { + Text(stringResource(R.string.confirm_button)) + } + }, + dismissButton = { + TextButton( + onClick = { + if (interactionEvent.interactionType == UserInteractionType.ENTER_CONSENT) { + bluetoothViewModel.processUserInteraction(interactionEvent.interactionType, -1) // -1 for abort signal + } else { + bluetoothViewModel.clearPendingUserInteraction() + } + } + ) { + Text(stringResource(R.string.cancel_button)) + } + } + ) + } + + + ModalNavigationDrawer( drawerState = drawerState, drawerContent = { diff --git a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothConnectionManager.kt b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothConnectionManager.kt index a434a0f2..8388d517 100644 --- a/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothConnectionManager.kt +++ b/android_app/app/src/main/java/com/health/openscale/ui/screen/bluetooth/BluetoothConnectionManager.kt @@ -19,12 +19,15 @@ package com.health.openscale.ui.screen.bluetooth import android.annotation.SuppressLint import android.content.Context +import android.os.Handler import androidx.compose.material3.SnackbarDuration import com.health.openscale.core.bluetooth.BluetoothEvent import com.health.openscale.core.bluetooth.ScaleCommunicator import com.health.openscale.core.bluetooth.ScaleFactory +import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType import com.health.openscale.core.bluetooth.data.ScaleMeasurement import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.scalesJava.BluetoothCommunication import com.health.openscale.core.data.Measurement import com.health.openscale.core.data.MeasurementTypeKey import com.health.openscale.core.data.MeasurementValue @@ -68,8 +71,6 @@ class BluetoothConnectionManager( private val databaseRepository: DatabaseRepository, private val sharedViewModel: SharedViewModel, private val getCurrentScaleUser: () -> ScaleUser?, - private val getCurrentAppUserId: () -> Int, - private val onUserSelectionRequired: (BluetoothEvent.UserSelectionRequired) -> Unit, private val onSavePreferredDevice: suspend (address: String, name: String) -> Unit ) : AutoCloseable { @@ -94,13 +95,8 @@ class BluetoothConnectionManager( /** Emits an error message if a connection or operational error occurs, null otherwise. */ val connectionError: StateFlow = _connectionError.asStateFlow() - private val _showUserSelectionDialog = MutableStateFlow(null) - /** - * Emits a [BluetoothEvent.UserSelectionRequired] event when the connected scale requires - * user interaction (e.g., selecting a user profile on the scale). - * The UI should observe this and display an appropriate dialog. - */ - val showUserSelectionDialog: StateFlow = _showUserSelectionDialog.asStateFlow() + private val _userInteractionRequiredEvent = MutableStateFlow(null) + val userInteractionRequiredEvent: StateFlow = _userInteractionRequiredEvent.asStateFlow() private var activeCommunicator: ScaleCommunicator? = null private var communicatorJob: Job? = null // Job for observing events from the activeCommunicator. @@ -121,16 +117,11 @@ class BluetoothConnectionManager( val deviceDisplayName = deviceInfo.name ?: deviceInfo.address LogManager.i(TAG, "Attempting to connect to $deviceDisplayName") - // Basic validation logic (adapted from ViewModel). - // Permissions and Bluetooth status should be checked BEFORE calling this method - // in the ViewModel, as the manager cannot display UI for it. - // Here only a fundamental check. - val currentAppUserId = getCurrentAppUserId() // Some legacy or specific openScale handlers might require a valid user. val needsUserCheck = deviceInfo.determinedHandlerDisplayName?.contains("legacy", ignoreCase = true) == true || deviceInfo.determinedHandlerDisplayName?.startsWith("com.health.openscale") == true - if (needsUserCheck && currentAppUserId == 0) { + if (needsUserCheck && getCurrentScaleUser()?.id == 0) { LogManager.e(TAG, "User ID is 0, which might be problematic for handler '${deviceInfo.determinedHandlerDisplayName}'. Connection ABORTED.") _connectionError.value = "No user selected. Connection to $deviceDisplayName not possible." _connectionStatus.value = ConnectionStatus.FAILED @@ -165,7 +156,7 @@ class BluetoothConnectionManager( LogManager.i(TAG, "ActiveCommunicator successfully created: ${activeCommunicator!!.javaClass.simpleName}. Starting observation job...") observeActiveCommunicatorEvents(deviceInfo) - activeCommunicator?.connect(deviceInfo.address, getCurrentScaleUser(), currentAppUserId) + activeCommunicator?.connect(deviceInfo.address, getCurrentScaleUser()) } } @@ -293,18 +284,37 @@ class BluetoothConnectionManager( // Consider setting status to FAILED if it's a critical error // that impacts/loses the connection. } - is BluetoothEvent.UserSelectionRequired -> { - LogManager.i(TAG, "Event: UserSelectionRequired for ${event.deviceIdentifier}. Description: ${event.description}.") - _showUserSelectionDialog.value = event // For the ViewModel to observe and show a dialog. - onUserSelectionRequired(event) // Direct callback to ViewModel if it needs to react immediately. - sharedViewModel.showSnackbar( - "Action required on $deviceDisplayName: ${event.description.take(50)}...", - SnackbarDuration.Long - ) + + is BluetoothEvent.UserInteractionRequired -> { + val actualDeviceIdentifier = event.deviceIdentifier + LogManager.i(TAG, "Event: UserInteractionRequired (${event.interactionType}) for $actualDeviceIdentifier. Data: ${event.data}") + _userInteractionRequiredEvent.value = event } } } + /** + * Forwards the user's feedback from an interaction to the active [ScaleCommunicator]. + * + * @param interactionType The type of interaction this feedback corresponds to. + * @param appUserId The ID of the current application user. + * @param feedbackData Data provided by the user. + * @param uiHandler A [Handler] required by the underlying communicator. + */ + fun provideUserInteractionFeedback( + interactionType: UserInteractionType, + appUserId: Int, + feedbackData: Any, + uiHandler: Handler + ) { + scope.launch { + activeCommunicator?.let { comm -> + LogManager.d(TAG, "Forwarding user interaction feedback to communicator: type=$interactionType, appUserId=$appUserId") + comm.processUserInteractionFeedback(interactionType, appUserId, feedbackData, uiHandler) + } ?: LogManager.w(TAG, "provideUserInteractionFeedback called but no active communicator.") + } + } + /** * Saves a [ScaleMeasurement] received from a device to the database. * This involves creating a [Measurement] entity and associated [MeasurementValue]s. @@ -314,7 +324,7 @@ class BluetoothConnectionManager( * @param deviceName The name of the device. */ private suspend fun saveMeasurementFromEvent(measurementData: ScaleMeasurement, deviceAddress: String, deviceName: String) { - val currentAppUserId = getCurrentAppUserId() + val currentAppUserId = getCurrentScaleUser()?.id if (currentAppUserId == 0) { LogManager.e(TAG, "($deviceName): No App User ID to save measurement.") sharedViewModel.showSnackbar("Measurement from $deviceName cannot be assigned to a user.", SnackbarDuration.Long) @@ -326,7 +336,7 @@ class BluetoothConnectionManager( // potentially be moved entirely into a dedicated MeasurementRepository or similar service. scope.launch(Dispatchers.IO) { // Perform database operations on IO dispatcher. val newDbMeasurement = Measurement( - userId = currentAppUserId, + userId = currentAppUserId ?: 0, timestamp = measurementData.dateTime?.time ?: System.currentTimeMillis() ) @@ -488,6 +498,12 @@ class BluetoothConnectionManager( } } + fun clearUserInteractionEvent() { + if (_userInteractionRequiredEvent.value != null) { + _userInteractionRequiredEvent.value = null + } + } + /** * Cleans up resources when the BluetoothConnectionManager is no longer needed. * This typically involves disconnecting any active connection and releasing the communicator. 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 dd4fbe84..ead04e88 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 @@ -24,6 +24,8 @@ import android.bluetooth.BluetoothManager import android.content.Context import android.content.pm.PackageManager import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.compose.material3.SnackbarDuration // Keep if used directly, otherwise remove import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel @@ -31,8 +33,10 @@ import androidx.lifecycle.viewModelScope import com.health.openscale.core.bluetooth.BluetoothEvent // ScaleCommunicator no longer needed directly here import com.health.openscale.core.bluetooth.ScaleFactory +import com.health.openscale.core.bluetooth.BluetoothEvent.UserInteractionType // ScaleMeasurement no longer needed directly here for saveMeasurementFromEvent import com.health.openscale.core.bluetooth.data.ScaleUser +import com.health.openscale.core.bluetooth.scalesJava.BluetoothCommunication // Measurement, MeasurementTypeKey, MeasurementValue no longer needed directly here import com.health.openscale.core.data.User import com.health.openscale.core.utils.LogManager @@ -116,11 +120,6 @@ class BluetoothViewModel( databaseRepository = databaseRepository, sharedViewModel = sharedViewModel, getCurrentScaleUser = { currentBtScaleUser }, - getCurrentAppUserId = { currentAppUserId }, - onUserSelectionRequired = { event -> - // Update internal state when ConnectionManager requires user selection. - _showUserSelectionDialogFromManager.value = event - }, onSavePreferredDevice = { address, name -> // Save preferred device when ConnectionManager successfully connects and indicates to do so. // Snackbar for user feedback can be shown here or in ConnectionManager; here is fine. @@ -162,13 +161,8 @@ class BluetoothViewModel( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), null) // --- UI Interaction for User Selection (triggered by ConnectionManager callback) --- - private val _showUserSelectionDialogFromManager = MutableStateFlow(null) - /** - * Emits a [BluetoothEvent.UserSelectionRequired] when the connected scale needs user interaction - * (e.g., selecting a user profile on the scale). The UI should observe this to show a dialog. - * Emits null when the dialog should be dismissed or is not needed. - */ - val showUserSelectionDialog: StateFlow = _showUserSelectionDialogFromManager.asStateFlow() + val pendingUserInteractionEvent: StateFlow = + bluetoothConnectionManager.userInteractionRequiredEvent init { LogManager.i(TAG, "ViewModel initialized. Setting up user observation.") @@ -446,17 +440,34 @@ class BluetoothViewModel( bluetoothConnectionManager.clearConnectionError() } - /** - * Clears the user selection dialog state. This should be called by the UI - * after the user has made a selection or dismissed the dialog. - */ - fun clearUserSelectionDialog() { - LogManager.d(TAG, "Clearing user selection dialog.") - _showUserSelectionDialogFromManager.value = null - // If BluetoothConnectionManager held its own state for this event (beyond the callback), - // a method like `bluetoothConnectionManager.userSelectionActionCompleted()` might be called here. + fun clearPendingUserInteraction() { + LogManager.d(TAG, "Requesting to clear pending user interaction event.") + bluetoothConnectionManager.clearUserInteractionEvent() } + fun processUserInteraction(interactionType: UserInteractionType, feedbackData: Any) { + viewModelScope.launch { + val currentAppUser = sharedViewModel.selectedUser.value + if (currentAppUser == null || currentAppUser.id == 0) { + sharedViewModel.showSnackbar("Fehler: Kein App-Benutzer ausgewählt.") + bluetoothConnectionManager.clearUserInteractionEvent() + return@launch + } + val appUserId = currentAppUser.id + + clearPendingUserInteraction() + val uiHandler = Handler(Looper.getMainLooper()) + + bluetoothConnectionManager.provideUserInteractionFeedback( + interactionType, + appUserId, + feedbackData, + uiHandler + ) + + sharedViewModel.showSnackbar("Benutzereingabe verarbeitet.", SnackbarDuration.Short) + } + } // --- Device Preferences --- 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 2e6058f5..40eaae12 100644 --- a/android_app/app/src/main/res/values-de/strings.xml +++ b/android_app/app/src/main/res/values-de/strings.xml @@ -8,6 +8,7 @@ Aktivieren Abbrechen + Bestätigen OK Link öffnen Warnsymbol @@ -217,6 +218,10 @@ Fehler beim Starten des Verbindungsprozesses zu %1$s: %2$s Nicht verbunden für Messanforderung. Messung sollte automatisch starten (Legacy-Treiber). + Fehler: Fehlende Benutzerdaten für Auswahl. + Fehler: Ungültige Daten für Zustimmung empfangen. + Fehler: Fehlende Daten für Zustimmung erforderlich. + Fehler: Ungültige Daten-Nutzlast für Benutzerinteraktion empfangen. Zeitraumsymbol @@ -375,4 +380,19 @@ Auslesen der openScale Daten, inkl. Benutzerinformationen und aller gespeicherten Messungen Lese- und Schreibzugriff auf openScale Daten + + Benutzer auf Waage auswählen + Wählen Sie Ihren Benutzer aus der Liste oder erstellen Sie einen neuen. + Benutzerauswahl + + Bestätigungscode eingeben + Bitte geben Sie den auf Ihrer Waage angezeigten Code ein. + Code-Eingabe + + Bestätigungscode + + Benutzerliste ist leer oder fehlerhaft. + Fehler: Benutzerdaten konnten nicht korrekt geladen werden. + Bitte einen Benutzer auswählen. + Bitte einen gültigen Code eingeben. diff --git a/android_app/app/src/main/res/values/strings.xml b/android_app/app/src/main/res/values/strings.xml index 4f623ed4..43d36626 100644 --- a/android_app/app/src/main/res/values/strings.xml +++ b/android_app/app/src/main/res/values/strings.xml @@ -9,6 +9,7 @@ Enable Cancel + Confirm OK Open link Warning Icon @@ -218,6 +219,10 @@ Error starting connection process to %1$s: %2$s Not connected for measurement request. Measurement should start automatically (Legacy driver). + Error: Missing user data for selection. + Error: Invalid data received for consent. + Error: Missing data required for consent. + Error: Invalid data payload received for user interaction. Time range icon @@ -377,4 +382,16 @@ read/write openScale data, including user information and all saved measurements Read and Write openScale data + Select User on Scale + Select your user from the list or create a new one. + User Selection + Enter Confirmation Code + Please enter the code displayed on your scale. + Code Entry + Confirmation Code + User list is empty or corrupted. + Error: User data could not be loaded correctly. + Please select a user. + Please enter a valid code. +