1
0
mirror of https://github.com/oliexdev/openScale.git synced 2025-08-31 20:11:58 +02:00

Implemented Bluetooth user interaction handling

This commit is contained in:
oliexdev
2025-08-09 21:01:13 +02:00
parent 8b77750ff0
commit 54b168d879
18 changed files with 560 additions and 290 deletions

View File

@@ -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"

View File

@@ -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<BluetoothEvent>
/**
* Processes feedback received from the user for a previously requested interaction.
*/
fun processUserInteractionFeedback(
interactionType: BluetoothEvent.UserInteractionType,
appUserId: Int,
feedbackData: Any,
uiHandler: Handler
)
}

View File

@@ -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<String, Any>?
): Boolean {
// Dummy implementation
println("DummyScaleHandler: provideUserConsent called for $consentType, consented: $consented")
return true // Or false
}
override suspend fun provideUserAttributes(
attributes: Map<String, Any>,
scaleUserIdentifier: Any?
): Boolean {
// Dummy implementation
println("DummyScaleHandler: provideUserAttributes called with $attributes")
return true // Or false
}
}

View File

@@ -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<BluetoothEvent>(replay = 1, extraBufferCapacity = 5)
override fun getEventsFlow(): SharedFlow<BluetoothEvent> = _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<Boolean> = _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()

View File

@@ -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<ScaleUserListItem>, 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<String, Any>? = 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<String>, // 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<String, Any>? = 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<String, Any>, 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

View File

@@ -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<ScaleUser> 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<ScaleUser> getScaleUserList() { return null; } // TODO Not implemented
public void setSelectedScaleUserId(int userId) {
selectedScaleUserId = userId;
}
public int getSelectedScaleUserId() {
return selectedScaleUserId;
public void setScaleUserList(List<ScaleUser> userList) {
cachedAppUserList = userList;
}
public void updateScaleUser(ScaleUser user) {} // TODO Not implemented
public List<ScaleUser> 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];

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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<CharSequence[], int[]> choices = new Pair(choiceStrings, indexArray);
chooseScaleUserUi(choices);
requestUserInteraction(UserInteractionType.CHOOSE_USER, choices);
}
protected String getInitials(String fullName) {

View File

@@ -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;
}

View File

@@ -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<BluetoothEvent> {
return events
}
override fun processUserInteractionFeedback(
interactionType: UserInteractionType,
appUserId: Int,
feedbackData: Any,
uiHandler: Handler
) {
bluetoothDriverInstance.processUserInteractionFeedback(interactionType, appUserId, feedbackData, uiHandler)
}
}

View File

