diff --git a/.idea/dictionaries/t.xml b/.idea/dictionaries/t.xml
index 27365b575a..edd0e1afa7 100644
--- a/.idea/dictionaries/t.xml
+++ b/.idea/dictionaries/t.xml
@@ -57,6 +57,7 @@
gfdi
gideão
girolamo
+ gloryfit
gobbetti
gree
greenberg
@@ -121,6 +122,7 @@
normano
novotny
oraclejdk
+ oukitel
paddleboarding
padel
pebblekit
diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
index 5cfd08381e..bbd48b132f 100644
--- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
+++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java
@@ -58,7 +58,7 @@ public class GBDaoGenerator {
public static void main(String[] args) throws Exception {
- final Schema schema = new Schema(104, MAIN_PACKAGE + ".entities");
+ final Schema schema = new Schema(106, MAIN_PACKAGE + ".entities");
Entity userAttributes = addUserAttributes(schema);
Entity user = addUserInfo(schema, userAttributes);
@@ -166,6 +166,7 @@ public class GBDaoGenerator {
addMoyoungBloodPressureSample(schema, user, device);
addMoyoungSleepStageSample(schema, user, device);
addMoyoungStressSample(schema, user, device);
+ addGloryFitStepsSample(schema, user, device);
addHuaweiActivitySample(schema, user, device);
addHuaweiStressSample(schema, user, device);
@@ -204,6 +205,7 @@ public class GBDaoGenerator {
addGenericStressSample(schema, user, device);
addGenericHrvValueSample(schema, user, device);
addGenericTemperatureSample(schema, user, device);
+ addGenericSleepStageSample(schema, user, device);
new DaoGenerator().generateAll(schema, "app/src/main/java");
}
@@ -1155,6 +1157,19 @@ public class GBDaoGenerator {
return stressSample;
}
+ private static Entity addGloryFitStepsSample(Schema schema, Entity user, Entity device) {
+ Entity sleepStageSample = addEntity(schema, "GloryFitStepsSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
+ sleepStageSample.addIntProperty("totalSteps").notNull();
+ sleepStageSample.addIntProperty("runningStart").notNull();
+ sleepStageSample.addIntProperty("runningEnd").notNull();
+ sleepStageSample.addIntProperty("runningSteps").notNull();
+ sleepStageSample.addIntProperty("walkingStart").notNull();
+ sleepStageSample.addIntProperty("walkingEnd").notNull();
+ sleepStageSample.addIntProperty("walkingSteps").notNull();
+ return sleepStageSample;
+ }
+
private static void addCommonActivitySampleProperties(String superClass, Entity activitySample, Entity user, Entity device) {
activitySample.setSuperclass(superClass);
activitySample.addImport(MAIN_PACKAGE + ".devices.SampleProvider");
@@ -1874,4 +1889,12 @@ public class GBDaoGenerator {
temperatureSample.addIntProperty(SAMPLE_TEMPERATURE_TYPE).notNull();
return temperatureSample;
}
+
+ private static Entity addGenericSleepStageSample(Schema schema, Entity user, Entity device) {
+ Entity sleepStageSample = addEntity(schema, "GenericSleepStageSample");
+ addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device);
+ sleepStageSample.addIntProperty("duration").notNull();
+ sleepStageSample.addIntProperty("stage").notNull();
+ return sleepStageSample;
+ }
}
diff --git a/app/build.gradle b/app/build.gradle
index 8ad014f36b..f400b97008 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -300,6 +300,7 @@ tasks.register('cleanGenerated', Delete) {
delete fileTree('src/main/java/nodomain/freeyourgadget/gadgetbridge/entities') {
include '**/*.java'
exclude '**/Abstract*.java'
+ exclude '**/GenericActivitySample.java'
}
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CameraActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CameraActivity.java
index a481dc3bfd..063b5702e6 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CameraActivity.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/CameraActivity.java
@@ -158,7 +158,7 @@ public class CameraActivity extends AppCompatActivity {
GBDeviceEventCameraRemote.Event event = GBDeviceEventCameraRemote.intToEvent(intent.getIntExtra(intentExtraEvent, 0));
- LOG.info("Camera received event: " + event.name());
+ LOG.info("Camera received event: {}", event);
// Nothing to do for unknown events
@@ -176,6 +176,10 @@ public class CameraActivity extends AppCompatActivity {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
).build();
+ if (imageCapture == null) {
+ LOG.error("imageCapture is null");
+ return;
+ }
imageCapture.takePicture(outputFileOptions, ContextCompat.getMainExecutor(this), new ImageCapture.OnImageSavedCallback() {
@Override
public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ContactDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ContactDetails.java
index df13be65d1..a9a108cf87 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ContactDetails.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ContactDetails.java
@@ -19,6 +19,7 @@ package nodomain.freeyourgadget.gadgetbridge.activities;
import android.app.Activity;
import android.os.Bundle;
import android.text.Editable;
+import android.text.InputType;
import android.text.TextWatcher;
import android.view.MenuItem;
import android.widget.EditText;
@@ -28,21 +29,14 @@ import androidx.annotation.NonNull;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
-import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.entities.Contact;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
-import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
public class ContactDetails extends AbstractGBActivity {
- private static final Logger LOG = LoggerFactory.getLogger(ContactDetails.class);
-
private Contact contact;
private GBDevice device;
@@ -64,9 +58,9 @@ public class ContactDetails extends AbstractGBActivity {
contactName = findViewById(R.id.contact_name);
contactNumber = findViewById(R.id.contact_number);
+ contactNumber.setInputType(InputType.TYPE_CLASS_PHONE);
device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
- final DeviceCoordinator coordinator = device.getDeviceCoordinator();
contactName.addTextChangedListener(new TextWatcher() {
@Override
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsDetailActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsDetailActivity.java
index 27af770887..f218dc65cc 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsDetailActivity.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/app_specific_notifications/AppSpecificNotificationSettingsDetailActivity.java
@@ -17,15 +17,13 @@
package nodomain.freeyourgadget.gadgetbridge.activities.app_specific_notifications;
import android.os.Bundle;
+import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.Spinner;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import android.widget.TextView;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.R;
@@ -36,7 +34,6 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.entities.AppSpecificNotificationSetting;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.AbstractNotificationPattern;
-import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper;
public class AppSpecificNotificationSettingsDetailActivity extends AbstractGBActivity {
private AppSpecificNotificationSettingsRepository repository = null;
@@ -45,13 +42,16 @@ public class AppSpecificNotificationSettingsDetailActivity extends AbstractGBAct
private GBDevice mDevice;
private DeviceCoordinator mCoordinator;
- private List mLedPatternValues = new ArrayList<>();
- private List mVibrationPatternValues = new ArrayList<>();
- private List mVibrationCountValues = new ArrayList<>();
+ private final List mLedPatternValues = new ArrayList<>();
+ private final List mVibrationPatternValues = new ArrayList<>();
+ private final List mVibrationCountValues = new ArrayList<>();
private Spinner mSpinnerLedPattern;
private Spinner mSpinnerVibrationPattern;
private Spinner mSpinnerVibrationCount;
+ private TextView mTextViewLedColorTitle;
+ private TextView mTextViewVibration;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -61,6 +61,8 @@ public class AppSpecificNotificationSettingsDetailActivity extends AbstractGBAct
mSpinnerLedPattern = findViewById(R.id.spinnerLedType);
mSpinnerVibrationPattern = findViewById(R.id.spinnerVibraType);
mSpinnerVibrationCount = findViewById(R.id.spinnerVibraCount);
+ mTextViewLedColorTitle = findViewById(R.id.textViewLedColorTitle);
+ mTextViewVibration = findViewById(R.id.textViewVibration);
mDevice = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE);
mCoordinator = mDevice.getDeviceCoordinator();
@@ -78,29 +80,34 @@ public class AppSpecificNotificationSettingsDetailActivity extends AbstractGBAct
mVibrationCountValues.add(p.getValue());
if(!mCoordinator.supportsNotificationLedPatterns()) {
- mSpinnerLedPattern.setEnabled(false);
+ mTextViewLedColorTitle.setVisibility(View.GONE);
+ mSpinnerLedPattern.setVisibility(View.GONE);
} else {
mSpinnerLedPattern.setAdapter(
createAdapterFromArrayAddingDefault(mCoordinator.getNotificationLedPatterns())
);
}
- if(!mCoordinator.supportsNotificationVibrationPatterns()) {
- mSpinnerVibrationPattern.setEnabled(false);
+ if (!mCoordinator.supportsNotificationVibrationPatterns()) {
+ mSpinnerVibrationPattern.setVisibility(View.GONE);
} else {
mSpinnerVibrationPattern.setAdapter(
createAdapterFromArrayAddingDefault(mCoordinator.getNotificationVibrationPatterns())
);
}
- if(!mCoordinator.supportsNotificationVibrationRepetitionPatterns()) {
- mSpinnerVibrationCount.setEnabled(false);
+ if (!mCoordinator.supportsNotificationVibrationRepetitionPatterns()) {
+ mSpinnerVibrationCount.setVisibility(View.GONE);
} else {
mSpinnerVibrationCount.setAdapter(
createAdapterFromArrayAddingDefault(mCoordinator.getNotificationVibrationRepetitionPatterns())
);
}
+ if (!mCoordinator.supportsNotificationVibrationPatterns() && !mCoordinator.supportsNotificationVibrationRepetitionPatterns()) {
+ mTextViewVibration.setVisibility(View.GONE);
+ }
+
String title = getIntent().getStringExtra(AppSpecificNotificationSettingsAppListAdapter.STRING_EXTRA_PACKAGE_TITLE);
setTitle(title);
bundleId = getIntent().getStringExtra(AppSpecificNotificationSettingsAppListAdapter.STRING_EXTRA_PACKAGE_NAME);
@@ -152,7 +159,7 @@ public class AppSpecificNotificationSettingsDetailActivity extends AbstractGBAct
allOptions.add(getString(R.string.pref_default));
for(AbstractNotificationPattern s: array) allOptions.add(s.getUserReadableName(getApplicationContext()));
- return new ArrayAdapter(this, android.R.layout.simple_spinner_item, allOptions);
+ return new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, allOptions);
}
private void saveSettings() {
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java
index fe6fd50bbc..6ad0a663ac 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsPreferenceConst.java
@@ -213,6 +213,10 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_HEARTRATE_STRESS_RELAXATION_REMINDER = "heartrate_stress_relaxation_reminder";
public static final String PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING = "heartrate_sleep_breathing_quality_monitoring";
public static final String PREF_SPO2_ALL_DAY_MONITORING = "spo2_all_day_monitoring_enabled";
+ public static final String PREF_SPO2_MEASUREMENT_INTERVAL = "spo2_measurement_interval";
+ public static final String PREF_SPO2_MEASUREMENT_TIME = "spo2_measurement_time";
+ public static final String PREF_SPO2_MEASUREMENT_START = "spo2_measurement_start";
+ public static final String PREF_SPO2_MEASUREMENT_END = "spo2_measurement_end";
public static final String PREF_SPO2_LOW_ALERT_THRESHOLD = "spo2_low_alert_threshold";
public static final String PREF_HRV_ALL_DAY_MONITORING = "hrv_all_day_monitoring_enabled";
public static final String PREF_TEMPERATURE_ALL_DAY_MONITORING = "continuous_skin_temperature_measurement";
@@ -611,10 +615,12 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_POWER_SAVING = "pref_key_power_saving";
public static final String PREF_FORCE_CONNECTION_TYPE = "pref_force_connection_type";
+ public static final String PREF_ENABLE_CALL_REJECT = "enable_call_reject";
public static final String PREF_AUTO_REPLY_INCOMING_CALL = "pref_auto_reply_phonecall";
public static final String PREF_AUTO_REPLY_INCOMING_CALL_DELAY = "pref_auto_reply_phonecall_delay";
public static final String PREF_SPEAK_NOTIFICATIONS_ALOUD = "pref_speak_notifications_aloud";
public static final String PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE = "pref_speak_notifications_focus_exclusive";
+ public static final String PREF_ENABLE_SMS_QUICK_REPLY = "enable_sms_quick_reply";
public static final String PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL = "pref_cycling_persistence_interval";
public static final String PREF_CYCLING_SENSOR_WHEEL_DIAMETER = "pref_cycling_wheel_diameter";
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java
index ea4a3283fe..9226932f34 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSettingsUtils.java
@@ -282,4 +282,36 @@ public final class DeviceSettingsUtils {
return false;
});
}
+
+ public static void populateWithBpmRange(final CharSequence prefKey,
+ final DeviceSpecificSettingsHandler handler,
+ final int rangeMin,
+ final int rangeMax) {
+ final Preference pref = handler.findPreference(prefKey);
+ if (pref == null) {
+ return;
+ }
+
+ if (rangeMin >= rangeMax) {
+ throw new IllegalArgumentException("Invalid range [" + rangeMin + ", " + rangeMax + "]");
+ }
+
+ final CharSequence[] entries = new CharSequence[rangeMax - rangeMin + 2];
+ final CharSequence[] values = new CharSequence[rangeMax - rangeMin + 2];
+ entries[0] = handler.getContext().getString(R.string.off);
+ values[0] = "0";
+
+ for (int i = 1, bpm = rangeMin; bpm <= rangeMax; i++, bpm++) {
+ entries[i] = handler.getContext().getString(R.string.bpm_value_unit, bpm);
+ values[i] = String.valueOf(bpm);
+ }
+
+ if (pref instanceof ListPreference) {
+ ((ListPreference) pref).setEntries(entries);
+ ((ListPreference) pref).setEntryValues(values);
+ } else if (pref instanceof MultiSelectListPreference) {
+ ((MultiSelectListPreference) pref).setEntries(entries);
+ ((MultiSelectListPreference) pref).setEntryValues(values);
+ }
+ }
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java
index 9b2520c994..eb6e0005f1 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsFragment.java
@@ -603,6 +603,10 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_HEARTRATE_STRESS_RELAXATION_REMINDER);
addPreferenceHandlerFor(PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING);
addPreferenceHandlerFor(PREF_SPO2_ALL_DAY_MONITORING);
+ addPreferenceHandlerFor(PREF_SPO2_MEASUREMENT_INTERVAL);
+ addPreferenceHandlerFor(PREF_SPO2_MEASUREMENT_TIME);
+ addPreferenceHandlerFor(PREF_SPO2_MEASUREMENT_START);
+ addPreferenceHandlerFor(PREF_SPO2_MEASUREMENT_END);
addPreferenceHandlerFor(PREF_SPO2_LOW_ALERT_THRESHOLD);
addPreferenceHandlerFor(PREF_HRV_ALL_DAY_MONITORING);
addPreferenceHandlerFor(PREF_TEMPERATURE_ALL_DAY_MONITORING);
@@ -662,6 +666,8 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_NOTIFICATION_DELAY_CALLS);
addPreferenceHandlerFor(PREF_CALL_REJECT_METHOD);
addPreferenceHandlerFor(PREF_AUTO_REPLY_INCOMING_CALL);
+ addPreferenceHandlerFor(PREF_ENABLE_CALL_REJECT);
+ addPreferenceHandlerFor(PREF_ENABLE_SMS_QUICK_REPLY);
addPreferenceHandlerFor(PREF_SLEEP_MODE_SLEEP_SCREEN);
addPreferenceHandlerFor(PREF_SLEEP_MODE_SMART_ENABLE);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsHandler.java
index ae6d31ac23..8bb80d87da 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsHandler.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/devicesettings/DeviceSpecificSettingsHandler.java
@@ -20,6 +20,7 @@ import android.content.Context;
import androidx.activity.result.ActivityResultCaller;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.preference.Preference;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@@ -35,6 +36,7 @@ public interface DeviceSpecificSettingsHandler extends ActivityResultCaller {
* @param preferenceKey the preference key.
* @return the preference, if found.
*/
+ @Nullable
T findPreference(@NonNull CharSequence preferenceKey);
/**
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java
index 1d69fdb75d..74f800135a 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java
@@ -20,6 +20,8 @@ package nodomain.freeyourgadget.gadgetbridge.deviceevents;
import android.content.Context;
import android.content.Intent;
+import androidx.annotation.NonNull;
+
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -54,6 +56,12 @@ public class GBDeviceEventCallControl extends GBDeviceEvent {
context.sendBroadcast(callIntent);
}
+ @NonNull
+ @Override
+ public String toString() {
+ return super.toString() + "event: " + event;
+ }
+
public enum Event {
UNKNOWN,
ACCEPT,
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCameraRemote.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCameraRemote.java
index 6d8326dcd1..bef95499c5 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCameraRemote.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCameraRemote.java
@@ -19,11 +19,21 @@ package nodomain.freeyourgadget.gadgetbridge.deviceevents;
import android.content.Context;
import android.content.Intent;
+import androidx.annotation.NonNull;
+
import nodomain.freeyourgadget.gadgetbridge.activities.CameraActivity;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class GBDeviceEventCameraRemote extends GBDeviceEvent {
- public Event event = Event.UNKNOWN;
+ public Event event;
+
+ public GBDeviceEventCameraRemote() {
+ this(Event.UNKNOWN);
+ }
+
+ public GBDeviceEventCameraRemote(Event event) {
+ this.event = event;
+ }
@Override
public void evaluate(final Context context, final GBDevice device) {
@@ -33,6 +43,12 @@ public class GBDeviceEventCameraRemote extends GBDeviceEvent {
context.startActivity(cameraIntent);
}
+ @NonNull
+ @Override
+ public String toString() {
+ return super.toString() + "event: " + event;
+ }
+
public enum Event {
UNKNOWN,
OPEN_CAMERA,
@@ -42,30 +58,22 @@ public class GBDeviceEventCameraRemote extends GBDeviceEvent {
}
static public int eventToInt(Event event) {
- switch (event) {
- case UNKNOWN:
- return 0;
- case OPEN_CAMERA:
- return 1;
- case TAKE_PICTURE:
- return 2;
- case CLOSE_CAMERA:
- return 3;
- }
- return -1;
+ return switch (event) {
+ case UNKNOWN -> 0;
+ case OPEN_CAMERA -> 1;
+ case TAKE_PICTURE -> 2;
+ case CLOSE_CAMERA -> 3;
+ default -> -1;
+ };
}
static public Event intToEvent(int event) {
- switch (event) {
- case 0:
- return Event.UNKNOWN;
- case 1:
- return Event.OPEN_CAMERA;
- case 2:
- return Event.TAKE_PICTURE;
- case 3:
- return Event.CLOSE_CAMERA;
- }
- return Event.EXCEPTION;
+ return switch (event) {
+ case 0 -> Event.UNKNOWN;
+ case 1 -> Event.OPEN_CAMERA;
+ case 2 -> Event.TAKE_PICTURE;
+ case 3 -> Event.CLOSE_CAMERA;
+ default -> Event.EXCEPTION;
+ };
}
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFindPhone.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFindPhone.java
index 9f14131df1..223045fe91 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFindPhone.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventFindPhone.java
@@ -26,6 +26,7 @@ import android.content.Intent;
import android.net.Uri;
import android.os.Build;
+import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
@@ -75,6 +76,12 @@ public class GBDeviceEventFindPhone extends GBDeviceEvent {
}
}
+ @NonNull
+ @Override
+ public String toString() {
+ return super.toString() + "event: " + event;
+ }
+
private void handleGBDeviceEventFindPhoneStart(final Context context, final boolean ring) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // this could be used if app in foreground // TODO: Below Q?
final Intent startIntent = new Intent(context, FindPhoneActivity.class);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventMusicControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventMusicControl.java
index 55b788fe29..1b219c4686 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventMusicControl.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventMusicControl.java
@@ -20,6 +20,8 @@ package nodomain.freeyourgadget.gadgetbridge.deviceevents;
import android.content.Context;
import android.content.Intent;
+import androidx.annotation.NonNull;
+
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,7 +31,15 @@ import nodomain.freeyourgadget.gadgetbridge.service.receivers.GBMusicControlRece
public class GBDeviceEventMusicControl extends GBDeviceEvent {
private static final Logger LOG = LoggerFactory.getLogger(GBDeviceEventMusicControl.class);
- public Event event = Event.UNKNOWN;
+ public Event event;
+
+ public GBDeviceEventMusicControl() {
+ this(Event.UNKNOWN);
+ }
+
+ public GBDeviceEventMusicControl(Event event) {
+ this.event = event;
+ }
@Override
public void evaluate(final Context context, final GBDevice device) {
@@ -40,6 +50,12 @@ public class GBDeviceEventMusicControl extends GBDeviceEvent {
context.sendBroadcast(musicIntent);
}
+ @NonNull
+ @Override
+ public String toString() {
+ return super.toString() + "event: " + event;
+ }
+
public enum Event {
UNKNOWN,
PLAY,
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java
index dd0f025d98..97ca44e3e0 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java
@@ -96,7 +96,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
private static final Logger LOG = LoggerFactory.getLogger(AbstractDeviceCoordinator.class);
- private Pattern supportedDeviceName = null;
+ protected Pattern supportedDeviceName = null;
/**
* This method should return a Regexp pattern that will matched against a found device
@@ -237,7 +237,7 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
* Returns a map from {@link AbstractDao} to the corresponding Device ID property. All data present
* in these tables for a device will be deleted when the device is deleted.
*/
- protected Map, Property> getAllDeviceDao(@NonNull final DaoSession session) {
+ public Map, Property> getAllDeviceDao(@NonNull final DaoSession session) {
return Collections.emptyMap();
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSleepStageSampleProvider.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSleepStageSampleProvider.kt
new file mode 100644
index 0000000000..cf5dcc653f
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSleepStageSampleProvider.kt
@@ -0,0 +1,45 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices
+
+import de.greenrobot.dao.AbstractDao
+import de.greenrobot.dao.Property
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSleepStageSample
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSleepStageSampleDao
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+
+class GenericSleepStageSampleProvider(
+ device: GBDevice,
+ session: DaoSession
+) : AbstractTimeSampleProvider(device, session) {
+ override fun getSampleDao(): AbstractDao {
+ return session.genericSleepStageSampleDao
+ }
+
+ override fun getTimestampSampleProperty(): Property {
+ return GenericSleepStageSampleDao.Properties.Timestamp
+ }
+
+ override fun getDeviceIdentifierSampleProperty(): Property {
+ return GenericSleepStageSampleDao.Properties.DeviceId
+ }
+
+ override fun createSample(): GenericSleepStageSample {
+ return GenericSleepStageSample()
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSpo2SampleProvider.java
index 41c3767448..4a714c4584 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSpo2SampleProvider.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/GenericSpo2SampleProvider.java
@@ -21,7 +21,6 @@ import androidx.annotation.NonNull;
import de.greenrobot.dao.AbstractDao;
import de.greenrobot.dao.Property;
-import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericSpo2Sample;
import nodomain.freeyourgadget.gadgetbridge.entities.GenericSpo2SampleDao;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
index 65ea333b46..2f437bd924 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminCoordinator.java
@@ -64,7 +64,7 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
}
@Override
- protected Map, Property> getAllDeviceDao( @NonNull final DaoSession session) {
+ public Map, Property> getAllDeviceDao( @NonNull final DaoSession session) {
return new HashMap<>() {{
put(session.getGarminActivitySampleDao(), GarminActivitySampleDao.Properties.DeviceId);
put(session.getGarminStressSampleDao(), GarminStressSampleDao.Properties.DeviceId);
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitActivitySampleProvider.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitActivitySampleProvider.kt
new file mode 100644
index 0000000000..433babdad6
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitActivitySampleProvider.kt
@@ -0,0 +1,217 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.gloryfit
+
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericHeartRateSampleProvider
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericSleepStageSampleProvider
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericActivitySample
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
+import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample
+import nodomain.freeyourgadget.gadgetbridge.util.RangeMap
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+
+open class GloryFitActivitySampleProvider(device: GBDevice, session: DaoSession) :
+ SampleProvider {
+
+ companion object {
+ private val LOG: Logger = LoggerFactory.getLogger(GloryFitActivitySampleProvider::class.java)
+ }
+
+ private val stepsProvider: GloryFitStepsSampleProvider = GloryFitStepsSampleProvider(device, session)
+ private val heartRateProvider: GenericHeartRateSampleProvider = GenericHeartRateSampleProvider(device, session)
+ private val sleepStagesProvider: GenericSleepStageSampleProvider = GenericSleepStageSampleProvider(device, session)
+
+ override fun normalizeType(rawType: Int): ActivityKind {
+ return ActivityKind.fromCode(rawType)
+ }
+
+ override fun toRawActivityKind(activityKind: ActivityKind): Int {
+ return activityKind.code
+ }
+
+ override fun normalizeIntensity(rawIntensity: Int): Float {
+ return rawIntensity.toFloat()
+ }
+
+ override fun getAllActivitySamples(timestampFrom: Int, timestampTo: Int): MutableList {
+ val byTimestamp: MutableMap = mutableMapOf()
+ val ret: MutableList = mutableListOf()
+
+ val stepsSamples = stepsProvider.getAllSamples(timestampFrom * 1000L - 2 * 86400L, timestampTo * 1000L)
+ for (stepsSample in stepsSamples) {
+ val activitySample = GenericActivitySample()
+ activitySample.provider = this
+ activitySample.timestamp = (stepsSample.timestamp / 1000L).toInt()
+ activitySample.steps = stepsSample.totalSteps
+ ret.add(activitySample)
+ byTimestamp.put(activitySample.timestamp, activitySample)
+ }
+ val hrSamples = heartRateProvider.getAllSamples(timestampFrom * 1000L - 2 * 86400L, timestampTo * 1000L)
+ for (hrSample in hrSamples) {
+ val timestamp = (hrSample.timestamp / 1000L).toInt()
+ if (byTimestamp.contains(timestamp)) {
+ byTimestamp[timestamp]!!.heartRate = hrSample.heartRate
+ } else {
+ val activitySample = GenericActivitySample()
+ activitySample.provider = this
+ activitySample.timestamp = timestamp
+ activitySample.heartRate = hrSample.heartRate
+ ret.add(activitySample)
+ byTimestamp.put(activitySample.timestamp, activitySample)
+ }
+ }
+
+ // TODO fill gaps?
+
+ overlaySleep(ret, timestampFrom, timestampTo)
+
+ return ret
+ .filter { sample -> sample.timestamp in timestampFrom..timestampTo }
+ .sortedBy { sample -> sample.timestamp }
+ .toMutableList()
+ }
+
+ override fun getAllActivitySamplesHighRes(
+ timestampFrom: Int,
+ timestampTo: Int
+ ): MutableList {
+ return getAllActivitySamples(timestampFrom, timestampTo)
+ }
+
+ override fun hasHighResData(): Boolean {
+ return false
+ }
+
+ override fun getActivitySamples(timestampFrom: Int, timestampTo: Int): MutableList {
+ return getAllActivitySamples(timestampFrom, timestampTo)
+ .filter { sample -> sample.kind == ActivityKind.ACTIVITY }
+ .toMutableList()
+ }
+
+ override fun addGBActivitySample(activitySample: GenericActivitySample) {
+ throw UnsupportedOperationException("Read-only sample provider")
+ }
+
+ override fun addGBActivitySamples(activitySamples: Array) {
+ throw UnsupportedOperationException("Read-only sample provider")
+ }
+
+ override fun createActivitySample(): GenericActivitySample {
+ return GenericActivitySample()
+ }
+
+ override fun getLatestActivitySample(): GenericActivitySample? {
+ // TODO getLatestActivitySample
+ LOG.warn("getLatestActivitySample not implemented");
+ return null
+ }
+
+ override fun getLatestActivitySample(until: Int): GenericActivitySample? {
+ // TODO getLatestActivitySample
+ LOG.warn("getLatestActivitySample(until) not implemented");
+ return null
+ }
+
+ override fun getFirstActivitySample(): GenericActivitySample? {
+ // TODO getFirstActivitySample
+ LOG.warn("getFirstActivitySample not implemented");
+ return null
+ }
+
+ fun overlaySleep(samples: MutableList, timestampFrom: Int, timestampTo: Int) {
+ val stagesMap = RangeMap(RangeMap.Mode.LOWER_BOUND)
+
+ // Retrieve the last stage before this time range, as the user could have been asleep during
+ // the range transition
+ val lastSleepStageBeforeRange = sleepStagesProvider.getLastSampleBefore(timestampFrom * 1000L)
+
+ if (lastSleepStageBeforeRange != null) {
+ LOG.debug(
+ "Last sleep stage before range: ts={}, stage={}",
+ lastSleepStageBeforeRange.timestamp,
+ lastSleepStageBeforeRange.stage
+ )
+ stagesMap.put(
+ lastSleepStageBeforeRange.timestamp,
+ sleepStageToActivityKind(lastSleepStageBeforeRange.stage)
+ )
+ stagesMap.put(
+ lastSleepStageBeforeRange.timestamp + lastSleepStageBeforeRange.duration * 60 * 1000L,
+ ActivityKind.UNKNOWN
+ )
+ }
+
+ // Retrieve all sleep stage samples during the range
+ val sleepStagesInRange = sleepStagesProvider.getAllSamples(
+ timestampFrom * 1000L,
+ timestampTo * 1000L
+ )
+
+ if (!sleepStagesInRange.isEmpty()) {
+ // We got actual sleep stages
+ LOG.debug(
+ "Found {} sleep stage samples between {} and {}",
+ sleepStagesInRange.size,
+ timestampFrom,
+ timestampTo
+ )
+
+ for (stageSample in sleepStagesInRange) {
+ stagesMap.put(
+ stageSample!!.timestamp,
+ sleepStageToActivityKind(stageSample.stage)
+ )
+ stagesMap.put(
+ stageSample.timestamp + stageSample.duration * 60 * 1000L,
+ ActivityKind.UNKNOWN
+ )
+ }
+ }
+
+ if (!stagesMap.isEmpty) {
+ LOG.debug(
+ "Found {} sleep stage samples between {} and {}",
+ stagesMap.size(),
+ timestampFrom,
+ timestampTo
+ )
+
+ for (sample in samples) {
+ val ts = sample.timestamp * 1000L
+ val sleepType = stagesMap.get(ts)
+ if (sleepType != null && sleepType != ActivityKind.UNKNOWN) {
+ sample.rawKind = sleepType.code
+ sample.rawIntensity = ActivitySample.NOT_MEASURED
+ }
+ }
+ }
+ }
+
+ fun sleepStageToActivityKind(stage: Int): ActivityKind {
+ return when (stage) {
+ 1 -> ActivityKind.DEEP_SLEEP
+ 2 -> ActivityKind.LIGHT_SLEEP
+ 3 -> ActivityKind.AWAKE_SLEEP
+ 4 -> ActivityKind.REM_SLEEP
+ else -> ActivityKind.UNKNOWN
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitCoordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitCoordinator.kt
new file mode 100644
index 0000000000..9b717b67c0
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitCoordinator.kt
@@ -0,0 +1,265 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.gloryfit
+
+import android.content.Context
+import android.content.Intent
+import de.greenrobot.dao.AbstractDao
+import de.greenrobot.dao.Property
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.activities.CameraActivity
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen
+import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability.MeasurementInterval
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator
+import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCardAction
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericSpo2SampleProvider
+import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider
+import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider
+import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHeartRateSampleDao
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSleepStageSampleDao
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSpo2SampleDao
+import nodomain.freeyourgadget.gadgetbridge.entities.GloryFitStepsSampleDao
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.AbstractNotificationPattern
+import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample
+import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport
+import nodomain.freeyourgadget.gadgetbridge.service.devices.gloryfit.GloryFitLanguage
+import nodomain.freeyourgadget.gadgetbridge.service.devices.gloryfit.GloryFitSupport
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.protocol.packets.notification.defines.VibrationCount
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.protocol.packets.notification.defines.VibrationKind
+
+abstract class GloryFitCoordinator : AbstractBLEDeviceCoordinator() {
+ override fun getDefaultIconResource(): Int {
+ return R.drawable.ic_device_amazfit_bip
+ }
+
+ override fun getDeviceSupportClass(device: GBDevice): Class {
+ return GloryFitSupport::class.java
+ }
+
+ override fun getAllDeviceDao(session: DaoSession): MutableMap, Property> {
+ return object : HashMap, Property>() {
+ init {
+ put(session.gloryFitStepsSampleDao, GloryFitStepsSampleDao.Properties.DeviceId)
+ put(session.genericSleepStageSampleDao, GenericSleepStageSampleDao.Properties.DeviceId)
+ put(session.genericHeartRateSampleDao, GenericHeartRateSampleDao.Properties.DeviceId)
+ put(session.genericSpo2SampleDao, GenericSpo2SampleDao.Properties.DeviceId)
+ }
+ }
+ }
+
+ override fun getSampleProvider(
+ device: GBDevice,
+ session: DaoSession
+ ): SampleProvider? {
+ return GloryFitActivitySampleProvider(device, session)
+ }
+
+ override fun getSpo2SampleProvider(
+ device: GBDevice,
+ session: DaoSession?
+ ): TimeSampleProvider? {
+ return GenericSpo2SampleProvider(device, session)
+ }
+
+ override fun getAlarmSlotCount(device: GBDevice): Int {
+ return 3
+ }
+
+ override fun suggestUnbindBeforePair(): Boolean {
+ return false
+ }
+
+ override fun supportsActivityDataFetching(): Boolean {
+ return true
+ }
+
+ override fun supportsActivityTracking(): Boolean {
+ return true
+ }
+
+ override fun supportsActiveCalories(): Boolean {
+ // TODO it does not, but we could try and match their formula in the samples
+ return false
+ }
+
+ override fun supportsSpo2(device: GBDevice): Boolean {
+ return true
+ }
+
+ override fun supportsMusicInfo(): Boolean {
+ // Not info, but supports music control
+ return true
+ }
+
+ override fun getCannedRepliesSlotCount(device: GBDevice): Int {
+ return 8
+ }
+
+ override fun getContactsSlotCount(device: GBDevice): Int {
+ return 100
+ }
+
+ override fun supportsHeartRateMeasurement(device: GBDevice): Boolean {
+ return true
+ }
+
+ override fun supportsManualHeartRateMeasurement(device: GBDevice): Boolean {
+ return false // TODO supportsManualHeartRateMeasurement
+ }
+
+ override fun supportsRealtimeData(): Boolean {
+ // TODO it does
+ return false
+ }
+
+ override fun supportsRemSleep(): Boolean {
+ return true
+ }
+
+ override fun supportsAwakeSleep(): Boolean {
+ return true
+ }
+
+ override fun supportsWeather(): Boolean {
+ // TODO it does
+ return false
+ }
+
+ override fun supportsFindDevice(): Boolean {
+ return true
+ }
+
+ override fun supportsUnicodeEmojis(): Boolean {
+ // Official app seems to just remove them outright
+ return false
+ }
+
+ override fun addBatteryPollingSettings(): Boolean {
+ // It only sends proactive updates during charging
+ return true
+ }
+
+ override fun supportsNotificationVibrationPatterns(): Boolean {
+ return true
+ }
+
+ override fun supportsNotificationVibrationRepetitionPatterns(): Boolean {
+ return true
+ }
+
+ override fun getNotificationVibrationPatterns(): Array {
+ return arrayOf(
+ VibrationKind.NONE,
+ VibrationKind.BASIC,
+ )
+ }
+
+ override fun getNotificationVibrationRepetitionPatterns(): Array {
+ return arrayOf(
+ VibrationCount.ONCE,
+ VibrationCount.TWICE,
+ VibrationCount.THREE,
+ VibrationCount.FOUR,
+ )
+ }
+
+ override fun getDeviceSpecificSettings(device: GBDevice): DeviceSpecificSettings {
+ val deviceSpecificSettings = DeviceSpecificSettings()
+
+ val dateTime = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME)
+ dateTime.add(R.xml.devicesettings_timeformat)
+
+ val display = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DISPLAY)
+ display.add(R.xml.devicesettings_liftwrist_display_noshed)
+
+ val health = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.HEALTH)
+ health.add(R.xml.devicesettings_heartrate_automatic_enable)
+ health.add(R.xml.devicesettings_heartrate_alerts)
+ if (supportsSpo2(device)) {
+ health.add(R.xml.devicesettings_spo2)
+ }
+ health.add(R.xml.devicesettings_inactivity_dnd)
+
+ val notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS)
+ notifications.add(R.xml.devicesettings_transliteration)
+ if (getContactsSlotCount(device) > 0) {
+ notifications.add(R.xml.devicesettings_contacts)
+ }
+ notifications.add(R.xml.devicesettings_header_notifications)
+ notifications.add(R.xml.devicesettings_send_app_notifications)
+ notifications.add(R.xml.devicesettings_per_app_notifications)
+ notifications.add(R.xml.devicesettings_header_phone_calls)
+ notifications.add(R.xml.devicesettings_reject_call_method)
+ if (getCannedRepliesSlotCount(device) > 0) {
+ notifications.add(R.xml.devicesettings_sms_quick_reply)
+ notifications.add(R.xml.devicesettings_canned_reply_16)
+ }
+
+ return deviceSpecificSettings
+ }
+
+ override fun getDeviceSpecificSettingsCustomizer(device: GBDevice): DeviceSpecificSettingsCustomizer? {
+ return GloryFitSettingsCustomizer()
+ }
+
+ override fun getSupportedLanguageSettings(device: GBDevice): Array {
+ // TODO fetch languages from device
+ return arrayOf("auto") + GloryFitLanguage.entries.map { it.locale }
+ }
+
+ override fun getHeartRateMeasurementIntervals(): List {
+ // actually on/off
+ return listOf(
+ MeasurementInterval.OFF,
+ MeasurementInterval.SMART
+ )
+ }
+
+ override fun getCustomActions(): List {
+ if (!CameraActivity.supportsCamera()) {
+ return emptyList()
+ }
+
+ return listOf(
+ object : DeviceCardAction {
+ override fun getIcon(device: GBDevice): Int {
+ return R.drawable.ic_camera_remote
+ }
+
+ override fun getDescription(device: GBDevice, context: Context): String? {
+ return context.getString(R.string.open_camera)
+ }
+
+ override fun onClick(device: GBDevice, context: Context) {
+ val cameraIntent = Intent(context, CameraActivity::class.java)
+ cameraIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ cameraIntent.putExtra(
+ CameraActivity.intentExtraEvent,
+ GBDeviceEventCameraRemote.eventToInt(GBDeviceEventCameraRemote.Event.OPEN_CAMERA)
+ )
+ context.startActivity(cameraIntent)
+ }
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitSettingsCustomizer.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitSettingsCustomizer.kt
new file mode 100644
index 0000000000..8673e34f9b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitSettingsCustomizer.kt
@@ -0,0 +1,70 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.gloryfit
+
+import androidx.preference.Preference
+import kotlinx.parcelize.Parcelize
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsUtils
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler
+import nodomain.freeyourgadget.gadgetbridge.util.Prefs
+
+@Parcelize
+class GloryFitSettingsCustomizer : DeviceSpecificSettingsCustomizer {
+ override fun onPreferenceChange(
+ preference: Preference,
+ handler: DeviceSpecificSettingsHandler
+ ) {
+ }
+
+ override fun onDeviceChanged(handler: DeviceSpecificSettingsHandler) {
+ }
+
+ override fun customizeSettings(
+ handler: DeviceSpecificSettingsHandler,
+ genericDevicePrefs: Prefs,
+ rootKey: String?
+ ) {
+ DeviceSettingsUtils.populateWithBpmRange(
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD,
+ handler,
+ 100,
+ 200
+ )
+
+ DeviceSettingsUtils.populateWithBpmRange(
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD,
+ handler,
+ 40,
+ 100
+ )
+
+ // Inactivity reminders
+ val dndEnabled = handler.findPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND)
+ dndEnabled?.summary = handler.context.getString(R.string.mi2_prefs_inactivity_warnings_dnd_lunch_break_summary)
+ val dndStart = handler.findPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_START)
+ dndStart?.isVisible = false
+ val dndEnd = handler.findPreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_END)
+ dndEnd?.isVisible = false
+ }
+
+ override fun getPreferenceKeysWithSummary(): Set {
+ return setOf()
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitStepsSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitStepsSampleProvider.java
new file mode 100644
index 0000000000..4569f53f5d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/GloryFitStepsSampleProvider.java
@@ -0,0 +1,57 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+
+package nodomain.freeyourgadget.gadgetbridge.devices.gloryfit;
+
+import androidx.annotation.NonNull;
+
+import de.greenrobot.dao.AbstractDao;
+import de.greenrobot.dao.Property;
+import nodomain.freeyourgadget.gadgetbridge.devices.AbstractTimeSampleProvider;
+import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
+import nodomain.freeyourgadget.gadgetbridge.entities.GloryFitStepsSample;
+import nodomain.freeyourgadget.gadgetbridge.entities.GloryFitStepsSampleDao;
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
+
+public class GloryFitStepsSampleProvider extends AbstractTimeSampleProvider {
+ public GloryFitStepsSampleProvider(final GBDevice device, final DaoSession session) {
+ super(device, session);
+ }
+
+ @NonNull
+ @Override
+ public AbstractDao getSampleDao() {
+ return getSession().getGloryFitStepsSampleDao();
+ }
+
+ @NonNull
+ @Override
+ protected Property getTimestampSampleProperty() {
+ return GloryFitStepsSampleDao.Properties.Timestamp;
+ }
+
+ @NonNull
+ @Override
+ protected Property getDeviceIdentifierSampleProperty() {
+ return GloryFitStepsSampleDao.Properties.DeviceId;
+ }
+
+ @Override
+ public GloryFitStepsSample createSample() {
+ return new GloryFitStepsSample();
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/watches/DotnP66DCoordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/watches/DotnP66DCoordinator.kt
new file mode 100644
index 0000000000..fc55db10e9
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/watches/DotnP66DCoordinator.kt
@@ -0,0 +1,50 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.watches
+
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.GloryFitCoordinator
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import java.util.regex.Pattern
+
+class DotnP66DCoordinator : GloryFitCoordinator() {
+ override fun isExperimental(): Boolean {
+ return true
+ }
+
+ override fun getManufacturer(): String {
+ return "Dotn"
+ }
+
+ override fun getSupportedDeviceName(): Pattern? {
+ return Pattern.compile("^P66D\\(ID-[0-9A-F]{4}\\)$")
+ }
+
+ override fun getDeviceNameResource(): Int {
+ return R.string.devicetype_dotn_p66d
+ }
+
+ override fun getBondingStyle(): Int {
+ // Watches without calls fail to pair
+ return BONDING_STYLE_NONE
+ }
+
+ override fun getContactsSlotCount(device: GBDevice): Int {
+ // No phone calls / contacts
+ return 0
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/watches/OukitelBT103Coordinator.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/watches/OukitelBT103Coordinator.kt
new file mode 100644
index 0000000000..4f4f92105b
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/gloryfit/watches/OukitelBT103Coordinator.kt
@@ -0,0 +1,41 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.watches
+
+import nodomain.freeyourgadget.gadgetbridge.R
+import nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.GloryFitCoordinator
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import java.util.regex.Pattern
+
+class OukitelBT103Coordinator : GloryFitCoordinator() {
+ override fun getManufacturer(): String {
+ return "Oukitel"
+ }
+
+ override fun getSupportedDeviceName(): Pattern? {
+ return Pattern.compile("^BT103\\(ID-[0-9A-F]{4}\\)$")
+ }
+
+ override fun getDeviceNameResource(): Int {
+ return R.string.devicetype_oukitel_bt103
+ }
+
+ override fun getAlarmSlotCount(device: GBDevice): Int {
+ // 8 slots, but alarms from app are not supported
+ return 0
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900DeviceCoordinator.java
index fc2eafee49..b1fe85f6ab 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900DeviceCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/hama/fit6900/HamaFit6900DeviceCoordinator.java
@@ -23,11 +23,8 @@ import androidx.annotation.NonNull;
import java.util.regex.Pattern;
-import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
-import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
-import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.hama.fit6900.HamaFit6900DeviceSupport;
@@ -56,7 +53,7 @@ public final class HamaFit6900DeviceCoordinator extends AbstractBLEDeviceCoordin
@Override
public int[] getSupportedDeviceSpecificSettings(GBDevice device) {
return new int[]{
- R.xml.devicesettings_allow_accept_reject_calls, // reject only
+ R.xml.devicesettings_allow_reject_calls, // reject only
R.xml.devicesettings_camera_remote,
R.xml.devicesettings_find_phone,
R.xml.devicesettings_liftwrist_display_no_on,
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java
index d1056a8009..accccc272d 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiCoordinator.java
@@ -334,7 +334,8 @@ public class HuaweiCoordinator {
// Other
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_find_phone);
deviceSpecificSettings.addRootScreen(R.xml.devicesettings_disable_find_phone_with_dnd);
- deviceSpecificSettings.addRootScreen(R.xml.devicesettings_allow_accept_reject_calls);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_allow_accept_calls);
+ deviceSpecificSettings.addRootScreen(R.xml.devicesettings_allow_reject_calls);
// Camera control
if (supportsCameraRemote())
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleNotification.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleNotification.java
index 0c0635542f..ae4293e11c 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleNotification.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/pebble/PebbleNotification.java
@@ -40,7 +40,6 @@ public class PebbleNotification {
private byte setColor(NotificationType notificationType) {
return switch (notificationType) {
- case UNKNOWN -> PebbleColor.DarkCandyAppleRed;
case AMAZON -> PebbleColor.ChromeYellow;
case BBM -> PebbleColor.DarkGray;
case CONVERSATIONS -> PebbleColor.Inchworm;
@@ -64,12 +63,12 @@ public class PebbleNotification {
case WECHAT -> PebbleColor.KellyGreen;
case YAHOO_MAIL -> PebbleColor.Indigo;
case ELEMENT, ELEMENTX -> PebbleColor.Malachite;
+ default -> PebbleColor.DarkCandyAppleRed;
};
}
private int setIcon(NotificationType notificationType) {
return switch (notificationType) {
- case UNKNOWN -> PebbleIconID.NOTIFICATION_GENERIC;
case AMAZON -> PebbleIconID.NOTIFICATION_AMAZON;
case BBM -> PebbleIconID.NOTIFICATION_BLACKBERRY_MESSENGER;
case CONVERSATIONS, HIPCHAT, RIOT, SIGNAL, WIRE, THREEMA, KONTALK,
@@ -107,6 +106,7 @@ public class PebbleNotification {
case WHATSAPP -> PebbleIconID.NOTIFICATION_WHATSAPP;
case YAHOO_MAIL -> PebbleIconID.NOTIFICATION_YAHOO_MAIL;
case COL_REMINDER -> PebbleIconID.NOTIFICATION_REMINDER;
+ default -> PebbleIconID.NOTIFICATION_GENERIC;
};
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/.gitignore b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/.gitignore
index b2099c861c..00d42dc136 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/.gitignore
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/.gitignore
@@ -1,2 +1,3 @@
*.java
!Abstract*.java
+!GenericActivitySample.java
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/GenericActivitySample.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/GenericActivitySample.java
new file mode 100644
index 0000000000..cb2e51df41
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/entities/GenericActivitySample.java
@@ -0,0 +1,119 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.entities;
+
+public class GenericActivitySample extends AbstractActivitySample {
+ private int timestamp;
+ private long userId;
+ private long deviceId;
+
+ private int rawKind = NOT_MEASURED;
+ private int rawIntensity = NOT_MEASURED;
+ private int steps = NOT_MEASURED;
+ private int distanceCm = NOT_MEASURED;
+ private int activeCalories = NOT_MEASURED;
+ private int heartRate = NOT_MEASURED;
+
+ @Override
+ public int getTimestamp() {
+ return timestamp;
+ }
+
+ @Override
+ public void setTimestamp(final int timestamp) {
+ this.timestamp = timestamp;
+ }
+
+ @Override
+ public long getUserId() {
+ return userId;
+ }
+
+ @Override
+ public void setUserId(final long userId) {
+ this.userId = userId;
+ }
+
+ @Override
+ public long getDeviceId() {
+ return deviceId;
+ }
+
+ @Override
+ public void setDeviceId(final long deviceId) {
+ this.deviceId = deviceId;
+ }
+
+ @Override
+ public int getRawKind() {
+ return rawKind;
+ }
+
+ @Override
+ public void setRawKind(final int rawKind) {
+ this.rawKind = rawKind;
+ }
+
+ @Override
+ public int getRawIntensity() {
+ return rawIntensity;
+ }
+
+ public void setRawIntensity(final int rawIntensity) {
+ this.rawIntensity = rawIntensity;
+ }
+
+ @Override
+ public int getSteps() {
+ return steps;
+ }
+
+ @Override
+ public void setSteps(final int steps) {
+ this.steps = steps;
+ }
+
+ @Override
+ public int getDistanceCm() {
+ return distanceCm;
+ }
+
+ @Override
+ public void setDistanceCm(final int distanceCm) {
+ this.distanceCm = distanceCm;
+ }
+
+ @Override
+ public int getActiveCalories() {
+ return activeCalories;
+ }
+
+ @Override
+ public void setActiveCalories(final int activeCalories) {
+ this.activeCalories = activeCalories;
+ }
+
+ @Override
+ public int getHeartRate() {
+ return heartRate;
+ }
+
+ @Override
+ public void setHeartRate(final int heartRate) {
+ this.heartRate = heartRate;
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AppNotificationType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AppNotificationType.java
index 145c4919b2..994c720e60 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AppNotificationType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/AppNotificationType.java
@@ -192,6 +192,13 @@ public class AppNotificationType extends HashMap {
// KTrip
put("org.kde.ktrip", NotificationType.TRANSIT);
+
+ // Others
+ put("com.vkontakte.android", NotificationType.VK);
+ put("com.tencent.mobileqq", NotificationType.QQ);
+ put("com.tumblr", NotificationType.TUMBLR);
+ put("com.pinterest", NotificationType.PINTEREST);
+ put("com.google.android.youtube", NotificationType.YOUTUBE);
}
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CannedMessagesSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CannedMessagesSpec.java
index 39351efdb2..7752ac2376 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CannedMessagesSpec.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/CannedMessagesSpec.java
@@ -17,9 +17,9 @@
package nodomain.freeyourgadget.gadgetbridge.model;
public class CannedMessagesSpec {
- public static final byte TYPE_GENERIC = 0;
- public static final byte TYPE_REJECTEDCALLS = 1;
- public static final byte TYPE_NEWSMS = 2;
+ public static final int TYPE_GENERIC = 0;
+ public static final int TYPE_REJECTEDCALLS = 1;
+ public static final int TYPE_NEWSMS = 2;
public int type;
public String[] cannedMessages;
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
index 52ae5f154a..58e7acf93a 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java
@@ -137,6 +137,8 @@ import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosmart.Gar
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosmart.GarminVivosmart5Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.watches.vivosport.GarminVivosportCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.generic_headphones.GenericHeadphonesCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.watches.DotnP66DCoordinator;
+import nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.watches.OukitelBT103Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.gree.GreeAcCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hama.fit6900.HamaFit6900DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.EXRIZUK8Coordinator;
@@ -698,6 +700,8 @@ public enum DeviceType {
OPPO_ENCO_AIR(OppoEncoAirCoordinator.class),
OPPO_ENCO_AIR2(OppoEncoAir2Coordinator.class),
OPPO_ENCO_BUDS2(OppoEncoBuds2Coordinator.class),
+ OUKITEL_BT103(OukitelBT103Coordinator.class),
+ DOTN_P66D(DotnP66DCoordinator.class),
REALME_BUDS_T110(RealmeBudsT110Coordinator.class),
REALME_BUDS_T100(RealmeBudsT100Coordinator.class),
REALME_BUDS_T300(RealmeBudsT300Coordinator.class),
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java
index b5fb240311..0efe544e8f 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/NotificationType.java
@@ -54,7 +54,13 @@ public enum NotificationType {
DELTACHAT,
ELEMENT,
ELEMENTX,
- MOLLY;
+ MOLLY,
+ VK,
+ QQ,
+ TUMBLR,
+ PINTEREST,
+ YOUTUBE,
+ ;
/**
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java
index ca1fced525..fe44df908f 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BleNamesResolver.java
@@ -499,6 +499,8 @@ public class BleNamesResolver {
mServices.put("02f00000-0000-0000-0000-00000000ffe0", "(Propr: Nothing CMF Data");
mServices.put("02f00000-0000-0000-0000-00000000fe00", "(Propr: Nothing CMF Firmware");
mServices.put("77d4e67c-2fe2-2334-0d35-9ccd078f529c", "(Propr: Nothing CMF Shell");
+ mServices.put("000055ff-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Command");
+ mServices.put("000056ff-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Data");
mServices.put("9b012401-bc30-ce9a-e111-0f67e491abde", "(Propr: Garmin GFDI V0)");
mServices.put("6a4e2401-667b-11e3-949a-0800200c9a66", "(Propr: Garmin GFDI V1)");
mServices.put("6a4e2800-667b-11e3-949a-0800200c9a66", "(Propr: Garmin ML)");
@@ -1006,6 +1008,10 @@ public class BleNamesResolver {
mCharacteristics.put("77d4ff02-2fe2-2334-0d35-9ccd078f529c", "(Propr: Nothing CMF Shell Read");
mCharacteristics.put("02f00000-0000-0000-0000-00000000ff01", "(Propr: Nothing CMF Firmware Write");
mCharacteristics.put("02f00000-0000-0000-0000-00000000ff02", "(Propr: Nothing CMF Firmware Read");
+ mCharacteristics.put("000033f1-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Command Write");
+ mCharacteristics.put("000033f2-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Command Read");
+ mCharacteristics.put("000034f1-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Data Write");
+ mCharacteristics.put("000034f2-0000-1000-8000-00805f9b34fb", "(Propr: GloryFit Data Read");
mCharacteristics.put("00010203-0405-0607-0809-0a0b0c0d2b12", "(Propr: Telink OTA Write)");
mCharacteristics.put("ebe0ccb7-7a0a-4b0c-8a1a-6ff2997da3a6", "(Propr: Lywsd TIME)");
mCharacteristics.put("ebe0ccc4-7a0a-4b0c-8a1a-6ff2997da3a6", "(Propr: Lywsd BATTERY)");
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitFetchType.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitFetchType.kt
new file mode 100644
index 0000000000..a5fe2efaae
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitFetchType.kt
@@ -0,0 +1,27 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.gloryfit
+
+import nodomain.freeyourgadget.gadgetbridge.R
+
+enum class GloryFitFetchType(val descriptionRes: Int) {
+ STEPS(R.string.busy_task_fetch_steps),
+ HEART_RATE(R.string.busy_task_fetch_hr_data),
+ SPO2(R.string.busy_task_fetch_spo2_data),
+ SLEEP(R.string.busy_task_fetch_sleep_data),
+ ;
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitFetcher.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitFetcher.kt
new file mode 100644
index 0000000000..9140ebf890
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitFetcher.kt
@@ -0,0 +1,404 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.gloryfit
+
+import android.widget.Toast
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericHeartRateSampleProvider
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericSleepStageSampleProvider
+import nodomain.freeyourgadget.gadgetbridge.devices.GenericSpo2SampleProvider
+import nodomain.freeyourgadget.gadgetbridge.devices.gloryfit.GloryFitStepsSampleProvider
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericHeartRateSample
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSleepStageSample
+import nodomain.freeyourgadget.gadgetbridge.entities.GenericSpo2Sample
+import nodomain.freeyourgadget.gadgetbridge.entities.GloryFitStepsSample
+import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes
+import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils
+import nodomain.freeyourgadget.gadgetbridge.util.GB
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.Calendar
+import java.util.Collections
+import java.util.GregorianCalendar
+import java.util.LinkedList
+import java.util.Queue
+
+class GloryFitFetcher(val mSupport: GloryFitSupport) {
+ companion object {
+ private val LOG: Logger = LoggerFactory.getLogger(GloryFitFetcher::class.java)
+ }
+
+ private val mFetchQueue: Queue = LinkedList()
+ private var mCurrentFetch: GloryFitFetchType? = null
+ private var mCurrentSleepSession: Calendar? = null
+ private var mCurrentSessionAfterMidnight = false
+
+ fun reset() {
+ mFetchQueue.clear()
+ mCurrentFetch = null
+ mCurrentSleepSession = null
+ }
+
+ fun onFetchRecordedData(dataTypes: Int) {
+ val coordinator = mSupport.device.deviceCoordinator
+
+ if ((dataTypes and RecordedDataTypes.TYPE_ACTIVITY) != 0) {
+ mFetchQueue.add(GloryFitFetchType.STEPS)
+ mFetchQueue.add(GloryFitFetchType.HEART_RATE)
+ mFetchQueue.add(GloryFitFetchType.SLEEP)
+ }
+
+ if ((dataTypes and RecordedDataTypes.TYPE_SPO2) != 0 && coordinator.supportsSpo2(mSupport.device)) {
+ mFetchQueue.add(GloryFitFetchType.SPO2)
+ }
+
+ if (mCurrentFetch == null) {
+ triggerNextFetch()
+ }
+ }
+
+ fun triggerNextFetch() {
+ val wasFetching = mCurrentFetch != null
+ mCurrentFetch = this.mFetchQueue.poll()
+
+ mCurrentFetch?.let {
+ LOG.debug("Fetching next: {}", it)
+
+ mSupport.device.setBusyTask(
+ it.descriptionRes,
+ mSupport.context
+ )
+ mSupport.device.sendDeviceUpdateIntent(mSupport.context)
+
+ sendFetchCommand(it)
+
+ return
+ }
+
+ if (wasFetching) {
+ LOG.debug("All operations finished")
+
+ GB.updateTransferNotification(null, "", false, 100, mSupport.context)
+ GB.signalActivityDataFinish(mSupport.device)
+
+ if (mSupport.device.isBusy) {
+ mSupport.device.unsetBusyTask()
+ mSupport.device.sendDeviceUpdateIntent(mSupport.context)
+ }
+ }
+ }
+
+ private fun sendFetchCommand(type: GloryFitFetchType) {
+ val builder = mSupport.createTransactionBuilder("fetch $type")
+ val characteristic = mSupport.getCharacteristic(GloryFitSupport.UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE)
+ val cmd: ByteArray
+ when (type) {
+ GloryFitFetchType.STEPS -> {
+ cmd = byteArrayOf(GloryFitSupport.CMD_STEPS, GloryFitSupport.FETCH_START)
+ }
+
+ GloryFitFetchType.HEART_RATE -> {
+ cmd = byteArrayOf(GloryFitSupport.CMD_HEART_RATE, GloryFitSupport.FETCH_START)
+ }
+
+ GloryFitFetchType.SPO2 -> {
+ cmd = byteArrayOf(GloryFitSupport.CMD_SPO2, GloryFitSupport.FETCH_START)
+ }
+
+ GloryFitFetchType.SLEEP -> {
+ cmd = byteArrayOf(GloryFitSupport.CMD_SLEEP_INFO, 0x01)
+ }
+ }
+
+ builder.write(characteristic, *cmd)
+
+ builder.queue(mSupport.queue)
+ }
+
+ fun handleSleepInfo(value: ByteArray) {
+ when (value[1]) {
+ GloryFitSupport.SLEEP_INFO_DATE -> {
+ val buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
+ buf.get(ByteArray(2)) // discard first 2 bytes
+ val timestamp = buf.getDate()
+ val numStages = buf.get().toInt() and 0xff
+ mCurrentSleepSession = timestamp
+ mCurrentSessionAfterMidnight = false
+ LOG.debug(
+ "Got sleep info date {}, expect {} stages",
+ DateTimeUtils.formatIso8601(timestamp.time),
+ numStages
+ )
+ }
+
+ GloryFitSupport.SLEEP_INFO_END -> {
+ LOG.debug("Got sleep info end")
+ mCurrentSleepSession = null
+ triggerNextFetch()
+ }
+ }
+ }
+
+ fun handleSleepStages(value: ByteArray) {
+ mCurrentSleepSession?.let {
+ LOG.debug("Got sleep stages at {}", DateTimeUtils.formatIso8601(it.time))
+
+ if ((value.size - 1) % 6 != 0) {
+ LOG.error("Unexpected sleep stages payload size {}", value.size)
+ return
+ }
+
+ val buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
+ buf.get() // discard first byte
+
+ val samples: MutableList = mutableListOf()
+
+ while (buf.position() < buf.limit()) {
+ val timestamp = GregorianCalendar.getInstance()
+ timestamp.timeInMillis = it.timeInMillis
+
+ val sample = GenericSleepStageSample()
+
+ val hour = buf.get().toInt()
+ val minute = buf.get().toInt()
+ val stage = buf.get().toInt()
+ buf.get() // ? 1
+ val duration = buf.getShort()
+
+ if (hour > 12) {
+ // assume times after noon correspond to the previous day
+ // unless they already come after noon the next day
+ // TODO is this right?
+ if (!mCurrentSessionAfterMidnight) {
+ timestamp.add(Calendar.DATE, -1)
+ }
+ } else {
+ mCurrentSessionAfterMidnight = true
+ }
+
+ timestamp.set(Calendar.HOUR_OF_DAY, hour)
+ timestamp.set(Calendar.MINUTE, minute)
+
+ sample.timestamp = timestamp.timeInMillis
+ sample.stage = stage
+ sample.duration = duration.toInt()
+
+ LOG.debug("Sleep stage at {}: {} for {}", DateTimeUtils.formatIso8601(timestamp.time), stage, duration)
+
+ samples.add(sample)
+ }
+
+ LOG.debug("Persisting {} sleep stage samples", samples.size)
+
+ try {
+ GBApplication.acquireDB().use { handler ->
+ val session = handler.getDaoSession()
+ val sampleProvider = GenericSleepStageSampleProvider(mSupport.device, session)
+
+ sampleProvider.persistForDevice(mSupport.context, mSupport.device, samples)
+ }
+ } catch (e: Exception) {
+ GB.toast(mSupport.context, "Error saving sleep session samples", Toast.LENGTH_LONG, GB.ERROR, e)
+ }
+
+ return
+ }
+
+ LOG.error("Got sleep stages, but sleep session date is unknown")
+ }
+
+ fun handleSteps(value: ByteArray) {
+ when {
+ value.size == 3 && value[1] == GloryFitSupport.FETCH_END -> {
+ LOG.debug("Got steps fetch end")
+ triggerNextFetch()
+ }
+
+ value.size == 18 -> {
+ val buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
+ buf.get() // discard first bytes
+ val timestamp = buf.getDate()
+ timestamp.set(Calendar.HOUR_OF_DAY, buf.get().toInt() and 0xff)
+
+ val sample = GloryFitStepsSample()
+ sample.timestamp = timestamp.timeInMillis
+ sample.totalSteps = buf.getShort().toInt()
+ sample.runningStart = buf.get().toInt()
+ sample.runningEnd = buf.get().toInt()
+ buf.get() // unk
+ sample.runningSteps = buf.getShort().toInt()
+ sample.walkingStart = buf.get().toInt()
+ sample.walkingEnd = buf.get().toInt()
+ buf.get() // unk
+ sample.walkingSteps = buf.getShort().toInt()
+
+ LOG.debug("Steps {}: {}", DateTimeUtils.formatIso8601(timestamp.time), sample.totalSteps)
+
+ try {
+ GBApplication.acquireDB().use { handler ->
+ val session = handler.getDaoSession()
+ val sampleProvider = GloryFitStepsSampleProvider(mSupport.device, session)
+
+ sampleProvider.persistForDevice(
+ mSupport.context,
+ mSupport.device,
+ Collections.singletonList(sample)
+ )
+ }
+ } catch (e: Exception) {
+ GB.toast(mSupport.context, "Error saving steps samples", Toast.LENGTH_LONG, GB.ERROR, e)
+ }
+ }
+
+ else -> LOG.warn("Unknown steps command {}", value.toHexString())
+ }
+ }
+
+ fun handleHeartRate(value: ByteArray): Boolean {
+ when {
+ value.size == 18 && value[1] == GloryFitSupport.FETCH_DATA -> {
+ // f707e90703083030315c2f2f2e2f2f353331
+ // 07:40 - 53
+ // 07:20 - 47
+ // 07:00 - 47
+
+ val buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
+ buf.get() // discard first byte
+ val timestamp = buf.getDate()
+ timestamp.set(Calendar.HOUR_OF_DAY, buf.get().toInt() and 0xff)
+
+ timestamp.add(Calendar.MINUTE, -10 * (buf.limit() - buf.position()) + 10)
+
+ val samples: MutableList = mutableListOf()
+
+ while (buf.position() < buf.limit()) {
+ val hr = buf.get().toInt() and 0xff
+ if (hr != 0xff && hr != 0) {
+ val sample = GenericHeartRateSample()
+ sample.timestamp = timestamp.timeInMillis
+ sample.heartRate = hr
+ samples.add(sample)
+ LOG.trace("HR {}: {}", DateTimeUtils.formatIso8601(timestamp.time), hr)
+ }
+
+ timestamp.add(Calendar.MINUTE, 10)
+ }
+
+ LOG.debug("Persisting {} HR samples", samples.size)
+
+ try {
+ GBApplication.acquireDB().use { handler ->
+ val session = handler.getDaoSession()
+ val sampleProvider = GenericHeartRateSampleProvider(mSupport.device, session)
+
+ sampleProvider.persistForDevice(mSupport.context, mSupport.device, samples)
+ }
+ } catch (e: Exception) {
+ GB.toast(mSupport.context, "Error saving hr samples", Toast.LENGTH_LONG, GB.ERROR, e)
+ }
+ }
+
+ value.size == 3 && value[1] == GloryFitSupport.FETCH_END -> {
+ LOG.debug("Got hr fetch end")
+ triggerNextFetch()
+ }
+
+ else -> return false
+ }
+
+ return true
+ }
+
+ fun handleSpO2(value: ByteArray): Boolean {
+ when (value[2]) {
+ GloryFitSupport.FETCH_DATA -> {
+ // 34fa07e907030400ffff60ffff61ffff61ffff60
+ // Data in blocks of 10 minutes
+ // 04:00 - 96, 03:50 N/A
+ // 03:30 - 97
+ // 03:00 - 97
+ // 02:30 - 96
+
+ if (value.size != 20) {
+ LOG.error("Unexpected spo2 data length {}", value.size)
+ return true
+ }
+
+ val buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
+ buf.get(ByteArray(2)) // discard first 2 bytes
+ val timestamp = buf.getDate()
+ timestamp.set(Calendar.HOUR_OF_DAY, buf.get().toInt() and 0xff)
+ timestamp.set(Calendar.MINUTE, buf.get().toInt() and 0xff)
+
+ timestamp.add(Calendar.MINUTE, -10 * (buf.limit() - buf.position()) + 10)
+
+ val samples: MutableList = mutableListOf()
+
+ while (buf.position() < buf.limit()) {
+ val spo2 = buf.get().toInt() and 0xff
+ if (spo2 != 0xff && spo2 != 0) {
+ val sample = GenericSpo2Sample()
+ sample.timestamp = timestamp.timeInMillis
+ sample.spo2 = spo2
+ samples.add(sample)
+ LOG.trace("SpO2 {}: {}", DateTimeUtils.formatIso8601(timestamp.time), spo2)
+ }
+
+ timestamp.add(Calendar.MINUTE, 10)
+ }
+
+ LOG.debug("Persisting {} SpO2 samples", samples.size)
+
+ try {
+ GBApplication.acquireDB().use { handler ->
+ val session = handler.getDaoSession()
+ val sampleProvider = GenericSpo2SampleProvider(mSupport.device, session)
+
+ sampleProvider.persistForDevice(mSupport.context, mSupport.device, samples)
+ }
+ } catch (e: Exception) {
+ GB.toast(mSupport.context, "Error saving SpO2 samples", Toast.LENGTH_LONG, GB.ERROR, e)
+ }
+ }
+
+ GloryFitSupport.FETCH_END -> {
+ LOG.debug("Got SpO2 fetch end")
+ triggerNextFetch()
+ }
+
+ else -> return false
+ }
+
+ return true
+ }
+
+ fun ByteBuffer.getDate(): Calendar {
+ val timestamp = GregorianCalendar.getInstance()
+
+ timestamp.set(Calendar.YEAR, getShort().toInt() and 0xffff)
+ timestamp.set(Calendar.MONTH, (get().toInt() and 0xff) - 1)
+ timestamp.set(Calendar.DATE, get().toInt() and 0xff)
+ timestamp.set(Calendar.HOUR_OF_DAY, 0)
+ timestamp.set(Calendar.MINUTE, 0)
+ timestamp.set(Calendar.SECOND, 0)
+ timestamp.set(Calendar.MILLISECOND, 0)
+
+ return timestamp
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitLanguage.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitLanguage.kt
new file mode 100644
index 0000000000..091472dc93
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitLanguage.kt
@@ -0,0 +1,51 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.gloryfit
+
+enum class GloryFitLanguage(val locale: String, val code: Byte) {
+ CHINESE_SIMPLIFIED("zh_CN", 0x01),
+ CHINESE_TRADITIONAL("zh_TW", 0x17),
+ ENGLISH("en", 0x02),
+ KOREAN("ko", 0x03),
+ JAPANESE("ja", 0x04),
+ GERMAN("de", 0x05),
+ SPANISH("es", 0x06),
+ FRENCH("fr", 0x07),
+ ITALIAN("it", 0x08),
+ PORTUGUESE("pt", 0x09),
+ ARABIC("ar", 0x0a),
+ POLISH("pl", 0x0d),
+ RUSSIAN("ru", 0x0e),
+ DUTCH("nl", 0x0f),
+ TURKISH("tr", 0x10),
+ BENGALI("bn", 0x11),
+ INDONESIAN("id", 0x13),
+ CZECH("cs", 0x16),
+ HEBREW("he", 0x18),
+ THAI("th", 0x15),
+ PERSIAN("fa", 0x28),
+ VIETNAMESE("vi", 0x63),
+ ;
+
+ companion object {
+ fun fromLocale(locale: String): GloryFitLanguage? {
+ return entries.find { language -> language.locale == locale }
+ // Fallback - attempt to find the next closest one
+ ?: entries.find { language -> language.locale.substring(0, 2) == locale.substring(0, 2) }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitNotificationType.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitNotificationType.kt
new file mode 100644
index 0000000000..5ea81d4a1d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitNotificationType.kt
@@ -0,0 +1,88 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.gloryfit
+
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationType
+
+enum class GloryFitNotificationType(val code: Byte) {
+ CALL(0),
+ QQ(1),
+ WECHAT(2),
+ SMS(3),
+ UNKNOWN_APP(4),
+ FACEBOOK(5),
+ TWITTER(6),
+ WHATSAPP(7),
+ SKYPE(8),
+ FACEBOOK_MESSENGER(9),
+ HANGOUTS(10),
+ LINE(11),
+ LINKEDIN(12),
+ INSTAGRAM(13),
+ VIBER(14),
+ KAKAO_TALK(15),
+ VK(16),
+ SNAPCHAT(17),
+ GOOGLE_PLUS(18),
+ EMAIL(19),
+ UNK_BLUE_RED_DOT(20),
+ TUMBLR(21),
+ PINTEREST(22),
+ YOUTUBE(23),
+ TELEGRAM(24),
+ NO_ICON(25),
+ ;
+
+ companion object {
+ fun fromNotificationType(type: NotificationType): GloryFitNotificationType {
+ when (type) {
+ NotificationType.CONVERSATIONS, NotificationType.RIOT, NotificationType.HIPCHAT, NotificationType.KONTALK,
+ NotificationType.ANTOX, NotificationType.GENERIC_SMS, NotificationType.WECHAT,
+ NotificationType.SIGNAL-> return WECHAT
+
+ NotificationType.GENERIC_EMAIL, NotificationType.GMAIL, NotificationType.YAHOO_MAIL,
+ NotificationType.OUTLOOK -> return EMAIL
+
+ NotificationType.FACEBOOK -> return FACEBOOK
+ NotificationType.FACEBOOK_MESSENGER -> return FACEBOOK_MESSENGER
+ NotificationType.GOOGLE_HANGOUTS, NotificationType.GOOGLE_MESSENGER -> return HANGOUTS
+ NotificationType.INSTAGRAM, NotificationType.GOOGLE_PHOTOS -> return INSTAGRAM
+ NotificationType.KAKAO_TALK -> return KAKAO_TALK
+ NotificationType.LINE -> return LINE
+ NotificationType.TWITTER -> return TWITTER
+ NotificationType.SKYPE -> return SKYPE
+ NotificationType.SNAPCHAT -> return SNAPCHAT
+ NotificationType.TELEGRAM -> return TELEGRAM
+ NotificationType.VIBER, NotificationType.DISCORD -> return VIBER
+ NotificationType.WHATSAPP -> return WHATSAPP
+ NotificationType.VK -> return VK
+ NotificationType.QQ -> return QQ
+ NotificationType.TUMBLR -> return TUMBLR
+ NotificationType.PINTEREST -> return PINTEREST
+ NotificationType.YOUTUBE -> return YOUTUBE
+
+ else -> {
+ when (type.genericType) {
+ "generic_email" -> return EMAIL
+ "generic_chat" -> return WECHAT
+ }
+ return UNKNOWN_APP
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitSupport.kt b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitSupport.kt
new file mode 100644
index 0000000000..2f7fa1f54d
--- /dev/null
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/gloryfit/GloryFitSupport.kt
@@ -0,0 +1,1320 @@
+/* Copyright (C) 2025 José Rebelo
+
+ This file is part of Gadgetbridge.
+
+ Gadgetbridge is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Gadgetbridge is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see . */
+package nodomain.freeyourgadget.gadgetbridge.service.devices.gloryfit
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCharacteristic
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import nodomain.freeyourgadget.gadgetbridge.GBApplication
+import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity
+import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst
+import nodomain.freeyourgadget.gadgetbridge.database.AppSpecificNotificationSettingsRepository
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCameraRemote
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl
+import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo
+import nodomain.freeyourgadget.gadgetbridge.entities.AppSpecificNotificationSetting
+import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
+import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser
+import nodomain.freeyourgadget.gadgetbridge.model.Alarm
+import nodomain.freeyourgadget.gadgetbridge.model.BatteryState
+import nodomain.freeyourgadget.gadgetbridge.model.CallSpec
+import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec
+import nodomain.freeyourgadget.gadgetbridge.model.Contact
+import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec
+import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec
+import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec
+import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec
+import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport
+import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder
+import nodomain.freeyourgadget.gadgetbridge.service.devices.sony.wena3.protocol.packets.notification.defines.VibrationKind
+import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol
+import nodomain.freeyourgadget.gadgetbridge.util.MediaManager
+import org.apache.commons.lang3.ArrayUtils
+import org.apache.commons.lang3.StringUtils
+import org.slf4j.Logger
+import org.slf4j.LoggerFactory
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.Calendar
+import java.util.GregorianCalendar
+import java.util.Locale
+import java.util.UUID
+import kotlin.math.roundToInt
+
+class GloryFitSupport() : AbstractBTLESingleDeviceSupport(LOG) {
+ init {
+ addSupportedService(UUID_SERVICE_GLORYFIT_CMD)
+ addSupportedService(UUID_SERVICE_GLORYFIT_DATA)
+ }
+
+ private var mMediaManager: MediaManager? = null
+ private var mAppNotificationSettingsRepository: AppSpecificNotificationSettingsRepository? = null
+
+ private val fetcher: GloryFitFetcher = GloryFitFetcher(this)
+
+ private var mHandler: Handler = Handler(Looper.getMainLooper())
+ private val mBatteryStateRequestRunnable: Runnable = Runnable {
+ val builder = createTransactionBuilder("get battery")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(CMD_BATTERY)
+ )
+ builder.queue(queue)
+ }
+
+ override fun useAutoConnect(): Boolean {
+ return true
+ }
+
+ override fun setContext(gbDevice: GBDevice, btAdapter: BluetoothAdapter, context: Context) {
+ super.setContext(gbDevice, btAdapter, context)
+ mMediaManager = MediaManager(context)
+ mAppNotificationSettingsRepository = AppSpecificNotificationSettingsRepository(gbDevice)
+ }
+
+ override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder {
+ fetcher.reset()
+
+ builder.setUpdateState(device, GBDevice.State.INITIALIZING, context)
+
+ builder.requestMtu(247)
+
+ builder.notify(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_READ), true)
+ builder.notify(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_READ), true)
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(CMD_VERSION)
+ )
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(CMD_BATTERY)
+ )
+ if (GBApplication.getPrefs().syncTime()) {
+ setTime(builder)
+ }
+ setUserInfo(builder)
+ setGoalSteps(builder)
+ setGoalCalories(builder)
+ setGoalDistance(builder)
+ setSedentaryReminder(builder)
+ setLanguage(builder)
+ setUnits(builder)
+ setHeartRateMeasurementInterval(builder)
+ if (device.deviceCoordinator.supportsSpo2(device)) {
+ setSpo2Measurement(builder)
+ }
+ setEnableCallRejection(builder)
+ setEnableSmsReply(builder)
+
+ // FIXME this is probably too early
+ builder.setUpdateState(device, GBDevice.State.INITIALIZED, context)
+
+ return builder
+ }
+
+ override fun dispose() {
+ synchronized(ConnectionMonitor) {
+ mHandler.removeCallbacksAndMessages(null)
+ super.dispose()
+ }
+ }
+
+ override fun onCharacteristicChanged(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ value: ByteArray
+ ): Boolean {
+ when (characteristic.uuid) {
+ UUID_CHARACTERISTIC_GLORYFIT_CMD_READ -> {
+ handleCommand(value)
+ return true
+ }
+
+ UUID_CHARACTERISTIC_GLORYFIT_DATA_READ -> {
+ handleData(value)
+ return true
+ }
+
+ }
+
+ return super.onCharacteristicChanged(gatt, characteristic, value)
+ }
+
+ private fun handleCommand(value: ByteArray) {
+ when (value[0]) {
+ CMD_VERSION -> {
+ val versionInfoEvent = GBDeviceEventVersionInfo()
+ versionInfoEvent.fwVersion = String(ArrayUtils.subarray(value, 1, value.size))
+ evaluateGBDeviceEvent(versionInfoEvent)
+ }
+
+ CMD_BATTERY -> {
+ val batteryInfoEvent = GBDeviceEventBatteryInfo()
+ batteryInfoEvent.state = BatteryState.BATTERY_NORMAL
+ batteryInfoEvent.level = value[1].toInt() and 0xff
+ if (value.size > 2 && value[2].toInt() == 0x01) {
+ batteryInfoEvent.state = BatteryState.BATTERY_CHARGING
+ }
+
+ evaluateGBDeviceEvent(batteryInfoEvent)
+ // It only sends proactive updates during charging
+ rearmBatteryStateRequestTimer()
+ }
+
+ CMD_DATE_TIME -> {
+ LOG.debug("Got date time ack")
+ }
+
+ CMD_CAMERA -> {
+ val cameraEvent = GBDeviceEventCameraRemote()
+
+ when (value[1]) {
+ CAMERA_OPEN ->
+ cameraEvent.event = GBDeviceEventCameraRemote.Event.OPEN_CAMERA
+
+ CAMERA_TRIGGER ->
+ cameraEvent.event = GBDeviceEventCameraRemote.Event.TAKE_PICTURE
+
+ CAMERA_CLOSE ->
+ cameraEvent.event = GBDeviceEventCameraRemote.Event.CLOSE_CAMERA
+
+ else -> {
+ LOG.warn("Unknown camera event {}", value[1].toHexString())
+ return
+ }
+ }
+
+ evaluateGBDeviceEvent(cameraEvent)
+ }
+
+ CMD_ACTION -> {
+ when (value[1]) {
+ ACTION_CALL_HANGUP -> {
+ val rejectMethodPref = devicePrefs.getString(
+ DeviceSettingsPreferenceConst.PREF_CALL_REJECT_METHOD,
+ "reject"
+ )
+ var rejectMethod = GBDeviceEventCallControl.Event.REJECT
+ if (rejectMethodPref == "ignore") rejectMethod = GBDeviceEventCallControl.Event.IGNORE
+ evaluateGBDeviceEvent(GBDeviceEventCallControl(rejectMethod))
+
+ // Signal to the watch that the call ended, or it might get stuck indefinitely
+ val callSpec = CallSpec()
+ callSpec.command = CallSpec.CALL_END
+ onSetCallState(callSpec)
+ }
+
+ ACTION_CAMERA_CLOSE -> {
+ evaluateGBDeviceEvent(
+ GBDeviceEventCameraRemote(GBDeviceEventCameraRemote.Event.CLOSE_CAMERA)
+ )
+ }
+
+ ACTION_FIND_PHONE -> {
+ val findPhoneEvent = GBDeviceEventFindPhone()
+
+ if (value.size == 2) {
+ findPhoneEvent.event = GBDeviceEventFindPhone.Event.START
+ evaluateGBDeviceEvent(findPhoneEvent)
+ } else if (value.size == 4 && value[2] == 0x01.toByte() && value[3] == 0x00.toByte()) {
+ findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP
+ evaluateGBDeviceEvent(findPhoneEvent)
+ } else {
+ LOG.warn("Unhandled find phone action")
+ }
+ }
+
+ ACTION_MUSIC_BTN_PLAY -> {
+ evaluateGBDeviceEvent(GBDeviceEventMusicControl(GBDeviceEventMusicControl.Event.PLAYPAUSE))
+ }
+
+ ACTION_MUSIC_BTN_NEXT -> {
+ evaluateGBDeviceEvent(GBDeviceEventMusicControl(GBDeviceEventMusicControl.Event.NEXT))
+ }
+
+ ACTION_MUSIC_BTN_PREV -> {
+ evaluateGBDeviceEvent(GBDeviceEventMusicControl(GBDeviceEventMusicControl.Event.PREVIOUS))
+ }
+
+ ACTION_MUSIC_BTN_VOL_DOWN -> {
+ evaluateGBDeviceEvent(GBDeviceEventMusicControl(GBDeviceEventMusicControl.Event.VOLUMEDOWN))
+ }
+
+ ACTION_MUSIC_BTN_VOL_UP -> {
+ evaluateGBDeviceEvent(GBDeviceEventMusicControl(GBDeviceEventMusicControl.Event.VOLUMEUP))
+ }
+
+ else -> {
+ LOG.warn("Unknown action event event {}", value[1].toHexString())
+ return
+ }
+ }
+ }
+
+ CMD_SLEEP_INFO -> {
+ fetcher.handleSleepInfo(value)
+ }
+
+ CMD_STEPS -> {
+ fetcher.handleSteps(value)
+ }
+
+ CMD_HEART_RATE -> {
+ if (!fetcher.handleHeartRate(value)) {
+ LOG.warn("Unhandled heart rate command {}", value.toHexString())
+ }
+ }
+
+ CMD_SPO2 -> {
+ if (!fetcher.handleSpO2(value)) {
+ LOG.warn("Unknown SpO2 command {}", value.toHexString())
+ }
+ }
+
+ else -> LOG.warn("Unhandled command on characteristic 33: {}", value.toHexString())
+ }
+ }
+
+ private fun handleData(value: ByteArray) {
+ when (value[0]) {
+ CMD_SLEEP_STAGES -> {
+ fetcher.handleSleepStages(value)
+ }
+
+ CMD_SMS_QUICK_REPLY -> {
+ if (value.size > 5) {
+ val buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN)
+ buf.get()
+
+ val numberLength = buf.get().toInt() and 0xff
+ val numberBytes = ByteArray(numberLength)
+ buf.get(numberBytes)
+ val number = String(numberBytes)
+
+ val messageLength = buf.get().toInt() and 0xff
+ val messageBytes = ByteArray(messageLength)
+ buf.get(messageBytes)
+ val message = String(messageBytes)
+
+ if (number.isBlank()) {
+ LOG.warn("No number for quick reply")
+ return
+ }
+ if (message.isBlank()) {
+ LOG.warn("No message for quick reply")
+ return
+ }
+
+ LOG.debug("Replying to {}: {}", number, message)
+
+ val devEvtNotificationControl = GBDeviceEventNotificationControl()
+ devEvtNotificationControl.handle = -1
+ devEvtNotificationControl.phoneNumber = number
+ devEvtNotificationControl.reply = message
+ devEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY
+ evaluateGBDeviceEvent(devEvtNotificationControl)
+
+ val rejectCallCmd = GBDeviceEventCallControl(GBDeviceEventCallControl.Event.REJECT)
+ evaluateGBDeviceEvent(rejectCallCmd)
+
+ val builder = createTransactionBuilder("sms reply ack")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ CMD_SMS_QUICK_REPLY,
+ SMS_QUICK_REPLY_SUCCESS
+ )
+ builder.queue(queue)
+ } else {
+ LOG.warn("Unhandled sms quick reply command")
+ }
+ }
+
+ else -> LOG.warn("Unhandled command on characteristic 34: {}", value.toHexString())
+ }
+ }
+
+ override fun onSendConfiguration(config: String) {
+ val builder = createTransactionBuilder("set config $config")
+
+ when (config) {
+ ActivityUser.PREF_USER_HEIGHT_CM,
+ ActivityUser.PREF_USER_WEIGHT_KG,
+ ActivityUser.PREF_USER_DATE_OF_BIRTH,
+ ActivityUser.PREF_USER_GENDER,
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD,
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD,
+ DeviceSettingsPreferenceConst.PREF_LIFTWRIST_NOSHED -> {
+ setUserInfo(builder)
+ }
+
+ ActivityUser.PREF_USER_STEPS_GOAL -> {
+ setUserInfo(builder) // user info also has steps goal
+ setGoalSteps(builder)
+ }
+
+ ActivityUser.PREF_USER_CALORIES_BURNT -> {
+ setGoalCalories(builder)
+ }
+
+ ActivityUser.PREF_USER_DISTANCE_METERS -> {
+ setGoalDistance(builder)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE,
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD,
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_START,
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_END,
+ DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND -> {
+ setSedentaryReminder(builder)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_LANGUAGE -> {
+ setLanguage(builder)
+ }
+
+ SettingsActivity.PREF_MEASUREMENT_SYSTEM -> {
+ setUserInfo(builder) // user info also has temperature unit
+ setUnits(builder)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_TIMEFORMAT -> {
+ setUnits(builder)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_HEARTRATE_AUTOMATIC_ENABLE -> {
+ setHeartRateMeasurementInterval(builder)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING,
+ DeviceSettingsPreferenceConst.PREF_SPO2_MEASUREMENT_INTERVAL,
+ DeviceSettingsPreferenceConst.PREF_SPO2_MEASUREMENT_TIME,
+ DeviceSettingsPreferenceConst.PREF_SPO2_MEASUREMENT_START,
+ DeviceSettingsPreferenceConst.PREF_SPO2_MEASUREMENT_END -> {
+ setSpo2Measurement(builder)
+ }
+
+ DeviceSettingsPreferenceConst.PREF_ENABLE_SMS_QUICK_REPLY -> {
+ setEnableSmsReply(builder)
+ }
+
+ else -> {
+ super.onSendConfiguration(config)
+ return
+ }
+ }
+
+ builder.queue(queue)
+ }
+
+ override fun onFindPhone(start: Boolean) {
+ val builder = createTransactionBuilder("find phone $start")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_ACTION,
+ ACTION_FIND_PHONE,
+ 0x01,
+ if (start) 0x01 else 0x00
+ )
+ )
+ builder.queue(queue)
+ }
+
+ override fun onFindDevice(start: Boolean) {
+ if (start) {
+ val buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_VIBRATE)
+ buf.put(0x00) // ?
+ buf.put(0x00) // ?
+ buf.put(0x00) // ?
+ buf.put(0x01) // ?
+ buf.put(0x02) // count?
+ buf.put(0x07) // ?
+ buf.put(0x01) // ?
+
+ val builder = createTransactionBuilder("find device")
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array())
+ builder.queue(queue)
+ }
+ }
+
+ override fun onSetContacts(contacts: ArrayList) {
+ // command + count + 0x37 + 0xfb + numberLength + nameLength + 15 + 20
+ val minSize = 2 + 1 + 4 + 15 + 20
+ var chunkSize = mtu - 3
+ if (mtu < minSize) {
+ LOG.warn("MTU is too low - setting contacts might fail")
+ chunkSize = 244
+ }
+
+ LOG.debug("Setting {} contacts", contacts.size)
+
+ val encodedContacts: MutableList = mutableListOf()
+
+ for (contact in contacts) {
+ val numberBytes = contact.number.toByteArray()
+ val nameBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateUtf16BE(contact.name, 20)
+
+ if (numberBytes.size > 15) {
+ LOG.warn("Contact number length {} too long, skipping", contact.number.length)
+ continue
+ }
+
+ val buf = ByteBuffer.allocate(4 + numberBytes.size + nameBytes.size).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_CONTACTS)
+ buf.put(CONTACTS_ADD)
+ buf.put(numberBytes.size.toByte())
+ buf.put(nameBytes.size.toByte())
+ buf.put(numberBytes)
+ buf.put(nameBytes)
+
+ encodedContacts.add(buf.array())
+ }
+
+ val builder = createTransactionBuilder("set contacts")
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE),
+ *byteArrayOf(
+ CMD_CONTACTS,
+ CONTACTS_START,
+ encodedContacts.size.toByte()
+ )
+ )
+
+ val buf = ByteBuffer.allocate(chunkSize).order(ByteOrder.BIG_ENDIAN)
+ var numAdded = 0
+ for (contactBytes in encodedContacts) {
+ if (numAdded == 0) {
+ buf.put(CMD_CONTACTS)
+ buf.put(CONTACTS_ADD)
+ buf.put(0) // numAdded set at the end
+ }
+
+ if (buf.position() + contactBytes.size < buf.limit()) {
+ buf.put(contactBytes)
+ numAdded++
+ } else {
+ LOG.debug("Buffer limit reached, writing {} contacts", numAdded)
+
+ buf.put(2, numAdded.toByte())
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE),
+ *buf.array()
+ )
+ buf.position(0)
+ buf.put(ByteArray(buf.limit()), 0, buf.limit())
+ buf.clear()
+
+ buf.put(CMD_CONTACTS)
+ buf.put(CONTACTS_ADD)
+ buf.put(0) // numAdded set at the end
+ buf.put(contactBytes)
+
+ numAdded = 1
+ }
+ }
+
+ // Send the last few
+ if (numAdded > 0) {
+ LOG.debug("Flushing last {} contacts", numAdded)
+
+ buf.put(2, numAdded.toByte())
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE),
+ *buf.array()
+ )
+ }
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE),
+ *byteArrayOf(
+ CMD_CONTACTS,
+ CONTACTS_END,
+ 0xfd.toByte()
+ )
+ )
+ builder.queue(queue)
+ }
+
+ override fun onSetAlarms(alarms: ArrayList) {
+ val builder = createTransactionBuilder("set alarms")
+
+ for ((i, alarm) in alarms.withIndex()) {
+ // Watch and Gb use a different bitmask
+ var repeatMask = 0
+ if ((alarm.repetition and Alarm.ALARM_MON.toInt()) != 0) repeatMask = repeatMask or ALARM_MON
+ if ((alarm.repetition and Alarm.ALARM_TUE.toInt()) != 0) repeatMask = repeatMask or ALARM_TUE
+ if ((alarm.repetition and Alarm.ALARM_WED.toInt()) != 0) repeatMask = repeatMask or ALARM_WED
+ if ((alarm.repetition and Alarm.ALARM_THU.toInt()) != 0) repeatMask = repeatMask or ALARM_THU
+ if ((alarm.repetition and Alarm.ALARM_FRI.toInt()) != 0) repeatMask = repeatMask or ALARM_FRI
+ if ((alarm.repetition and Alarm.ALARM_SAT.toInt()) != 0) repeatMask = repeatMask or ALARM_SAT
+ if ((alarm.repetition and Alarm.ALARM_SUN.toInt()) != 0) repeatMask = repeatMask or ALARM_SUN
+
+ val buf = ByteBuffer.allocate(9).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_VIBRATE)
+ buf.put(repeatMask.toByte())
+ buf.put(alarm.hour.toByte())
+ buf.put(alarm.minute.toByte())
+ if (alarm.enabled) {
+ buf.put(0x02) // ?
+ buf.put(0x0a) // ?
+ buf.put(0x02) // ?
+ } else {
+ buf.put(0x00) // ?
+ buf.put(0x00) // ?
+ buf.put(0x00) // ?
+ }
+ buf.put(0x00) // ?
+ buf.put((i + 1).toByte()) // alarm index, starting at 1
+
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array().clone())
+ }
+
+ builder.queue(queue)
+ }
+
+ override fun onNotification(notificationSpec: NotificationSpec) {
+ if (!devicePrefs.getBoolean("send_app_notifications", true)) {
+ LOG.debug("App notifications disabled - ignoring")
+ return
+ }
+
+ val senderOrTitle = notificationSpec.sender.takeIf { !it.isNullOrBlank() } ?: notificationSpec.title
+
+ val payloadString = when {
+ StringUtils.isNotBlank(senderOrTitle) && StringUtils.isNotBlank(notificationSpec.body)
+ -> "${senderOrTitle}: ${notificationSpec.body}"
+
+ StringUtils.isNotBlank(senderOrTitle) -> senderOrTitle
+ StringUtils.isNotBlank(notificationSpec.body) -> notificationSpec.body
+
+ else -> "?"
+ }
+
+ val builder = createTransactionBuilder("send notification")
+ sendNotification(
+ builder,
+ payloadString,
+ GloryFitNotificationType.fromNotificationType(notificationSpec.type)
+ )
+
+ var vibrationKind = VibrationKind.BASIC
+ var vibrationCount = 1
+
+ if (notificationSpec.sourceAppId != null) {
+ val appSpecificSetting: AppSpecificNotificationSetting? =
+ mAppNotificationSettingsRepository!!.getSettingsForAppId(notificationSpec.sourceAppId)
+
+ if (appSpecificSetting != null) {
+ if (appSpecificSetting.vibrationPattern != null) {
+ vibrationKind = VibrationKind.valueOf(appSpecificSetting.vibrationPattern.uppercase(Locale.ROOT))
+ }
+
+ if (appSpecificSetting.vibrationRepetition != null) {
+ vibrationCount = appSpecificSetting.vibrationRepetition.toInt()
+ }
+ }
+ }
+
+ if (vibrationKind != VibrationKind.NONE) {
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_VIBRATE,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x01,
+ vibrationCount.toByte(),
+ 0x00,
+ 0x00
+ )
+ )
+ }
+
+ builder.queue(queue)
+ }
+
+ override fun onSetTime() {
+ if (!GBApplication.getPrefs().syncTime()) {
+ return
+ }
+
+ val builder = createTransactionBuilder("set time")
+ setTime(builder)
+ builder.queue(queue)
+ }
+
+ override fun onSetCallState(callSpec: CallSpec) {
+ if (callSpec.command == CallSpec.CALL_OUTGOING) {
+ return
+ }
+
+ if (callSpec.command == CallSpec.CALL_INCOMING) {
+ val caller = when {
+ StringUtils.isNotBlank(callSpec.name) && StringUtils.isNotBlank(callSpec.number)
+ -> "${callSpec.name}: ${callSpec.number}"
+
+ StringUtils.isNotBlank(callSpec.name) -> callSpec.name
+ StringUtils.isNotBlank(callSpec.name) -> callSpec.number
+ else -> "?"
+ }
+
+ val builder = createTransactionBuilder("call incoming")
+
+ sendNotification(
+ builder,
+ caller,
+ GloryFitNotificationType.CALL
+ )
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_VIBRATE,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x01,
+ 0x0a,
+ 0x02,
+ 0x01
+ )
+ )
+
+ val callerBytes = (callSpec.number ?: "").toByteArray()
+ val buf = ByteBuffer.allocate(3 + callerBytes.size).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_QUICK_REPLY_TARGET)
+ buf.put(0xfa.toByte())
+ buf.put(callerBytes.size.toByte())
+ buf.put(callerBytes)
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *buf.array()
+ )
+
+ builder.queue(queue)
+
+ return
+ }
+
+ val builder = createTransactionBuilder("call end")
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_CALL_STATUS,
+ CALL_END
+ )
+ )
+
+ builder.queue(queue)
+ }
+
+ override fun onSetCannedMessages(cannedMessagesSpec: CannedMessagesSpec) {
+ val builder = createTransactionBuilder("set canned messages")
+
+ for ((i, message) in cannedMessagesSpec.cannedMessages.withIndex()) {
+ if (i >= device.deviceCoordinator.getCannedRepliesSlotCount(device)) {
+ LOG.warn(
+ "Got {} canned messages, over the limit of {}",
+ cannedMessagesSpec.cannedMessages.size,
+ device.deviceCoordinator.getCannedRepliesSlotCount(device)
+ )
+ break
+ }
+
+ val messageBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncate(message, 24).toByteArray()
+ val buf = ByteBuffer.allocate(5 + messageBytes.size).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_CANNED_MESSAGES)
+ buf.put(CANNED_MESSAGES_SET)
+ buf.put(cannedMessagesSpec.cannedMessages.size.toByte())
+ buf.put(i.toByte())
+ buf.put(messageBytes.size.toByte())
+ buf.put(messageBytes)
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE), *buf.array())
+ // TODO do we need to throttle?
+ }
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE),
+ *byteArrayOf(
+ CMD_CANNED_MESSAGES,
+ CANNED_MESSAGES_END,
+ 0x00,
+ )
+ )
+
+ builder.queue(queue)
+ }
+
+ override fun onSetMusicState(stateSpec: MusicStateSpec?) {
+ if (!mMediaManager!!.onSetMusicState(stateSpec)) {
+ return
+ }
+
+ val stateByte: Byte = if (stateSpec?.state?.toInt() == MusicStateSpec.STATE_PLAYING) {
+ MUSIC_STATE_PLAYING
+ } else {
+ MUSIC_STATE_PAUSED
+ }
+
+ val builder = createTransactionBuilder("set music state")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE),
+ *byteArrayOf(
+ CMD_MUSIC,
+ MUSIC_STATE_2,
+ stateByte,
+ 0xff.toByte(),
+ 0xff.toByte(),
+ 0xff.toByte(),
+ 0xff.toByte(),
+ 0xff.toByte(),
+ 0xff.toByte(),
+ 0xff.toByte(),
+ 0xff.toByte(),
+ 0xff.toByte(),
+ 0xff.toByte()
+ )
+ )
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_ACTION,
+ ACTION_MUSIC_VOLUME,
+ mMediaManager!!.phoneVolume.toByte()
+ )
+ )
+ builder.queue(queue)
+ }
+
+ override fun onSetMusicInfo(musicSpec: MusicSpec?) {
+ if (!mMediaManager!!.onSetMusicInfo(musicSpec)) {
+ return
+ }
+
+ val builder = createTransactionBuilder("set music info")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE),
+ *byteArrayOf(
+ CMD_MUSIC,
+ MUSIC_STATE_1
+ )
+ )
+ builder.queue(queue)
+ }
+
+ override fun onSetPhoneVolume(volume: Float) {
+ val builder = createTransactionBuilder("set phone volume")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_ACTION,
+ ACTION_MUSIC_VOLUME,
+ volume.toInt().toByte()
+ )
+ )
+ builder.queue(queue)
+ }
+
+ override fun onReset(flags: Int) {
+ if ((flags and GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) != 0) {
+ val builder = createTransactionBuilder("factory reset")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ CMD_FACTORY_RESET
+ )
+ builder.queue(queue)
+ }
+ }
+
+ override fun onEnableRealtimeHeartRateMeasurement(enable: Boolean) {
+ // TODO onEnableRealtimeHeartRateMeasurement
+ }
+
+ override fun onEnableRealtimeSteps(enable: Boolean) {
+ // TODO onEnableRealtimeSteps
+ }
+
+ override fun onSetHeartRateMeasurementInterval(seconds: Int) {
+ val builder = createTransactionBuilder("set heart rate measurement interval = $seconds")
+ setHeartRateMeasurementInterval(builder)
+ builder.queue(queue)
+ }
+
+ override fun onSendWeather(weatherSpecs: ArrayList?) {
+ // TODO onSendWeather
+ }
+
+ override fun onTestNewFunction() {
+
+ }
+
+ override fun onCameraStatusChange(
+ event: GBDeviceEventCameraRemote.Event,
+ filename: String?
+ ) {
+ val builder = createTransactionBuilder("camera status change to $event")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_CAMERA,
+ when (event) {
+ GBDeviceEventCameraRemote.Event.OPEN_CAMERA -> CAMERA_OPEN
+ GBDeviceEventCameraRemote.Event.CLOSE_CAMERA -> CAMERA_CLOSE
+ else -> {
+ LOG.warn("Unknown camera status change {}", event)
+ return
+ }
+ }
+ )
+ )
+ builder.queue(queue)
+ }
+
+ override fun onFetchRecordedData(dataTypes: Int) {
+ fetcher.onFetchRecordedData(dataTypes)
+ }
+
+ private fun setTime(builder: TransactionBuilder) {
+ LOG.debug("Setting time")
+
+ val timestamp = GregorianCalendar.getInstance()
+ val buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_DATE_TIME)
+ buf.putShort(timestamp.get(Calendar.YEAR).toShort())
+ buf.put((timestamp.get(Calendar.MONTH) + 1).toByte())
+ buf.put(timestamp.get(Calendar.DATE).toByte())
+ buf.put(timestamp.get(Calendar.HOUR_OF_DAY).toByte())
+ buf.put(timestamp.get(Calendar.MINUTE).toByte())
+ buf.put(timestamp.get(Calendar.SECOND).toByte())
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array())
+ }
+
+ private fun setUserInfo(builder: TransactionBuilder) {
+ LOG.debug("Setting user info")
+
+ val activityUser = ActivityUser()
+ val devicePrefs = getDevicePrefs()
+
+ val raiseHandToActivateDisplay = devicePrefs.getBoolean(
+ DeviceSettingsPreferenceConst.PREF_LIFTWRIST_NOSHED,
+ false
+ )
+
+ val heartRateAlertHigh = if (devicePrefs.heartRateHighThreshold > 0) {
+ devicePrefs.heartRateHighThreshold.coerceIn(100, 200).toByte()
+ } else {
+ 0xff.toByte()
+ }
+
+ val heartRateAlertLow = if (devicePrefs.heartRateLowThreshold > 0) {
+ devicePrefs.heartRateLowThreshold.coerceIn(40, 100).toByte()
+ } else {
+ 0x00.toByte()
+ }
+
+ val measurementSystem =
+ GBApplication.getPrefs().getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, "metric")
+
+ val buf = ByteBuffer.allocate(19).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_USER_INFO)
+ buf.putShort(activityUser.heightCm.coerceIn(91, 241).toShort())
+ buf.putShort(activityUser.weightKg.coerceIn(20, 255).toShort())
+ buf.put(0x05) // ?
+ buf.put(0x00) // ?
+ buf.put(0x00) // ?
+ buf.putShort(((activityUser.stepsGoal / 1000.0).roundToInt() * 1000).coerceIn(1000, 30000).toShort())
+ buf.put(if (raiseHandToActivateDisplay) 0x01 else 0x00)
+ buf.put(heartRateAlertHigh)
+ buf.put(0x00) // ?
+ buf.put(activityUser.age.coerceIn(3, 100).toByte())
+ buf.put(
+ when (activityUser.gender) {
+ ActivityUser.GENDER_MALE -> 0x01
+ ActivityUser.GENDER_FEMALE -> 0x02
+ else -> 0x01 // other?
+ }
+ )
+ buf.put(0x00) // ?
+ buf.put(if (measurementSystem == "metric") 0x02 else 0x01) // 0x02 celsius, 0x01 fahrenheit
+ buf.put(0x01) // ?
+ buf.put(heartRateAlertLow)
+
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array())
+ }
+
+ fun setGoalSteps(builder: TransactionBuilder) {
+ val activityUser = ActivityUser()
+
+ val stepsCoerced = ((activityUser.stepsGoal / 1000.0).roundToInt() * 1000).coerceIn(1000, 30000).toShort()
+
+ LOG.debug("Setting steps goal to {}", stepsCoerced)
+
+ val buf = ByteBuffer.allocate(7).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_GOALS)
+ buf.put(GOAL_STEPS)
+ buf.put(0x01) // ?
+ buf.put(0x00) // ?
+ buf.put(0x00) // ?
+ buf.putShort(stepsCoerced)
+
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array())
+ }
+
+ fun setGoalCalories(builder: TransactionBuilder) {
+ val activityUser = ActivityUser()
+
+ val caloriesCoerced = ((activityUser.caloriesBurntGoal / 50.0).roundToInt() * 50).coerceIn(50, 1000).toShort()
+
+ LOG.debug("Setting calories goal to {}", caloriesCoerced)
+
+ val buf = ByteBuffer.allocate(7).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_GOALS)
+ buf.put(GOAL_CALORIES)
+ buf.put(0x01) // ?
+ buf.putShort(caloriesCoerced)
+
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array())
+ }
+
+ fun setGoalDistance(builder: TransactionBuilder) {
+ val activityUser = ActivityUser()
+
+ val distanceCoerced =
+ ((activityUser.distanceGoalMeters / 1000f).roundToInt() * 1000).coerceIn(1000, 20000).toShort()
+
+ LOG.debug("Setting distance goal to {}", distanceCoerced)
+
+ val buf = ByteBuffer.allocate(7).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_GOALS)
+ buf.put(GOAL_DISTANCE)
+ buf.put(0x01) // ?
+ buf.put(0x00) // ?
+ buf.put(0x00) // ?
+ buf.putShort(distanceCoerced)
+
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array())
+ }
+
+ private fun setSedentaryReminder(builder: TransactionBuilder) {
+ val devicePrefs = getDevicePrefs()
+
+ val enabled = devicePrefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE, false)
+ val startTime = devicePrefs.getLocalTime(DeviceSettingsPreferenceConst.PREF_INACTIVITY_START, "06:00")
+ val endTime = devicePrefs.getLocalTime(DeviceSettingsPreferenceConst.PREF_INACTIVITY_END, "22:00")
+ val duration = devicePrefs.getInt(DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD, 60)
+ val lunchBreak = devicePrefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND, false)
+
+ LOG.debug(
+ "Setting sedentary reminder, enabled={} startTime={} endTime={} duration={} lunchBreak={}",
+ enabled, startTime, endTime, duration, lunchBreak
+ )
+
+ val buf = ByteBuffer.allocate(12).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_SEDENTARY_REMINDER)
+ buf.put(if (enabled) 0x01 else 0x00)
+ buf.put(((duration / 5.0).roundToInt() * 5).coerceIn(30, 180).toByte())
+ buf.put(0x02) // ?
+ buf.put(0x03) // ?
+ buf.put(0x01) // ?
+ buf.put(0x00) // ?
+ if (endTime.isAfter(startTime)) {
+ buf.put(startTime.hour.toByte())
+ buf.put(startTime.minute.toByte())
+ buf.put(endTime.hour.toByte())
+ buf.put(endTime.minute.toByte())
+ } else {
+ buf.put(endTime.hour.toByte())
+ buf.put(endTime.minute.toByte())
+ buf.put(startTime.hour.toByte())
+ buf.put(startTime.minute.toByte())
+ }
+ buf.put(if (lunchBreak) 0x01 else 0x00) // 12:00 - 14:00 hardcoded
+
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array())
+ }
+
+ private fun setLanguage(builder: TransactionBuilder) {
+ var localeString: String = devicePrefs.getString("language", "auto")
+ if (StringUtils.isBlank(localeString) || localeString == "auto") {
+ val language = Locale.getDefault().language
+ val country = Locale.getDefault().country
+
+ localeString = language + "_" + country.uppercase(Locale.getDefault())
+ }
+
+ val language = GloryFitLanguage.fromLocale(localeString) ?: run {
+ LOG.warn("Unable to map language for {}, falling back to english", localeString)
+ GloryFitLanguage.ENGLISH
+ }
+
+ LOG.debug("Setting language to {} -> {}", localeString, language)
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_LANGUAGE,
+ LANGUAGE_SET,
+ language.code
+ )
+ )
+ }
+
+ private fun setUnits(builder: TransactionBuilder) {
+ val measurementSystem =
+ GBApplication.getPrefs().getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, "metric")
+ val devicePrefs = getDevicePrefs()
+
+ val metric = measurementSystem == "metric"
+ val timeFormat24h = DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_24H == devicePrefs.timeFormat
+
+ LOG.debug("Setting units metric={} 24h={}", metric, timeFormat24h)
+
+ val buf = ByteBuffer.allocate(3).order(ByteOrder.BIG_ENDIAN)
+ buf.put(CMD_UNITS)
+ buf.put(if (metric) 0x01 else 0x02)
+ buf.put(if (timeFormat24h) 0x01 else 0x02)
+
+ builder.write(getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE), *buf.array())
+ }
+
+ private fun setHeartRateMeasurementInterval(builder: TransactionBuilder) {
+ val enabled = devicePrefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_AUTOMATIC_ENABLE, false)
+ LOG.debug("Setting heart rate measurement enabled = {}", enabled)
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_HEART_RATE,
+ if (enabled) HEART_RATE_MEASURING_ON else HEART_RATE_MEASURING_OFF
+ )
+ )
+ }
+
+ private fun setSpo2Measurement(builder: TransactionBuilder) {
+ val enabled = devicePrefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false)
+ val intervalSeconds = devicePrefs.getInt(DeviceSettingsPreferenceConst.PREF_SPO2_MEASUREMENT_INTERVAL, 600)
+ val restrictTime = devicePrefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_MEASUREMENT_TIME, false)
+ val startTime = devicePrefs.getLocalTime(DeviceSettingsPreferenceConst.PREF_SPO2_MEASUREMENT_START, "06:00")
+ val endTime = devicePrefs.getLocalTime(DeviceSettingsPreferenceConst.PREF_SPO2_MEASUREMENT_END, "22:00")
+
+ LOG.debug(
+ "Setting sedentary reminder, enabled={} intervalSeconds={} restrictTime={} startTime={} endTime={}",
+ enabled, intervalSeconds, restrictTime, startTime, endTime
+ )
+
+ val bufEnabled = ByteBuffer.allocate(5).order(ByteOrder.BIG_ENDIAN)
+ bufEnabled.put(CMD_SPO2)
+ bufEnabled.put(SPO2_MONITORING_ENABLED)
+ bufEnabled.put(if (enabled) 0x01 else 0x00)
+ bufEnabled.putShort((intervalSeconds / 60).toShort())
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *bufEnabled.array()
+ )
+
+ val bufTimeInterval: ByteBuffer = ByteBuffer.allocate(7).order(ByteOrder.BIG_ENDIAN)
+ bufTimeInterval.put(CMD_SPO2)
+ bufTimeInterval.put(SPO2_MONITORING_TIME)
+ bufTimeInterval.put(if (restrictTime) 0x01 else 0x00)
+ bufTimeInterval.put(startTime.hour.toByte())
+ bufTimeInterval.put(startTime.minute.toByte())
+ bufTimeInterval.put(endTime.hour.toByte())
+ bufTimeInterval.put(endTime.minute.toByte())
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *bufTimeInterval.array()
+ )
+ }
+
+ private fun setEnableCallRejection(builder: TransactionBuilder) {
+ // In the official app, this is configurable
+ // but if we disable it, the watch shows the button anyway and freezes?
+ LOG.debug("Setting enable call reject button")
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_CALL_REJECT_WITH_BUTTON,
+ 0x08, // 0x00 for disabled
+ 0x16,
+ 0x00,
+ 0x08,
+ 0x00,
+ 0x01,
+ )
+ )
+ }
+
+ private fun setEnableSmsReply(builder: TransactionBuilder) {
+ val enabled = devicePrefs.getBoolean(DeviceSettingsPreferenceConst.PREF_ENABLE_SMS_QUICK_REPLY, true)
+ LOG.debug("Setting enable sms reply = {}", enabled)
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(
+ CMD_SMS_QUICK_REPLY,
+ if (enabled) 0x01 else 0x00,
+ )
+ )
+ }
+
+ private fun rearmBatteryStateRequestTimer() {
+ mHandler.removeCallbacks(mBatteryStateRequestRunnable)
+ val devicePrefs = getDevicePrefs()
+ if (devicePrefs.batteryPollingEnabled) {
+ LOG.debug("Rearming battery state request timer")
+ mHandler.postDelayed(
+ mBatteryStateRequestRunnable,
+ devicePrefs.batteryPollingIntervalMinutes * 60 * 1000L
+ )
+ }
+ }
+
+ private fun sendNotification(
+ builder: TransactionBuilder,
+ content: String,
+ type: GloryFitNotificationType
+ ) {
+ // Split into chunks. We might be able to send this all in a single chunk that fits the MTU?
+ // official app send to send in chunks of 20 bytes though
+
+ val truncatedPayload = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateUtf16BE(content, 240)
+ var byteIdx = 0
+ var chunkIdx = 0
+ val buf = ByteBuffer.allocate(20).order(ByteOrder.BIG_ENDIAN)
+
+ while (byteIdx < truncatedPayload.size) {
+ buf.clear()
+ buf.put(CMD_NOTIFICATION)
+ buf.put(chunkIdx.toByte())
+ if (chunkIdx == 0) {
+ buf.put(type.code)
+ buf.put(truncatedPayload.size.toByte())
+ }
+ val endPos = truncatedPayload.size.coerceAtMost(byteIdx + buf.limit() - buf.position())
+ buf.put(
+ truncatedPayload.copyOfRange(byteIdx, endPos)
+ )
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *buf.array().copyOfRange(0, buf.position())
+ )
+ byteIdx = endPos
+ chunkIdx++
+ }
+
+ LOG.debug("Sending notification in {} chunks", chunkIdx)
+
+ builder.write(
+ getCharacteristic(UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE),
+ *byteArrayOf(CMD_NOTIFICATION, 0xfd.toByte())
+ )
+ }
+
+ companion object {
+ private val LOG: Logger = LoggerFactory.getLogger(GloryFitSupport::class.java)
+
+ val UUID_SERVICE_GLORYFIT_CMD: UUID = UUID.fromString("000055ff-0000-1000-8000-00805f9b34fb")
+ val UUID_CHARACTERISTIC_GLORYFIT_CMD_WRITE: UUID = UUID.fromString("000033f1-0000-1000-8000-00805f9b34fb")
+ val UUID_CHARACTERISTIC_GLORYFIT_CMD_READ: UUID = UUID.fromString("000033f2-0000-1000-8000-00805f9b34fb")
+
+ val UUID_SERVICE_GLORYFIT_DATA: UUID = UUID.fromString("000056ff-0000-1000-8000-00805f9b34fb")
+ val UUID_CHARACTERISTIC_GLORYFIT_DATA_WRITE: UUID = UUID.fromString("000034f1-0000-1000-8000-00805f9b34fb")
+ val UUID_CHARACTERISTIC_GLORYFIT_DATA_READ: UUID = UUID.fromString("000034f2-0000-1000-8000-00805f9b34fb")
+
+ const val CMD_VERSION: Byte = 0xa1.toByte()
+ const val CMD_BATTERY: Byte = 0xa2.toByte()
+ const val CMD_DATE_TIME: Byte = 0xa3.toByte()
+ const val CMD_CAMERA: Byte = 0xc4.toByte()
+ const val CAMERA_OPEN: Byte = 0x01.toByte()
+ const val CAMERA_TRIGGER: Byte = 0x02.toByte()
+ const val CAMERA_CLOSE: Byte = 0x03.toByte()
+ const val CMD_CANNED_MESSAGES: Byte = 0x46.toByte()
+ const val CANNED_MESSAGES_SET: Byte = 0xfa.toByte()
+ const val CANNED_MESSAGES_END: Byte = 0xfd.toByte()
+ const val CMD_NOTIFICATION: Byte = 0xc5.toByte()
+ const val CMD_SMS_QUICK_REPLY: Byte = 0x52.toByte()
+ const val SMS_QUICK_REPLY_SUCCESS: Byte = 0xfe.toByte()
+ const val CMD_CALL_REJECT_WITH_BUTTON: Byte = 0xd7.toByte()
+ const val CMD_STEPS: Byte = 0xb2.toByte()
+ const val FETCH_START: Byte = 0xfa.toByte()
+ const val FETCH_DATA: Byte = 0x07.toByte()
+ const val FETCH_END: Byte = 0xfd.toByte()
+ const val CMD_HEART_RATE: Byte = 0xf7.toByte()
+ const val HEART_RATE_MEASURING_ON: Byte = 0x01.toByte()
+ const val HEART_RATE_MEASURING_OFF: Byte = 0x02.toByte()
+ const val CMD_SPO2: Byte = 0x34.toByte()
+ const val SPO2_MONITORING_ENABLED: Byte = 0x03.toByte()
+ const val SPO2_MONITORING_TIME: Byte = 0x04.toByte()
+ const val CMD_SLEEP_INFO: Byte = 0x31.toByte()
+ const val SLEEP_INFO_DATE: Byte = 0x01.toByte()
+ const val SLEEP_INFO_END: Byte = 0x02.toByte()
+ const val CMD_SLEEP_STAGES: Byte = 0x32.toByte()
+ const val CMD_ACTION: Byte = 0xd1.toByte()
+ const val ACTION_CAMERA_CLOSE: Byte = 0x0f.toByte()
+ const val ACTION_FIND_PHONE: Byte = 0x0a.toByte()
+ const val ACTION_CALL_HANGUP: Byte = 0x02.toByte()
+ const val ACTION_MUSIC_BTN_PLAY: Byte = 0x07.toByte()
+ const val ACTION_MUSIC_BTN_NEXT: Byte = 0x08.toByte()
+ const val ACTION_MUSIC_BTN_PREV: Byte = 0x09.toByte()
+ const val ACTION_MUSIC_BTN_VOL_DOWN: Byte = 0x0e.toByte()
+ const val ACTION_MUSIC_BTN_VOL_UP: Byte = 0x0d.toByte()
+ const val ACTION_MUSIC_VOLUME: Byte = 0x0d.toByte()
+ const val CMD_USER_INFO: Byte = 0xa9.toByte()
+ const val CMD_UNITS: Byte = 0xa0.toByte()
+ const val CMD_SEDENTARY_REMINDER: Byte = 0xd3.toByte()
+ const val CMD_GOALS: Byte = 0x3f.toByte()
+ const val GOAL_CALORIES: Byte = 0x03.toByte()
+ const val GOAL_DISTANCE: Byte = 0x05.toByte()
+ const val GOAL_STEPS: Byte = 0x04.toByte()
+ const val CMD_LANGUAGE: Byte = 0xaf.toByte()
+ const val LANGUAGE_LIST: Byte = 0xaa.toByte()
+ const val LANGUAGE_SET: Byte = 0xab.toByte()
+ const val CMD_VIBRATE: Byte = 0xab.toByte()
+ const val CMD_CALL_STATUS: Byte = 0xc1.toByte()
+ const val CMD_QUICK_REPLY_TARGET: Byte = 0x45.toByte()
+ const val CALL_END: Byte = 0x04.toByte()
+ const val ALARM_MON: Int = (1 shl 1)
+ const val ALARM_TUE: Int = (1 shl 2)
+ const val ALARM_WED: Int = (1 shl 3)
+ const val ALARM_THU: Int = (1 shl 4)
+ const val ALARM_FRI: Int = (1 shl 5)
+ const val ALARM_SAT: Int = (1 shl 6)
+ const val ALARM_SUN: Int = (1 shl 0)
+ const val CMD_MUSIC: Byte = 0x3a.toByte()
+ const val MUSIC_STATE_1: Byte = 0xfa.toByte()
+ const val MUSIC_STATE_2: Byte = 0x01.toByte()
+ const val MUSIC_STATE_PLAYING: Byte = 0x02.toByte()
+ const val MUSIC_STATE_PAUSED: Byte = 0x01.toByte()
+ const val CMD_CONTACTS: Byte = 0x37.toByte()
+ const val CONTACTS_START: Byte = 0xfa.toByte()
+ const val CONTACTS_ADD: Byte = 0xfb.toByte()
+ const val CONTACTS_END: Byte = 0xfc.toByte()
+ const val CMD_FACTORY_RESET: Byte = 0xad.toByte()
+ }
+}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/AsynchronousResponse.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/AsynchronousResponse.java
index cbfca13c23..45ece38954 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/AsynchronousResponse.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huawei/AsynchronousResponse.java
@@ -394,7 +394,7 @@ public class AsynchronousResponse {
callControlEvent.event = GBDeviceEventCallControl.Event.REJECT;
LOG.info("Rejected call");
- if (!prefs.getBoolean("enable_call_reject", true)) {
+ if (!prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_ENABLE_CALL_REJECT, true)) {
LOG.info("Disabled rejecting calls, ignoring");
return;
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java
index f25b9ec9ce..55cf10071f 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java
@@ -22,6 +22,7 @@ import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -223,4 +224,25 @@ public class StringUtils {
}
return result.toString();
}
+
+ public static byte[] truncateUtf16BE(final String str, final int maxBytes) {
+ final byte[] utf16Bytes = str.getBytes(StandardCharsets.UTF_16BE);
+
+ // UTF-16 code units are 2 bytes, so truncate at even boundary
+ int limit = Math.min(maxBytes, utf16Bytes.length);
+ if (limit % 2 != 0) {
+ limit -= 1;
+ }
+
+ // Check for surrogate pair at the cut point
+ int highByte = utf16Bytes[limit - 2] & 0xFF;
+ int lowByte = utf16Bytes[limit - 1] & 0xFF;
+ int lastCodeUnit = (highByte << 8) | lowByte;
+ // If it's a high surrogate (0xD800 to 0xDBFF), remove 2 more bytes to drop the full pair
+ if (lastCodeUnit >= 0xD800 && lastCodeUnit <= 0xDBFF) {
+ limit -= 2;
+ }
+
+ return Arrays.copyOfRange(utf16Bytes, 0, limit);
+ }
}
diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/DevicePrefs.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/DevicePrefs.java
index ff3f861cba..7a7b469051 100644
--- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/DevicePrefs.java
+++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/preferences/DevicePrefs.java
@@ -25,6 +25,8 @@ import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.Dev
import android.content.SharedPreferences;
import android.text.format.DateFormat;
+import androidx.annotation.NonNull;
+
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.model.BatteryConfig;
@@ -72,6 +74,7 @@ public class DevicePrefs extends Prefs {
return getBoolean("fetch_unknown_files", false);
}
+ @NonNull
public String getTimeFormat() {
String timeFormat = getString(DeviceSettingsPreferenceConst.PREF_TIMEFORMAT, DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_AUTO);
if (DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_AUTO.equals(timeFormat)) {
@@ -136,4 +139,12 @@ public class DevicePrefs extends Prefs {
// either set to default, unknown option selected, or has not been set
return DeviceCoordinator.ConnectionType.BOTH;
}
+
+ public int getHeartRateHighThreshold() {
+ return getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD, 0);
+ }
+
+ public int getHeartRateLowThreshold() {
+ return getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD, 0);
+ }
}
diff --git a/app/src/main/res/layout/activity_notification_per_app_setting_detail.xml b/app/src/main/res/layout/activity_notification_per_app_setting_detail.xml
index 875644d00c..57ffa0860a 100644
--- a/app/src/main/res/layout/activity_notification_per_app_setting_detail.xml
+++ b/app/src/main/res/layout/activity_notification_per_app_setting_detail.xml
@@ -8,7 +8,7 @@
tools:layout_editor_absoluteY="81dp">
+ app:layout_constraintTop_toBottomOf="@+id/textViewLedColorTitle" />
+ app:layout_constraintTop_toBottomOf="@+id/textViewVibration" />
@string/finnish
- @string/hungarian
- @string/bahasa_melayu
+ - @string/persian
+
+ - @string/english
+ - @string/korean
+ - @string/japanese
+ - @string/german
+ - @string/spanish
+ - @string/french
+ - @string/italian
+ - @string/portuguese
+ - @string/arabic
+ - @string/polish
+ - @string/russian
+ - @string/dutch
+ - @string/turkish
+ - @string/bengali
+ - @string/indonesian
+ - @string/czech
+ - @string/hebrew
+ - @string/thai
+ - @string/persian
+ - @string/vietnamese
@@ -2749,6 +2771,28 @@
- fi_FI
- hu_HU
- ms_MY
+ - fa_IR
+
+ - en
+ - ko
+ - ja
+ - de
+ - es
+ - fr
+ - it
+ - pt
+ - ar
+ - pl
+ - ru
+ - nl
+ - tr
+ - bn
+ - id
+ - cs
+ - he
+ - th
+ - fa
+ - vi
@@ -2847,6 +2891,26 @@
- 0
+
+ - @string/interval_ten_minutes
+ - @string/interval_thirty_minutes
+ - @string/interval_1_hour
+ - @string/interval_2_hour
+ - @string/interval_3_hour
+ - @string/interval_4_hour
+ - @string/interval_6_hour
+
+
+
+ - 600
+ - 1800
+ - 3600
+ - 7200
+ - 10800
+ - 14400
+ - 21600
+
+
- @string/heartrate_bpm_100
- @string/heartrate_bpm_105
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index ff968d5af4..47b1f639b4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -265,6 +265,7 @@
Auto wake and unlock
Wake and unlock the Android device when the gadget sends a OPEN response back. Needs to be in a trusted state.
Perform and receive calls directly on the watch
+ Phone calls
Bluetooth calls
Bluetooth calls pairing
Bluetooth calls settings
@@ -823,6 +824,11 @@
90%
Off
once an hour
+ every 1 hour
+ every 2 hours
+ every 3 hours
+ every 4 hours
+ every 6 hours
Speed zones
Total minutes
Steps per minute
@@ -1144,6 +1150,7 @@
The band will vibrate when you have been inactive for a while
Inactivity threshold (in minutes)
Disable inactivity warnings for a time interval
+ Disable inactivity warnings during the lunch break (12:00 - 14:00)
Disable hydration warnings for a time interval
Heart Rate Monitoring
Configure heart rate monitoring
@@ -1193,8 +1200,8 @@
Interface language
Automatic
Manual
- Simplified Chinese
- Traditional Chinese
+ Chinese (Simplified)
+ Chinese (Traditional)
English
English (Australia)
English (Canada)
@@ -3276,6 +3283,8 @@
Enable accepting calls from the device
Enable rejecting calls
Enable rejecting calls from the device
+ Enable SMS replies
+ Allow rejecting calls with an SMS reply
Disable find my phone when do not disturb is active
@@ -4121,4 +4130,8 @@
Print size (px): %1$d x %2$d
Gadgetbridge Notification Monitor
Monitoring notifications in background
+ Oukitel BT103
+ Dotn P66D
+ Time interval
+ Restrict to specific intervals
diff --git a/app/src/main/res/xml/devicesettings_allow_accept_reject_calls.xml b/app/src/main/res/xml/devicesettings_allow_accept_calls.xml
similarity index 58%
rename from app/src/main/res/xml/devicesettings_allow_accept_reject_calls.xml
rename to app/src/main/res/xml/devicesettings_allow_accept_calls.xml
index 59b889472f..4a0664e5d3 100644
--- a/app/src/main/res/xml/devicesettings_allow_accept_reject_calls.xml
+++ b/app/src/main/res/xml/devicesettings_allow_accept_calls.xml
@@ -7,11 +7,4 @@
android:layout="@layout/preference_checkbox"
android:title="@string/pref_enable_call_accept"
android:summary="@string/pref_enable_call_accept_summary" />
-
\ No newline at end of file
diff --git a/app/src/main/res/xml/devicesettings_allow_reject_calls.xml b/app/src/main/res/xml/devicesettings_allow_reject_calls.xml
new file mode 100644
index 0000000000..b52d0329b5
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_allow_reject_calls.xml
@@ -0,0 +1,10 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/xml/devicesettings_header_phone_calls.xml b/app/src/main/res/xml/devicesettings_header_phone_calls.xml
new file mode 100644
index 0000000000..e3d6f8e5bf
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_header_phone_calls.xml
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/app/src/main/res/xml/devicesettings_heartrate_alerts.xml b/app/src/main/res/xml/devicesettings_heartrate_alerts.xml
new file mode 100644
index 0000000000..d5fa4d9db2
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_heartrate_alerts.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/devicesettings_inactivity_dnd.xml b/app/src/main/res/xml/devicesettings_inactivity_dnd.xml
index f57789aedf..64b39fa05b 100644
--- a/app/src/main/res/xml/devicesettings_inactivity_dnd.xml
+++ b/app/src/main/res/xml/devicesettings_inactivity_dnd.xml
@@ -9,6 +9,7 @@
-
-
-
+
+
+
+
diff --git a/app/src/main/res/xml/devicesettings_sms_quick_reply.xml b/app/src/main/res/xml/devicesettings_sms_quick_reply.xml
new file mode 100644
index 0000000000..5f09b0566e
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_sms_quick_reply.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/app/src/main/res/xml/devicesettings_spo2.xml b/app/src/main/res/xml/devicesettings_spo2.xml
new file mode 100644
index 0000000000..1d768da2f0
--- /dev/null
+++ b/app/src/main/res/xml/devicesettings_spo2.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/devicesettings_wena3.xml b/app/src/main/res/xml/devicesettings_wena3.xml
index 3bdf62462a..f95d646de0 100644
--- a/app/src/main/res/xml/devicesettings_wena3.xml
+++ b/app/src/main/res/xml/devicesettings_wena3.xml
@@ -49,7 +49,6 @@
android:title="@string/prefs_wena3_notification_per_app_settings_title"
android:icon="@drawable/ic_widgets"
android:key="pref_per_app_notification_settings">
-