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