@@ -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<Array<String>, IntArray> oder Pair<Array<CharSequence>, IntArray>
if (choicesData is Pair<*, *> && choicesData.first is Array<*> && choicesData.second is IntArray) {
@Suppress("UNCHECKED_CAST")
val choices = choicesData as Pair<Array<CharSequence>, 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 = {

View File

@@ -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<String?> = _connectionError.asStateFlow()
private val _showUserSelectionDialog = MutableStateFlow<BluetoothEvent.UserSelectionRequired?>(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<BluetoothEvent.UserSelectionRequired?> = _showUserSelectionDialog.asStateFlow()
private val _userInteractionRequiredEvent = MutableStateFlow<BluetoothEvent.UserInteractionRequired?>(null)
val userInteractionRequiredEvent: StateFlow<BluetoothEvent.UserInteractionRequired?> = _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.

View File

@@ -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<BluetoothEvent.UserSelectionRequired?>(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<BluetoothEvent.UserSelectionRequired?> = _showUserSelectionDialogFromManager.asStateFlow()
val pendingUserInteractionEvent: StateFlow<BluetoothEvent.UserInteractionRequired?> =
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 ---

View File

@@ -8,6 +8,7 @@
<!-- Generische UI-Elemente & Aktionen -->
<string name="enable_button">Aktivieren</string>
<string name="cancel_button">Abbrechen</string>
<string name="confirm_button">Bestätigen</string>
<string name="dialog_ok">OK</string>
<string name="open_link_content_description">Link öffnen</string>
<string name="content_desc_warning_icon">Warnsymbol</string>
@@ -217,6 +218,10 @@
<string name="legacy_adapter_connect_exception">Fehler beim Starten des Verbindungsprozesses zu %1$s: %2$s</string>
<string name="legacy_adapter_request_measurement_not_connected">Nicht verbunden für Messanforderung.</string>
<string name="legacy_adapter_request_measurement_auto">Messung sollte automatisch starten (Legacy-Treiber).</string>
<string name="legacy_adapter_event_missing_user_data">Fehler: Fehlende Benutzerdaten für Auswahl.</string>
<string name="legacy_adapter_event_invalid_consent_data">Fehler: Ungültige Daten für Zustimmung empfangen.</string>
<string name="legacy_adapter_event_missing_consent_data">Fehler: Fehlende Daten für Zustimmung erforderlich.</string>
<string name="legacy_adapter_event_invalid_interaction_payload">Fehler: Ungültige Daten-Nutzlast für Benutzerinteraktion empfangen.</string>
<!-- Diagramme (LineChart) -->
<string name="content_description_time_range_icon">Zeitraumsymbol</string>
@@ -375,4 +380,19 @@
<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>
<string name="dialog_bt_interaction_title_choose_user">Benutzer auf Waage auswählen</string>
<string name="dialog_bt_interaction_desc_choose_user_default">Wählen Sie Ihren Benutzer aus der Liste oder erstellen Sie einen neuen.</string>
<string name="dialog_bt_icon_desc_choose_user">Benutzerauswahl</string>
<string name="dialog_bt_interaction_title_enter_consent">Bestätigungscode eingeben</string>
<string name="dialog_bt_interaction_desc_enter_consent_default">Bitte geben Sie den auf Ihrer Waage angezeigten Code ein.</string>
<string name="dialog_bt_icon_desc_enter_consent">Code-Eingabe</string>
<string name="dialog_bt_label_consent_code">Bestätigungscode</string>
<string name="dialog_bt_error_loading_user_list_empty">Benutzerliste ist leer oder fehlerhaft.</string>
<string name="dialog_bt_error_loading_user_list_format">Fehler: Benutzerdaten konnten nicht korrekt geladen werden.</string>
<string name="dialog_bt_select_user_prompt">Bitte einen Benutzer auswählen.</string>
<string name="dialog_bt_enter_valid_code_prompt">Bitte einen gültigen Code eingeben.</string>
</resources>

View File

@@ -9,6 +9,7 @@
<!-- Generic UI Elements & Actions -->
<string name="enable_button">Enable</string>
<string name="cancel_button">Cancel</string>
<string name="confirm_button">Confirm</string>
<string name="dialog_ok">OK</string>
<string name="open_link_content_description">Open link</string>
<string name="content_desc_warning_icon">Warning Icon</string>
@@ -218,6 +219,10 @@
<string name="legacy_adapter_connect_exception">Error starting connection process to %1$s: %2$s</string>
<string name="legacy_adapter_request_measurement_not_connected">Not connected for measurement request.</string>
<string name="legacy_adapter_request_measurement_auto">Measurement should start automatically (Legacy driver).</string>
<string name="legacy_adapter_event_missing_user_data">Error: Missing user data for selection.</string>
<string name="legacy_adapter_event_invalid_consent_data">Error: Invalid data received for consent.</string>
<string name="legacy_adapter_event_missing_consent_data">Error: Missing data required for consent.</string>
<string name="legacy_adapter_event_invalid_interaction_payload">Error: Invalid data payload received for user interaction.</string>
<!-- Charts (LineChart) -->
<string name="content_description_time_range_icon">Time range icon</string>
@@ -377,4 +382,16 @@
<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>
<string name="dialog_bt_interaction_title_choose_user">Select User on Scale</string>
<string name="dialog_bt_interaction_desc_choose_user_default">Select your user from the list or create a new one.</string>
<string name="dialog_bt_icon_desc_choose_user">User Selection</string>
<string name="dialog_bt_interaction_title_enter_consent">Enter Confirmation Code</string>
<string name="dialog_bt_interaction_desc_enter_consent_default">Please enter the code displayed on your scale.</string>
<string name="dialog_bt_icon_desc_enter_consent">Code Entry</string>
<string name="dialog_bt_label_consent_code">Confirmation Code</string>
<string name="dialog_bt_error_loading_user_list_empty">User list is empty or corrupted.</string>
<string name="dialog_bt_error_loading_user_list_format">Error: User data could not be loaded correctly.</string>
<string name="dialog_bt_select_user_prompt">Please select a user.</string>
<string name="dialog_bt_enter_valid_code_prompt">Please enter a valid code.</string>
</resources>