diff --git a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java index 10883f697..3fcec24ef 100644 --- a/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java +++ b/GBDaoGenerator/src/nodomain/freeyourgadget/gadgetbridge/daogen/GBDaoGenerator.java @@ -45,7 +45,7 @@ public class GBDaoGenerator { public static void main(String[] args) throws Exception { - final Schema schema = new Schema(68, MAIN_PACKAGE + ".entities"); + final Schema schema = new Schema(70, MAIN_PACKAGE + ".entities"); Entity userAttributes = addUserAttributes(schema); Entity user = addUserInfo(schema, userAttributes); @@ -75,6 +75,13 @@ public class GBDaoGenerator { addXiaomiSleepStageSamples(schema, user, device); addXiaomiManualSamples(schema, user, device); addXiaomiDailySummarySamples(schema, user, device); + addCmfActivitySample(schema, user, device); + addCmfStressSample(schema, user, device); + addCmfSpo2Sample(schema, user, device); + addCmfSleepSessionSample(schema, user, device); + addCmfSleepStageSample(schema, user, device); + addCmfHeartRateSample(schema, user, device); + addCmfWorkoutGpsSample(schema, user, device); addPebbleHealthActivitySample(schema, user, device); addPebbleHealthActivityKindOverlay(schema, user, device); addPebbleMisfitActivitySample(schema, user, device); @@ -275,7 +282,7 @@ public class GBDaoGenerator { private static Entity addHuamiStressSample(Schema schema, Entity user, Entity device) { Entity stressSample = addEntity(schema, "HuamiStressSample"); addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); - stressSample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE); + stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); return stressSample; } @@ -283,7 +290,7 @@ public class GBDaoGenerator { private static Entity addHuamiSpo2Sample(Schema schema, Entity user, Entity device) { Entity spo2sample = addEntity(schema, "HuamiSpo2Sample"); addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device); - spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE); + spo2sample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE); spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE); return spo2sample; } @@ -407,6 +414,64 @@ public class GBDaoGenerator { return sample; } + private static Entity addCmfActivitySample(Schema schema, Entity user, Entity device) { + Entity activitySample = addEntity(schema, "CmfActivitySample"); + addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device); + activitySample.implementsSerializable(); + activitySample.addIntProperty(SAMPLE_RAW_INTENSITY).notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE); + activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE); + addHeartRateProperties(activitySample); + activitySample.addIntProperty("distance"); + activitySample.addIntProperty("calories"); + return activitySample; + } + + private static Entity addCmfStressSample(Schema schema, Entity user, Entity device) { + Entity stressSample = addEntity(schema, "CmfStressSample"); + addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); + stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); + return stressSample; + } + + private static Entity addCmfSpo2Sample(Schema schema, Entity user, Entity device) { + Entity spo2sample = addEntity(schema, "CmfSpo2Sample"); + addCommonTimeSampleProperties("AbstractSpo2Sample", spo2sample, user, device); + spo2sample.addIntProperty("spo2").notNull().codeBeforeGetter(OVERRIDE); + return spo2sample; + } + + private static Entity addCmfSleepSessionSample(Schema schema, Entity user, Entity device) { + Entity sleepSessionSample = addEntity(schema, "CmfSleepSessionSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sleepSessionSample, user, device); + sleepSessionSample.addLongProperty("wakeupTime"); + sleepSessionSample.addByteArrayProperty("metadata"); + return sleepSessionSample; + } + + private static Entity addCmfSleepStageSample(Schema schema, Entity user, Entity device) { + Entity sleepStageSample = addEntity(schema, "CmfSleepStageSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sleepStageSample, user, device); + sleepStageSample.addIntProperty("duration").notNull(); + sleepStageSample.addIntProperty("stage").notNull(); + return sleepStageSample; + } + + private static Entity addCmfHeartRateSample(Schema schema, Entity user, Entity device) { + Entity heartRateSample = addEntity(schema, "CmfHeartRateSample"); + addCommonTimeSampleProperties("AbstractHeartRateSample", heartRateSample, user, device); + heartRateSample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetter(OVERRIDE); + return heartRateSample; + } + + private static Entity addCmfWorkoutGpsSample(Schema schema, Entity user, Entity device) { + Entity sample = addEntity(schema, "CmfWorkoutGpsSample"); + addCommonTimeSampleProperties("AbstractTimeSample", sample, user, device); + sample.addIntProperty("latitude"); + sample.addIntProperty("longitude"); + return sample; + } + private static void addHeartRateProperties(Entity activitySample) { activitySample.addIntProperty(SAMPLE_HEART_RATE).notNull().codeBeforeGetterAndSetter(OVERRIDE); } @@ -981,7 +1046,7 @@ public class GBDaoGenerator { private static Entity addWena3StressSample(Schema schema, Entity user, Entity device) { Entity stressSample = addEntity(schema, "Wena3StressSample"); addCommonTimeSampleProperties("AbstractStressSample", stressSample, user, device); - stressSample.addIntProperty("typeNum").notNull().codeBeforeGetterAndSetter(OVERRIDE); + stressSample.addIntProperty("typeNum").notNull().codeBeforeGetter(OVERRIDE); stressSample.addIntProperty("stress").notNull().codeBeforeGetter(OVERRIDE); return stressSample; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java index 2308662c3..d201c95f3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/AlarmDetails.java @@ -18,6 +18,7 @@ package nodomain.freeyourgadget.gadgetbridge.activities; import android.os.Bundle; +import android.text.InputFilter; import android.text.format.DateFormat; import android.view.MenuItem; import android.view.View; @@ -132,10 +133,15 @@ public class AlarmDetails extends AbstractGBActivity { int snoozeVisibility = supportsSnoozing() ? View.VISIBLE : View.GONE; cbSnooze.setVisibility(snoozeVisibility); - int descriptionVisibility = supportsDescription() ? View.VISIBLE : View.GONE; - title.setVisibility(descriptionVisibility); + title.setVisibility(supportsTitle() ? View.VISIBLE : View.GONE); title.setText(alarm.getTitle()); - description.setVisibility(descriptionVisibility); + + final int titleLimit = getAlarmTitleLimit(); + if (titleLimit > 0) { + title.setFilters(new InputFilter[]{new InputFilter.LengthFilter(titleLimit)}); + } + + description.setVisibility(supportsDescription() ? View.VISIBLE : View.GONE); description.setText(alarm.getDescription()); cbMonday.setChecked(alarm.getRepetition(Alarm.ALARM_MON)); @@ -145,7 +151,6 @@ public class AlarmDetails extends AbstractGBActivity { cbFriday.setChecked(alarm.getRepetition(Alarm.ALARM_FRI)); cbSaturday.setChecked(alarm.getRepetition(Alarm.ALARM_SAT)); cbSunday.setChecked(alarm.getRepetition(Alarm.ALARM_SUN)); - } private boolean supportsSmartWakeup() { @@ -156,6 +161,22 @@ public class AlarmDetails extends AbstractGBActivity { return false; } + private boolean supportsTitle() { + if (device != null) { + DeviceCoordinator coordinator = device.getDeviceCoordinator(); + return coordinator.supportsAlarmTitle(device); + } + return false; + } + + private int getAlarmTitleLimit() { + if (device != null) { + DeviceCoordinator coordinator = device.getDeviceCoordinator(); + return coordinator.getAlarmTitleLimit(device); + } + return -1; + } + private boolean supportsDescription() { if (device != null) { DeviceCoordinator coordinator = device.getDeviceCoordinator(); 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 86feecb11..751e372be 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 @@ -177,6 +177,7 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_HEARTRATE_MEASUREMENT_INTERVAL = "heartrate_measurement_interval"; public static final String PREF_HEARTRATE_ACTIVITY_MONITORING = "heartrate_activity_monitoring"; public static final String PREF_HEARTRATE_ALERT_ENABLED = "heartrate_alert_enabled"; + public static final String PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD = "heartrate_alert_active_high_threshold"; public static final String PREF_HEARTRATE_ALERT_HIGH_THRESHOLD = "heartrate_alert_threshold"; public static final String PREF_HEARTRATE_ALERT_LOW_THRESHOLD = "heartrate_alert_low_threshold"; public static final String PREF_HEARTRATE_STRESS_MONITORING = "heartrate_stress_monitoring"; @@ -262,6 +263,9 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_ANTILOST_ENABLED = "pref_antilost_enabled"; public static final String PREF_HYDRATION_SWITCH = "pref_hydration_switch"; public static final String PREF_HYDRATION_PERIOD = "pref_hydration_period"; + public static final String PREF_HYDRATION_DND = "pref_hydration_dnd"; + public static final String PREF_HYDRATION_DND_START = "pref_hydration_dnd_start"; + public static final String PREF_HYDRATION_DND_END = "pref_hydration_dnd_end"; public static final String PREF_AMPM_ENABLED = "pref_ampm_enabled"; public static final String PREF_SONYSWR12_LOW_VIBRATION = "vibration_preference"; 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 5bc3f4abc..b7419c8ac 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 @@ -426,6 +426,9 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i addPreferenceHandlerFor(PREF_ANTILOST_ENABLED); addPreferenceHandlerFor(PREF_HYDRATION_SWITCH); addPreferenceHandlerFor(PREF_HYDRATION_PERIOD); + addPreferenceHandlerFor(PREF_HYDRATION_DND); + addPreferenceHandlerFor(PREF_HYDRATION_DND_START); + addPreferenceHandlerFor(PREF_HYDRATION_DND_END); addPreferenceHandlerFor(PREF_AMPM_ENABLED); addPreferenceHandlerFor(PREF_SOUNDS); addPreferenceHandlerFor(PREF_CAMERA_REMOTE); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java index 17e3124cd..c6029fb6b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/capabilities/HeartRateCapability.java @@ -17,6 +17,7 @@ package nodomain.freeyourgadget.gadgetbridge.capabilities; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD; @@ -82,20 +83,23 @@ public class HeartRateCapability { }); } + handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD); handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD); handler.addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_LOW_THRESHOLD); final ListPreference heartrateMeasurementInterval = handler.findPreference(PREF_HEARTRATE_MEASUREMENT_INTERVAL); + final ListPreference heartrateAlertActiveHigh = handler.findPreference(PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD); final ListPreference heartrateAlertHigh = handler.findPreference(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD); final ListPreference heartrateAlertLow = handler.findPreference(PREF_HEARTRATE_ALERT_LOW_THRESHOLD); // Newer devices that have low alert threshold can only use it if measurement interval is smart (-1) or 1 minute - final boolean hrAlertsNeedSmartOrOne = heartrateAlertHigh != null && heartrateAlertLow != null && heartrateMeasurementInterval != null; + final boolean hrAlertsNeedSmartOrOne = heartrateAlertActiveHigh != null && heartrateAlertHigh != null && heartrateAlertLow != null && heartrateMeasurementInterval != null; if (hrAlertsNeedSmartOrOne) { final boolean hrMonitoringIsSmartOrOne = heartrateMeasurementInterval.getValue().equals("60") || heartrateMeasurementInterval.getValue().equals("-1"); heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne); heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne); + heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne); } if (heartrateMeasurementInterval != null) { @@ -129,6 +133,7 @@ public class HeartRateCapability { // Same as above, check if smart or 1 minute final boolean hrMonitoringIsSmartOrOne = newVal.equals("60") || newVal.equals("-1"); + heartrateAlertActiveHigh.setEnabled(hrMonitoringIsSmartOrOne); heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne); heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne); } 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 ba8399137..e6e8115c3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -436,6 +436,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return false; } + @Override + public boolean supportsAlarmTitle(GBDevice device) { + return false; + } + + @Override + public int getAlarmTitleLimit(GBDevice device) { + return -1; + } + @Override public boolean supportsAlarmDescription(GBDevice device) { return false; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index db5b71116..b0af24587 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -343,6 +343,18 @@ public interface DeviceCoordinator { */ boolean supportsAlarmSnoozing(); + /** + * Returns true if this device/coordinator supports alarm titles + * @return + */ + boolean supportsAlarmTitle(GBDevice device); + + /** + * Returns the character limit for the alarm title, negative if no limit. + * @return + */ + int getAlarmTitleLimit(GBDevice device); + /** * Returns true if this device/coordinator supports alarm descriptions * @return diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java new file mode 100644 index 000000000..efceba4d7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProCoordinator.java @@ -0,0 +1,368 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import android.app.Activity; +import android.bluetooth.le.ScanFilter; +import android.content.Context; +import android.net.Uri; +import android.os.ParcelUuid; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.ArrayUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.GBException; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.appmanager.AppManagerActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.capabilities.HeartRateCapability; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.TimeSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfActivitySampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSpo2SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfStressSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout.CmfWorkoutSummaryParser; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2SampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; +import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample; +import nodomain.freeyourgadget.gadgetbridge.model.StressSample; +import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfInstallHandler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfWatchProSupport; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class CmfWatchProCoordinator extends AbstractBLEDeviceCoordinator { + @Override + protected Pattern getSupportedDeviceName() { + return Pattern.compile("^Watch Pro$"); + } + + @NonNull + @Override + public Collection createBLEScanFilters() { + final ParcelUuid casioService = new ParcelUuid(CmfWatchProSupport.UUID_SERVICE_CMF_CMD); + final ScanFilter filter = new ScanFilter.Builder().setServiceUuid(casioService).build(); + return Collections.singletonList(filter); + } + + @Nullable + @Override + public InstallHandler findInstallHandler(final Uri uri, final Context context) { + final CmfInstallHandler handler = new CmfInstallHandler(uri, context); + return handler.isValid() ? handler : null; + } + + @Override + protected void deleteDevice(@NonNull final GBDevice gbDevice, + @NonNull final Device device, + @NonNull final DaoSession session) throws GBException { + final Long deviceId = device.getId(); + + session.getCmfActivitySampleDao().queryBuilder() + .where(CmfActivitySampleDao.Properties.DeviceId.eq(deviceId)) + .buildDelete().executeDeleteWithoutDetachingEntities(); + + session.getCmfStressSampleDao().queryBuilder() + .where(CmfStressSampleDao.Properties.DeviceId.eq(deviceId)) + .buildDelete().executeDeleteWithoutDetachingEntities(); + + session.getCmfHeartRateSampleDao().queryBuilder() + .where(CmfHeartRateSampleDao.Properties.DeviceId.eq(deviceId)) + .buildDelete().executeDeleteWithoutDetachingEntities(); + + session.getCmfSleepSessionSampleDao().queryBuilder() + .where(CmfSleepSessionSampleDao.Properties.DeviceId.eq(deviceId)) + .buildDelete().executeDeleteWithoutDetachingEntities(); + + session.getCmfSleepStageSampleDao().queryBuilder() + .where(CmfSleepStageSampleDao.Properties.DeviceId.eq(deviceId)) + .buildDelete().executeDeleteWithoutDetachingEntities(); + + session.getCmfSpo2SampleDao().queryBuilder() + .where(CmfSpo2SampleDao.Properties.DeviceId.eq(deviceId)) + .buildDelete().executeDeleteWithoutDetachingEntities(); + } + + @Override + public String getManufacturer() { + return "Nothing"; + } + + @Override + public int getDeviceNameResource() { + return R.string.devicetype_nothing_cmf_watch_pro; + } + + @Override + public int getDefaultIconResource() { + return R.drawable.ic_device_amazfit_bip; + } + + @Override + public int getDisabledIconResource() { + return R.drawable.ic_device_amazfit_bip_disabled; + } + + @NonNull + @Override + public Class getDeviceSupportClass() { + return CmfWatchProSupport.class; + } + + @Override + public int getBondingStyle() { + return BONDING_STYLE_REQUIRE_KEY; + } + + @Override + public boolean validateAuthKey(final String authKey) { + final byte[] authKeyBytes = authKey.trim().getBytes(); + return authKeyBytes.length == 32 || (authKey.startsWith("0x") && authKeyBytes.length == 34); + } + + @Override + public int[] getSupportedDeviceSpecificAuthenticationSettings() { + return new int[]{R.xml.devicesettings_pairingkey}; + } + + @Override + public SampleProvider getSampleProvider(final GBDevice device, DaoSession session) { + return new CmfActivitySampleProvider(device, session); + } + + @Override + public TimeSampleProvider getStressSampleProvider(final GBDevice device, final DaoSession session) { + return new CmfStressSampleProvider(device, session); + } + + @Override + public int[] getStressRanges() { + // 1-29 = relaxed + // 30-59 = normal + // 60-79 = medium + // 80-99 = high + return new int[]{1, 30, 60, 80}; + } + + @Override + public TimeSampleProvider getSpo2SampleProvider(final GBDevice device, final DaoSession session) { + return new CmfSpo2SampleProvider(device, session); + } + + @Nullable + @Override + public ActivitySummaryParser getActivitySummaryParser(final GBDevice device) { + return new CmfWorkoutSummaryParser(device); + } + + @Override + public boolean supportsFlashing() { + return true; + } + + @Override + public int getAlarmSlotCount(final GBDevice device) { + return 5; + } + + @Override + public boolean supportsAlarmTitle(final GBDevice device) { + return true; + } + + @Override + public int getAlarmTitleLimit(final GBDevice device) { + return 8; + } + + @Override + public boolean supportsAppsManagement(final GBDevice device) { + return false; // TODO for watchface management + } + + @Override + public boolean supportsCachedAppManagement(GBDevice device) { + return false; + } + + @Override + public boolean supportsInstalledAppManagement(GBDevice device) { + return false; + } + + @Override + public boolean supportsWatchfaceManagement(GBDevice device) { + return supportsAppsManagement(device); + } + + @Override + public Class getAppsManagementActivity() { + return AppManagerActivity.class; + } + + @Override + public boolean supportsAppListFetching() { + return false; // TODO it does not, but we can fake it for watchfaces + } + + @Override + public boolean supportsActivityDataFetching() { + return true; + } + + @Override + public boolean supportsActivityTracking() { + return true; + } + + @Override + public boolean supportsActivityTracks() { + return true; + } + + @Override + public boolean supportsStressMeasurement() { + return true; + } + + @Override + public boolean supportsSpo2() { + return true; + } + + @Override + public boolean supportsMusicInfo() { + return true; + } + + @Override + public int getContactsSlotCount(final GBDevice device) { + return 20; + } + + @Override + public boolean supportsHeartRateMeasurement(final GBDevice device) { + return true; + } + + @Override + public boolean supportsManualHeartRateMeasurement(final GBDevice device) { + return false; + } + + @Override + public boolean supportsRemSleep() { + return true; + } + + @Override + public boolean supportsWeather() { + return false; // TODO weather is not implemented + } + + @Override + public boolean supportsFindDevice() { + return true; + } + + @Override + public int[] getSupportedDeviceSpecificSettings(final GBDevice device) { + final List settings = new ArrayList<>(); + + settings.add(R.xml.devicesettings_header_time); + settings.add(R.xml.devicesettings_timeformat); + + settings.add(R.xml.devicesettings_header_display); + settings.add(R.xml.devicesettings_workout_activity_types); + settings.add(R.xml.devicesettings_liftwrist_display_noshed); + + settings.add(R.xml.devicesettings_header_health); + settings.add(R.xml.devicesettings_heartrate_sleep_alert_activity_stress_spo2); + settings.add(R.xml.devicesettings_inactivity_dnd); + settings.add(R.xml.devicesettings_hydration_reminder_dnd); + + settings.add(R.xml.devicesettings_header_notifications); + settings.add(R.xml.devicesettings_send_app_notifications); + settings.add(R.xml.devicesettings_transliteration); + + settings.add(R.xml.devicesettings_header_other); + if (getContactsSlotCount(device) > 0) { + settings.add(R.xml.devicesettings_contacts); + } + + return ArrayUtils.toPrimitive(settings.toArray(new Integer[0])); + } + + @Override + public DeviceSpecificSettingsCustomizer getDeviceSpecificSettingsCustomizer(final GBDevice device) { + return new CmfWatchProSettingsCustomizer(); + } + + @Override + public String[] getSupportedLanguageSettings(final GBDevice device) { + return null; + // FIXME language setting does not seem to work from phone + //return new String[]{ + // "auto", + // "ar_SA", + // "de_DE", + // "en_US", + // "es_ES", + // "fr_FR", + // "hi_IN", + // "id_ID", + // "it_IT", + // "ja_JP", + // "ko_KO", + // "zh_CN", + // "zh_HK", + //}; + } + + @Override + public List getHeartRateMeasurementIntervals() { + return Arrays.asList( + HeartRateCapability.MeasurementInterval.OFF, + HeartRateCapability.MeasurementInterval.SMART + ); + } + + protected static Prefs getPrefs(final GBDevice device) { + return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress())); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java new file mode 100644 index 000000000..c83df700c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchProSettingsCustomizer.java @@ -0,0 +1,82 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import android.os.Parcel; + +import androidx.annotation.NonNull; +import androidx.preference.Preference; + +import java.util.Collections; +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class CmfWatchProSettingsCustomizer implements DeviceSpecificSettingsCustomizer { + @Override + public void onPreferenceChange(final Preference preference, final DeviceSpecificSettingsHandler handler) { + } + + @Override + public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs) { + final String[] prefsToHide = new String[]{ + "pref_key_header_heartrate_sleep", + DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION, + DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING, + DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING, + DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER, + DeviceSettingsPreferenceConst.PREF_INACTIVITY_START, + DeviceSettingsPreferenceConst.PREF_INACTIVITY_END, + }; + + for (final String prefKey : prefsToHide) { + final Preference pref = handler.findPreference(prefKey); + if (pref != null) { + pref.setVisible(false); + } + } + } + + @Override + public Set getPreferenceKeysWithSummary() { + return Collections.emptySet(); + } + + public static final Creator CREATOR = new Creator() { + @Override + public CmfWatchProSettingsCustomizer createFromParcel(final Parcel in) { + return new CmfWatchProSettingsCustomizer(); + } + + @Override + public CmfWatchProSettingsCustomizer[] newArray(final int size) { + return new CmfWatchProSettingsCustomizer[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull final Parcel dest, final int flags) { + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java new file mode 100644 index 000000000..1dec31d0b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfActivitySampleProvider.java @@ -0,0 +1,224 @@ +/* Copyright (C) 2024 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.cmfwatchpro.samples; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.threeten.bp.LocalDate; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import de.greenrobot.dao.AbstractDao; +import de.greenrobot.dao.Property; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSample; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; + +public class CmfActivitySampleProvider extends AbstractSampleProvider { + private static final Logger LOG = LoggerFactory.getLogger(CmfActivitySampleProvider.class); + + public CmfActivitySampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @Override + public AbstractDao getSampleDao() { + return getSession().getCmfActivitySampleDao(); + } + + @Nullable + @Override + protected Property getRawKindSampleProperty() { + return CmfActivitySampleDao.Properties.RawKind; + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return CmfActivitySampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return CmfActivitySampleDao.Properties.DeviceId; + } + + @Override + public int normalizeType(final int rawType) { + return rawType; + } + + @Override + public int toRawActivityKind(final int activityKind) { + return activityKind; + } + + @Override + public float normalizeIntensity(final int rawIntensity) { + return rawIntensity / 100f; + } + + @Override + public CmfActivitySample createActivitySample() { + return new CmfActivitySample(); + } + + @Override + protected List getGBActivitySamples(final int timestamp_from, final int timestamp_to, final int activityType) { + LOG.trace( + "Getting cmf activity samples for {} between {} and {}", + String.format("0x%08x", activityType), + timestamp_from, + timestamp_to + ); + + final long nanoStart = System.nanoTime(); + + final List samples = super.getGBActivitySamples(timestamp_from, timestamp_to, activityType); + + if (!samples.isEmpty()) { + convertCumulativeSteps(samples); + } + + final Map sampleByTs = new HashMap<>(); + for (final CmfActivitySample sample : samples) { + sampleByTs.put(sample.getTimestamp(), sample); + } + + overlayHeartRate(sampleByTs, timestamp_from, timestamp_to); + overlaySleep(sampleByTs, timestamp_from, timestamp_to); + + final List finalSamples = new ArrayList<>(sampleByTs.values()); + Collections.sort(finalSamples, (a, b) -> Integer.compare(a.getTimestamp(), b.getTimestamp())); + + final long nanoEnd = System.nanoTime(); + + final long executionTime = (nanoEnd - nanoStart) / 1000000; + + LOG.trace("Getting cmf samples took {}ms", executionTime); + + return finalSamples; + } + + private void convertCumulativeSteps(final List samples) { + final Calendar cal = Calendar.getInstance(); + + // Steps on the Cmf Watch are reported cumulatively per day - convert them to + // This slightly breaks activity recognition, because we don't have per-minute granularity... + int prevSteps = samples.get(0).getSteps(); + samples.get(0).setTimestamp((int) (samples.get(0).getTimestamp() / 60) * 60); + + for (int i = 1; i < samples.size(); i++) { + final CmfActivitySample s1 = samples.get(i - 1); + final CmfActivitySample s2 = samples.get(i); + s2.setTimestamp((int) (s2.getTimestamp() / 60) * 60); + + cal.setTimeInMillis(s1.getTimestamp() * 1000L - 1000L); + final LocalDate d1 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); + cal.setTimeInMillis(s2.getTimestamp() * 1000L - 1000L); + final LocalDate d2 = LocalDate.of(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); + + if (d1.equals(d2)) { + int bak = s2.getSteps(); + s2.setSteps(s2.getSteps() - prevSteps); + prevSteps = bak; + } + } + } + + private void overlayHeartRate(final Map sampleByTs, final int timestamp_from, final int timestamp_to) { + final CmfHeartRateSampleProvider heartRateSampleProvider = new CmfHeartRateSampleProvider(getDevice(), getSession()); + final List hrSamples = heartRateSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L); + + for (final CmfHeartRateSample hrSample : hrSamples) { + // round to the nearest minute, we don't need per-second granularity + final int tsSeconds = (int) ((hrSample.getTimestamp() / 1000) / 60) * 60; + CmfActivitySample sample = sampleByTs.get(tsSeconds); + if (sample == null) { + //LOG.debug("Adding dummy sample at {} for hr", tsSeconds); + sample = new CmfActivitySample(); + sample.setTimestamp(tsSeconds); + sample.setProvider(this); + sampleByTs.put(tsSeconds, sample); + } + + sample.setHeartRate(hrSample.getHeartRate()); + } + } + + private void overlaySleep(final Map sampleByTs, final int timestamp_from, final int timestamp_to) { + final CmfSleepStageSampleProvider sleepStageSampleProvider = new CmfSleepStageSampleProvider(getDevice(), getSession()); + final List sleepStageSamples = sleepStageSampleProvider.getAllSamples(timestamp_from * 1000L, timestamp_to * 1000L); + + for (final CmfSleepStageSample sleepStageSample : sleepStageSamples) { + // round to the nearest minute, we don't need per-second granularity + final int tsSeconds = (int) ((sleepStageSample.getTimestamp() / 1000) / 60) * 60; + for (int i = tsSeconds; i < tsSeconds + sleepStageSample.getDuration(); i += 60) { + CmfActivitySample sample = sampleByTs.get(i); + if (sample == null) { + //LOG.debug("Adding dummy sample at {} for sleep", i); + sample = new CmfActivitySample(); + sample.setTimestamp(i); + sample.setProvider(this); + sampleByTs.put(i, sample); + } + + final int sleepRawKind = sleepStageToActivityKind(sleepStageSample.getStage()); + sample.setRawKind(sleepRawKind); + + switch (sleepRawKind) { + case ActivityKind.TYPE_DEEP_SLEEP: + sample.setRawIntensity(20); + break; + case ActivityKind.TYPE_LIGHT_SLEEP: + sample.setRawIntensity(30); + break; + case ActivityKind.TYPE_REM_SLEEP: + sample.setRawIntensity(40); + break; + } + } + } + } + + final int sleepStageToActivityKind(final int sleepStage) { + switch (sleepStage) { + case 1: + return ActivityKind.TYPE_DEEP_SLEEP; + case 2: + return ActivityKind.TYPE_LIGHT_SLEEP; + case 3: + return ActivityKind.TYPE_REM_SLEEP; + default: + return ActivityKind.TYPE_UNKNOWN; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java new file mode 100644 index 000000000..268e44ddb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfHeartRateSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 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.cmfwatchpro.samples; + +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.CmfHeartRateSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class CmfHeartRateSampleProvider extends AbstractTimeSampleProvider { + public CmfHeartRateSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getCmfHeartRateSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return CmfHeartRateSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return CmfHeartRateSampleDao.Properties.DeviceId; + } + + @Override + public CmfHeartRateSample createSample() { + return new CmfHeartRateSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java new file mode 100644 index 000000000..b907137f6 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepSessionSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 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.cmfwatchpro.samples; + +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.CmfSleepSessionSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class CmfSleepSessionSampleProvider extends AbstractTimeSampleProvider { + public CmfSleepSessionSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getCmfSleepSessionSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return CmfSleepSessionSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return CmfSleepSessionSampleDao.Properties.DeviceId; + } + + @Override + public CmfSleepSessionSample createSample() { + return new CmfSleepSessionSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java new file mode 100644 index 000000000..c78a1b519 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSleepStageSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 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.cmfwatchpro.samples; + +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.CmfSleepStageSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class CmfSleepStageSampleProvider extends AbstractTimeSampleProvider { + public CmfSleepStageSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getCmfSleepStageSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return CmfSleepStageSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return CmfSleepStageSampleDao.Properties.DeviceId; + } + + @Override + public CmfSleepStageSample createSample() { + return new CmfSleepStageSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java new file mode 100644 index 000000000..860c50eef --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfSpo2SampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 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.cmfwatchpro.samples; + +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.CmfSpo2Sample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2SampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class CmfSpo2SampleProvider extends AbstractTimeSampleProvider { + public CmfSpo2SampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getCmfSpo2SampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return CmfSpo2SampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return CmfSpo2SampleDao.Properties.DeviceId; + } + + @Override + public CmfSpo2Sample createSample() { + return new CmfSpo2Sample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java new file mode 100644 index 000000000..3e1a94a20 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfStressSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 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.cmfwatchpro.samples; + +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.CmfStressSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class CmfStressSampleProvider extends AbstractTimeSampleProvider { + public CmfStressSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getCmfStressSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return CmfStressSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return CmfStressSampleDao.Properties.DeviceId; + } + + @Override + public CmfStressSample createSample() { + return new CmfStressSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java new file mode 100644 index 000000000..680906e51 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/samples/CmfWorkoutGpsSampleProvider.java @@ -0,0 +1,56 @@ +/* Copyright (C) 2024 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.cmfwatchpro.samples; + +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.CmfWorkoutGpsSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfWorkoutGpsSampleDao; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; + +public class CmfWorkoutGpsSampleProvider extends AbstractTimeSampleProvider { + public CmfWorkoutGpsSampleProvider(final GBDevice device, final DaoSession session) { + super(device, session); + } + + @NonNull + @Override + public AbstractDao getSampleDao() { + return getSession().getCmfWorkoutGpsSampleDao(); + } + + @NonNull + @Override + protected Property getTimestampSampleProperty() { + return CmfWorkoutGpsSampleDao.Properties.Timestamp; + } + + @NonNull + @Override + protected Property getDeviceIdentifierSampleProperty() { + return CmfWorkoutGpsSampleDao.Properties.DeviceId; + } + + @Override + public CmfWorkoutGpsSample createSample() { + return new CmfWorkoutGpsSample(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java new file mode 100644 index 000000000..ae4ff6e0a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/workout/CmfWorkoutSummaryParser.java @@ -0,0 +1,95 @@ +/* Copyright (C) 2024 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.cmfwatchpro.workout; + +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.ACTIVE_SECONDS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryEntries.UNIT_SECONDS; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Date; + +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySummaryParser; +import nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro.CmfActivityType; + +public class CmfWorkoutSummaryParser implements ActivitySummaryParser { + private final GBDevice gbDevice; + + public CmfWorkoutSummaryParser(final GBDevice device) { + this.gbDevice = device; + } + + @Override + public BaseActivitySummary parseBinaryData(final BaseActivitySummary summary) { + final JSONObject summaryData = new JSONObject(); + + final ByteBuffer buf = ByteBuffer.wrap(summary.getRawSummaryData()).order(ByteOrder.LITTLE_ENDIAN); + + final int startTime = buf.getInt(); + final int duration = buf.getShort(); + final byte workoutType = buf.get(); + + buf.get(new byte[19]); // ? + final int endTime = buf.getInt(); + final boolean gps = buf.get() == 1; + buf.get(); // ? + + summary.setStartTime(new Date(startTime * 1000L)); + summary.setEndTime(new Date(endTime * 1000L)); + + final CmfActivityType cmfActivityType = CmfActivityType.fromCode(workoutType); + if (cmfActivityType != null) { + summary.setActivityKind(cmfActivityType.getActivityKind()); + } else { + summary.setActivityKind(ActivityKind.TYPE_UNKNOWN); + } + + addSummaryData(summaryData, ACTIVE_SECONDS, duration, UNIT_SECONDS); + + return summary; + } + + protected void addSummaryData(final JSONObject summaryData, final String key, final float value, final String unit) { + if (value > 0) { + try { + final JSONObject innerData = new JSONObject(); + innerData.put("value", value); + innerData.put("unit", unit); + summaryData.put(key, innerData); + } catch (final JSONException ignore) { + } + } + } + + protected void addSummaryData(final JSONObject summaryData, final String key, final String value) { + if (key != null && !key.equals("") && value != null && !value.equals("")) { + try { + final JSONObject innerData = new JSONObject(); + innerData.put("value", value); + innerData.put("unit", "string"); + summaryData.put(key, innerData); + } catch (final JSONException ignore) { + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java index d992b5a79..57015cfd4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiSettingsCustomizer.java @@ -28,6 +28,7 @@ import java.util.Locale; import java.util.Set; import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; @@ -51,6 +52,11 @@ public class HuamiSettingsCustomizer implements DeviceSpecificSettingsCustomizer @Override public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs) { + final Preference hrAlertActivePref = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD); + if (hrAlertActivePref != null) { + hrAlertActivePref.setVisible(false); + } + // Setup the vibration patterns for all supported notification types for (HuamiVibrationPatternNotificationType notificationType : HuamiVibrationPatternNotificationType.values()) { final String typeKey = notificationType.name().toLowerCase(Locale.ROOT); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java index 41c18384b..228645812 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiBRCoordinator.java @@ -126,6 +126,11 @@ public abstract class HuaweiBRCoordinator extends AbstractBLClassicDeviceCoordin return false; } + @Override + public boolean supportsAlarmTitle(GBDevice device) { + return true; + } + @Override public boolean supportsAlarmDescription(GBDevice device) { // TODO: only name is supported diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java index bd404efae..45421af7a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiLECoordinator.java @@ -126,6 +126,11 @@ public abstract class HuaweiLECoordinator extends AbstractBLEDeviceCoordinator i return false; } + @Override + public boolean supportsAlarmTitle(GBDevice device) { + return true; + } + @Override public boolean supportsAlarmDescription(GBDevice device) { // TODO: only name is supported diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java index d383e1fce..a6a5d091f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huawei/HuaweiSpo2SampleProvider.java @@ -180,11 +180,6 @@ public class HuaweiSpo2SampleProvider extends AbstractTimeSampleProvider. */ +package nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.Nullable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import de.greenrobot.dao.query.QueryBuilder; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfActivitySampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfHeartRateSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSleepSessionSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSleepStageSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfSpo2SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfStressSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.samples.CmfWorkoutGpsSampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.workout.CmfWorkoutSummaryParser; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummary; +import nodomain.freeyourgadget.gadgetbridge.entities.BaseActivitySummaryDao; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfHeartRateSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepSessionSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSleepStageSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfSpo2Sample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfStressSample; +import nodomain.freeyourgadget.gadgetbridge.entities.CmfWorkoutGpsSample; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.entities.User; +import nodomain.freeyourgadget.gadgetbridge.export.ActivityTrackExporter; +import nodomain.freeyourgadget.gadgetbridge.export.GPXExporter; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityPoint; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityTrack; +import nodomain.freeyourgadget.gadgetbridge.model.GPSCoordinate; +import nodomain.freeyourgadget.gadgetbridge.util.DateTimeUtils; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class CmfActivitySync { + private static final Logger LOG = LoggerFactory.getLogger(CmfActivitySync.class); + + private final CmfWatchProSupport mSupport; + + private final List activitiesWithGps = new ArrayList<>(); + + protected CmfActivitySync(final CmfWatchProSupport support) { + this.mSupport = support; + } + + protected boolean onCommand(final CmfCommand cmd, final byte[] payload) { + switch (cmd) { + case ACTIVITY_FETCH_ACK_1: + handleActivityFetchAck1(payload); + return true; + case ACTIVITY_FETCH_ACK_2: + handleActivityFetchAck2(payload); + return true; + case ACTIVITY_DATA: + handleActivityData(payload); + return true; + case HEART_RATE_MANUAL_AUTO: + case HEART_RATE_WORKOUT: + handleHeartRate(payload); + return true; + case HEART_RATE_RESTING: + handleHeartRateResting(payload); + return true; + case SLEEP_DATA: + handleSleepData(payload); + return true; + case STRESS: + handleStress(payload); + return true; + case SPO2: + handleSpo2(payload); + return true; + case WORKOUT_SUMMARY: + handleWorkoutSummary(payload); + return true; + case WORKOUT_GPS: + handleWorkoutGps(payload); + return true; + } + + return false; + } + + private void handleActivityFetchAck1(final byte[] payload) { + switch (payload[0]) { + case 0x01: + LOG.debug("Got activity fetch ack 1, starting step 2"); + GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext()); + getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_activity_data)); + mSupport.sendCommand("fetch recorded data step 2", CmfCommand.ACTIVITY_FETCH_2, CmfWatchProSupport.A5); + break; + case 0x02: + LOG.debug("Got activity fetch finish"); + // Process activities with GPS before unsetting device as busy + processActivitiesWithGps(); + break; + default: + LOG.warn("Unknown activity fetch ack code {}", payload[0]); + return; + } + + getDevice().sendDeviceUpdateIntent(getContext()); + } + + private static void handleActivityFetchAck2(final byte[] payload) { + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + + final int activityTs = buf.getInt(); + final byte[] activityFlags = new byte[4]; // TODO what do they mean? + buf.order(ByteOrder.BIG_ENDIAN).get(activityFlags); + LOG.debug("Getting activity since {}, flags={}", activityTs, GB.hexdump(activityFlags)); + } + + private void handleActivityData(final byte[] payload) { + if (payload.length % 32 != 0) { + LOG.error("Activity data payload size {} not divisible by 32", payload.length); + return; + } + + LOG.debug("Got {} activity samples", payload.length / 32); + + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + + final List samples = new ArrayList<>(); + + while (buf.remaining() > 0) { + final CmfActivitySample sample = new CmfActivitySample(); + sample.setTimestamp(buf.getInt()); + sample.setSteps(buf.getInt()); + sample.setDistance(buf.getInt()); + sample.setCalories(buf.getInt()); + + final byte[] unk = new byte[16]; + buf.get(unk); + + samples.add(sample); + } + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(getDevice(), session); + final User user = DBHelper.getUser(session); + + final CmfActivitySampleProvider sampleProvider = new CmfActivitySampleProvider(getDevice(), session); + + for (final CmfActivitySample sample : samples) { + sample.setDevice(device); + sample.setUser(user); + sample.setProvider(sampleProvider); + } + + LOG.debug("Will persist {} activity samples", samples.size()); + sampleProvider.addGBActivitySamples(samples.toArray(new CmfActivitySample[0])); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving activity samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void handleHeartRate(final byte[] payload) { + if (payload.length % 8 != 0) { + LOG.error("Heart rate payload size {} not divisible by 8", payload.length); + return; + } + + LOG.debug("Got {} heart rate samples", payload.length / 8); + + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + + final List samples = new ArrayList<>(); + + while (buf.remaining() > 0) { + final CmfHeartRateSample sample = new CmfHeartRateSample(); + sample.setTimestamp(buf.getInt() * 1000L); + sample.setHeartRate(buf.getInt()); + + samples.add(sample); + } + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(getDevice(), session); + final User user = DBHelper.getUser(session); + + final CmfHeartRateSampleProvider sampleProvider = new CmfHeartRateSampleProvider(getDevice(), session); + + for (final CmfHeartRateSample sample : samples) { + sample.setDevice(device); + sample.setUser(user); + } + + LOG.debug("Will persist {} heart rate samples", samples.size()); + sampleProvider.addSamples(samples); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving heart rate samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private static void handleHeartRateResting(final byte[] payload) { + // TODO persist resting HR samples; + LOG.warn("Persisting resting HR samples is not implemented"); + } + + private void handleSleepData(final byte[] payload) { + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + + LOG.debug("Got sleep data samples"); + + final int sessionTimestamp = buf.getInt(); + final int wakeupTime = buf.getInt(); + final byte[] metadata = new byte[10]; + buf.get(metadata); + + final CmfSleepSessionSample sessionSample = new CmfSleepSessionSample(); + sessionSample.setTimestamp(sessionTimestamp * 1000L); + sessionSample.setWakeupTime(wakeupTime * 1000L); + sessionSample.setMetadata(metadata); + + final List stageSamples = new ArrayList<>(); + + while (buf.remaining() > 0) { + final CmfSleepStageSample sample = new CmfSleepStageSample(); + sample.setTimestamp(buf.getInt() * 1000L); + sample.setDuration(buf.getShort()); + sample.setStage(buf.getShort()); + stageSamples.add(sample); + } + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(getDevice(), session); + final User user = DBHelper.getUser(session); + + final CmfSleepSessionSampleProvider sampleProvider = new CmfSleepSessionSampleProvider(getDevice(), session); + + sessionSample.setDevice(device); + sessionSample.setUser(user); + + LOG.debug("Will persist 1 sleep session sample from {} to {}", sessionSample.getTimestamp(), sessionSample.getWakeupTime()); + sampleProvider.addSample(sessionSample); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving sleep session sample", Toast.LENGTH_LONG, GB.ERROR, e); + } + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(getDevice(), session); + final User user = DBHelper.getUser(session); + + final CmfSleepStageSampleProvider sampleProvider = new CmfSleepStageSampleProvider(getDevice(), session); + + for (final CmfSleepStageSample sample : stageSamples) { + sample.setDevice(device); + sample.setUser(user); + } + + LOG.debug("Will persist {} sleep stage samples", stageSamples.size()); + sampleProvider.addSamples(stageSamples); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving sleep samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void handleStress(final byte[] payload) { + if (payload.length % 8 != 0) { + LOG.error("Stress payload size {} not divisible by 8", payload.length); + return; + } + + LOG.debug("Got {} stress samples", payload.length / 8); + + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + + final List samples = new ArrayList<>(); + + while (buf.remaining() > 0) { + final CmfStressSample sample = new CmfStressSample(); + sample.setTimestamp(buf.getInt() * 1000L); + sample.setStress(buf.getInt()); + + samples.add(sample); + } + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(getDevice(), session); + final User user = DBHelper.getUser(session); + + final CmfStressSampleProvider sampleProvider = new CmfStressSampleProvider(getDevice(), session); + + for (final CmfStressSample sample : samples) { + sample.setDevice(device); + sample.setUser(user); + } + + LOG.debug("Will persist {} stress samples", samples.size()); + sampleProvider.addSamples(samples); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving stress samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void handleSpo2(final byte[] payload) { + if (payload.length % 8 != 0) { + LOG.error("Spo2 payload size {} not divisible by 8", payload.length); + return; + } + + LOG.debug("Got {} spo2 samples", payload.length / 8); + + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + + final List samples = new ArrayList<>(); + + while (buf.remaining() > 0) { + final CmfSpo2Sample sample = new CmfSpo2Sample(); + sample.setTimestamp(buf.getInt() * 1000L); + sample.setSpo2(buf.getInt()); + + samples.add(sample); + } + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(getDevice(), session); + final User user = DBHelper.getUser(session); + + final CmfSpo2SampleProvider sampleProvider = new CmfSpo2SampleProvider(getDevice(), session); + + for (final CmfSpo2Sample sample : samples) { + sample.setDevice(device); + sample.setUser(user); + } + + LOG.debug("Will persist {} spo2 samples", samples.size()); + sampleProvider.addSamples(samples); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving spo2 samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void handleWorkoutSummary(final byte[] payload) { + if (payload.length % 32 != 0) { + LOG.error("Workout summary payload size {} not divisible by 32", payload.length); + return; + } + + LOG.debug("Got {} workout summary samples", payload.length / 32); + + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + + final CmfWorkoutSummaryParser summaryParser = new CmfWorkoutSummaryParser(getDevice()); + + while (buf.remaining() > 0) { + final byte[] summaryBytes = new byte[32]; + buf.get(summaryBytes); + + BaseActivitySummary summary = new BaseActivitySummary(); + summary.setRawSummaryData(summaryBytes); + summary.setActivityKind(ActivityKind.TYPE_UNKNOWN); + + try { + summary = summaryParser.parseBinaryData(summary); + } catch (final Exception e) { + LOG.error("Failed to parse workout summary", e); + GB.toast(getContext(), "Failed to parse workout summary", Toast.LENGTH_LONG, GB.ERROR, e); + return; + } + + if (summary == null) { + LOG.error("Workout summary is null"); + return; + } + + summary.setSummaryData(null); // remove json before saving to database + + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final DaoSession session = dbHandler.getDaoSession(); + final Device device = DBHelper.getDevice(getDevice(), session); + final User user = DBHelper.getUser(session); + + summary.setDevice(device); + summary.setUser(user); + + LOG.debug("Persisting workout summary for {}", summary.getStartTime()); + + session.getBaseActivitySummaryDao().insertOrReplace(summary); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving activity summary", Toast.LENGTH_LONG, GB.ERROR, e); + return; + } + + // Previous to last byte indicates if it has gps + if (summaryBytes[summaryBytes.length - 2] == 1) { + activitiesWithGps.add(summary); + } + } + } + + private void handleWorkoutGps(final byte[] payload) { + if (payload.length % 12 != 0) { + LOG.error("Workout gps payload size {} not divisible by 12", payload.length); + return; + } + + LOG.debug("Got {} workout gps samples", payload.length / 12); + + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); + + final List samples = new ArrayList<>(); + + while (buf.remaining() > 0) { + final CmfWorkoutGpsSample sample = new CmfWorkoutGpsSample(); + sample.setTimestamp(buf.getInt() * 1000L); + sample.setLongitude(buf.getInt()); + sample.setLatitude(buf.getInt()); + + samples.add(sample); + } + + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final Device device = DBHelper.getDevice(getDevice(), session); + final User user = DBHelper.getUser(session); + + final CmfWorkoutGpsSampleProvider sampleProvider = new CmfWorkoutGpsSampleProvider(getDevice(), session); + + for (final CmfWorkoutGpsSample sample : samples) { + sample.setDevice(device); + sample.setUser(user); + } + + LOG.debug("Will persist {} workout gps samples", samples.size()); + sampleProvider.addSamples(samples); + } catch (final Exception e) { + GB.toast(getContext(), "Error saving workout gps samples", Toast.LENGTH_LONG, GB.ERROR, e); + } + } + + private void processActivitiesWithGps() { + LOG.debug("There are {} activities with gps to process", activitiesWithGps.size()); + + for (final BaseActivitySummary summary : activitiesWithGps) { + processGps(summary); + } + + activitiesWithGps.clear(); + + getDevice().unsetBusyTask(); + GB.updateTransferNotification(null, "", false, 100, getContext()); + } + + private void processGps(final BaseActivitySummary summary) { + final ActivityTrack activityTrack = buildActivityTrack(summary); + if (activityTrack == null) { + return; + } + + // Save the gpx file + final File gpxFile = exportGpx(summary, activityTrack); + if (gpxFile == null) { + return; + } + + // Update the summary in the db with the gpx path + try (DBHandler dbHandler = GBApplication.acquireDB()) { + final DaoSession session = dbHandler.getDaoSession(); + final Device device = DBHelper.getDevice(mSupport.getDevice(), session); + final User user = DBHelper.getUser(session); + + final BaseActivitySummaryDao summaryDao = session.getBaseActivitySummaryDao(); + final QueryBuilder qb = summaryDao.queryBuilder(); + qb.where(BaseActivitySummaryDao.Properties.StartTime.eq(summary.getStartTime())); + qb.where(BaseActivitySummaryDao.Properties.DeviceId.eq(device.getId())); + qb.where(BaseActivitySummaryDao.Properties.UserId.eq(user.getId())); + final List summaries = qb.build().list(); + + if (summaries.isEmpty()) { + LOG.warn("Failed to find existing summary in db - this should never happen"); + return; + } + if (summaries.size() > 1) { + LOG.warn("Found multiple summaries in db - this should never happen"); + } + + final BaseActivitySummary summaryToUpdate = summaries.get(0); + summaryToUpdate.setGpxTrack(gpxFile.getAbsolutePath()); + session.getBaseActivitySummaryDao().insertOrReplace(summaryToUpdate); + } catch (final Exception e) { + LOG.error("Failed to update summary with gpx path", e); + } + } + + @Nullable + private File exportGpx(final BaseActivitySummary summary, final ActivityTrack activityTrack) { + final GPXExporter exporter = new GPXExporter(); + exporter.setCreator(GBApplication.app().getNameAndVersion()); + + final String gpxFileName = FileUtils.makeValidFileName("gadgetbridge-" + DateTimeUtils.formatIso8601(summary.getStartTime()) + ".gpx"); + final File gpxTargetFile; + try { + gpxTargetFile = new File(FileUtils.getExternalFilesDir(), gpxFileName); + } catch (final IOException e) { + LOG.error("Failed to get external files dir", e); + return null; + } + + try { + exporter.performExport(activityTrack, gpxTargetFile); + } catch (final ActivityTrackExporter.GPXTrackEmptyException e) { + LOG.warn("Gpx is empty"); + return null; + } catch (IOException e) { + LOG.error("Failed to write gpx", e); + return null; + } + + return gpxTargetFile; + } + + @Nullable + private ActivityTrack buildActivityTrack(final BaseActivitySummary summary) { + final ActivityTrack track = new ActivityTrack(); + track.setUser(summary.getUser()); + track.setDevice(summary.getDevice()); + track.setName(createActivityName(summary)); + + final List gpsSamples; + final List hrSamples; + try (DBHandler handler = GBApplication.acquireDB()) { + final DaoSession session = handler.getDaoSession(); + + final CmfWorkoutGpsSampleProvider gpsSampleProvider = new CmfWorkoutGpsSampleProvider(getDevice(), session); + gpsSamples = gpsSampleProvider.getAllSamples(summary.getStartTime().getTime(), summary.getEndTime().getTime()); + + final CmfHeartRateSampleProvider hrSampleProvider = new CmfHeartRateSampleProvider(getDevice(), session); + hrSamples = new ArrayList<>(hrSampleProvider.getAllSamples(summary.getStartTime().getTime(), summary.getEndTime().getTime())); + } catch (final Exception e) { + LOG.error("Error while building activity track", e); + return null; + } + + Collections.sort(hrSamples, (a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp())); + + for (final CmfWorkoutGpsSample gpsSample : gpsSamples) { + final ActivityPoint ap = new ActivityPoint(new Date(gpsSample.getTimestamp())); + final GPSCoordinate coordinate = new GPSCoordinate( + gpsSample.getLongitude() / 10000000d, + gpsSample.getLatitude() / 10000000d, + -20000 + ); + ap.setLocation(coordinate); + + final CmfHeartRateSample hrSample = findNearestSample(hrSamples, gpsSample.getTimestamp()); + if (hrSample != null) { + ap.setHeartRate(hrSample.getHeartRate()); + } + + track.addTrackPoint(ap); + } + + return track; + } + + @Nullable + private CmfHeartRateSample findNearestSample(final List samples, final long timestamp) { + if (samples.isEmpty()) { + return null; + } + + if (timestamp < samples.get(0).getTimestamp()) { + return samples.get(0); + } + + if (timestamp > samples.get(samples.size() - 1).getTimestamp()) { + return samples.get(samples.size() - 1); + } + + int start = 0; + int end = samples.size() - 1; + + while (start <= end) { + final int mid = (start + end) / 2; + + if (timestamp < samples.get(mid).getTimestamp()) { + end = mid - 1; + } else if (timestamp > samples.get(mid).getTimestamp()) { + start = mid + 1; + } else { + return samples.get(mid); + } + } + + // FIXME return null if too far? + + if (samples.get(start).getTimestamp() - timestamp < timestamp - samples.get(end).getTimestamp()) { + return samples.get(start); + } + + return samples.get(end); + } + + protected static String createActivityName(final BaseActivitySummary summary) { + String name = summary.getName(); + String nameText = ""; + Long id = summary.getId(); + if (name != null) { + nameText = name + " - "; + } + return nameText + id; + } + + private Context getContext() { + return mSupport.getContext(); + } + + private GBDevice getDevice() { + return mSupport.getDevice(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivityType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivityType.java new file mode 100644 index 000000000..56d5d0782 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfActivityType.java @@ -0,0 +1,184 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; + +public enum CmfActivityType { + // Core (non-removable in official app) + INDOOR_RUNNING(0x03, R.string.activity_type_indoor_running, ActivityKind.TYPE_RUNNING), + OUTDOOR_RUNNING(0x02, R.string.activity_type_outdoor_running, ActivityKind.TYPE_RUNNING), + // Fitness + OUTDOOR_WALKING(0x01, R.string.activity_type_outdoor_walking, ActivityKind.TYPE_WALKING), + INDOOR_WALKING(0x19, R.string.activity_type_indoor_walking, ActivityKind.TYPE_WALKING), + OUTDOOR_CYCLING(0x05, R.string.activity_type_outdoor_cycling, ActivityKind.TYPE_CYCLING), + INDOOR_CYCLING(0x72, R.string.activity_type_indoor_cycling, ActivityKind.TYPE_INDOOR_CYCLING), + MOUNTAIN_HIKE(0x04, R.string.activity_type_mountain_hike, ActivityKind.TYPE_HIKING), + HIKING(0x1A, R.string.activity_type_hiking, ActivityKind.TYPE_HIKING), + CROSS_TRAINER(0x18, R.string.activity_type_cross_trainer), + FREE_TRAINING(0x10, R.string.activity_type_free_training, ActivityKind.TYPE_STRENGTH_TRAINING), + STRENGTH_TRAINING(0x13, R.string.activity_type_strength_training, ActivityKind.TYPE_STRENGTH_TRAINING), + YOGA(0x0F, R.string.activity_type_yoga, ActivityKind.TYPE_YOGA), + BOXING(0x21, R.string.activity_type_boxing), + ROWER(0x0E, R.string.activity_type_rower, ActivityKind.TYPE_ROWING_MACHINE), + DYNAMIC_CYCLE(0x0D, R.string.activity_type_dynamic_cycle), + STAIR_STEPPER(0x73, R.string.activity_type_stair_stepper), + TREADMILL(0x26, R.string.activity_type_treadmill, ActivityKind.TYPE_TREADMILL), + HIIT(0x5C, R.string.activity_type_hiit), + FITNESS_EXERCISES(0x4E, R.string.activity_type_fitness_exercises), + JUMP_ROPING(0x06, R.string.activity_type_jump_roping, ActivityKind.TYPE_JUMP_ROPING), + PILATES(0x2C, R.string.activity_type_pilates), + CROSSFIT(0x74, R.string.activity_type_crossfit), + FUNCTIONAL_TRAINING(0x2E, R.string.activity_type_functional_training), + PHYSICAL_TRAINING(0x2F, R.string.activity_type_physical_training), + TAEKWONDO(0x25, R.string.activity_type_taekwondo), + CROSS_COUNTRY_RUNNING(0x1B, R.string.activity_type_cross_country_running), + KARATE(0x29, R.string.activity_type_karate), + FENCING(0x54, R.string.activity_type_fencing), + CORE_TRAINING(0x4B, R.string.activity_type_core_training), + KENDO(0x75, R.string.activity_type_kendo), + HORIZONTAL_BAR(0x56, R.string.activity_type_horizontal_bar), + PARALLEL_BAR(0x57, R.string.activity_type_parallel_bar), + COOLDOWN(0x92, R.string.activity_type_cooldown), + CROSS_TRAINING(0x2B, R.string.activity_type_cross_training), + SIT_UPS(0x11, R.string.activity_type_sit_ups), + FITNESS_GAMING(0x4D, R.string.activity_type_fitness_gaming), + AEROBIC_EXERCISE(0x94, R.string.activity_type_aerobic_exercise), + ROLLING(0x95, R.string.activity_type_rolling), + FLEXIBILITY(0x31, R.string.activity_type_flexibility), + GYMNASTICS(0x23, R.string.activity_type_gymnastics), + TRACK_AND_FIELD(0x27, R.string.activity_type_track_and_field), + PUSH_UPS(0x67, R.string.activity_type_push_ups), + BATTLE_ROPE(0x99, R.string.activity_type_battle_rope), + SMITH_MACHINE(0x9A, R.string.activity_type_smith_machine), + PULL_UPS(0x66, R.string.activity_type_pull_ups), + PLANK(0x68, R.string.activity_type_plank), + JAVELIN(0x9E, R.string.activity_type_javelin), + LONG_JUMP(0x6C, R.string.activity_type_long_jump), + HIGH_JUMP(0x6A, R.string.activity_type_high_jump), + TRAMPOLINE(0x5F, R.string.activity_type_trampoline), + DUMBBELL(0x9F, R.string.activity_type_dumbbell), + // Dance + BELLY_DANCE(0x76, R.string.activity_type_belly_dance), + JAZZ_DANCE(0x77, R.string.activity_type_jazz_dance), + LATIN_DANCE(0x33, R.string.activity_type_latin_dance), + BALLET(0x36, R.string.activity_type_ballet), + STREET_DANCE(0x34, R.string.activity_type_street_dance), + ZUMBA(0x9B, R.string.activity_type_zumba), + OTHER_DANCE(0x78, R.string.activity_type_other_dance), + // Leisure sports + ROLLER_SKATING(0x58, R.string.activity_type_roller_skating), + MARTIAL_ARTS(0x38, R.string.activity_type_martial_arts), + TAI_CHI(0x1F, R.string.activity_type_tai_chi), + HULA_HOOPING(0x59, R.string.activity_type_hula_hooping), + DISC_SPORTS(0x43, R.string.activity_type_disc_sports), + DARTS(0x5A, R.string.activity_type_darts), + ARCHERY(0x30, R.string.activity_type_archery), + HORSE_RIDING(0x1D, R.string.activity_type_horse_riding), + KITE_FLYING(0x70, R.string.activity_type_kite_flying), + SWING(0x71, R.string.activity_type_swing), + STAIRS(0x15, R.string.activity_type_stairs), + FISHING(0x42, R.string.activity_type_fishing), + HAND_CYCLING(0x96, R.string.activity_type_hand_cycling), + MIND_AND_BODY(0x97, R.string.activity_type_mind_and_body), + WRESTLING(0x53, R.string.activity_type_wrestling), + KABADDI(0x9C, R.string.activity_type_kabaddi), + KARTING(0xA0, R.string.activity_type_karting), + // Ball sports + BADMINTON(0x09, R.string.activity_type_badminton), + TABLE_TENNIS(0x0A, R.string.activity_type_table_tennis, ActivityKind.TYPE_PINGPONG), + TENNIS(0x0C, R.string.activity_type_tennis), + BILLIARDS(0x7C, R.string.activity_type_billiards), + BOWLING(0x3B, R.string.activity_type_bowling), + VOLLEYBALL(0x49, R.string.activity_type_volleyball), + SHUTTLECOCK(0x20, R.string.activity_type_shuttlecock), + HANDBALL(0x39, R.string.activity_type_handball), + BASEBALL(0x3A, R.string.activity_type_baseball), + SOFTBALL(0x55, R.string.activity_type_softball), + CRICKET(0x0B, R.string.activity_type_cricket), + RUGBY(0x44, R.string.activity_type_rugby), + HOCKEY(0x1E, R.string.activity_type_hockey), + SQUASH(0x3C, R.string.activity_type_squash), + DODGEBALL(0x81, R.string.activity_type_dodgeball), + SOCCER(0x07, R.string.activity_type_soccer, ActivityKind.TYPE_SOCCER), + BASKETBALL(0x08, R.string.activity_type_basketball, ActivityKind.TYPE_BASKETBALL), + AUSTRALIAN_FOOTBALL(0x37, R.string.activity_type_australian_football), + GOLF(0x45, R.string.activity_type_golf), + PICKLEBALL(0x5B, R.string.activity_type_pickleball), + LACROSS(0x98, R.string.activity_type_lacross), + SHOT(0x9D, R.string.activity_type_shot), + // Water sports + SAILING(0x82, R.string.activity_type_sailing), + SURFING(0x64, R.string.activity_type_surfing), + JET_SKIING(0x87, R.string.activity_type_jet_skiing), + // Snow sports + SKATING(0x4C, R.string.activity_type_skating), + ICE_HOCKEY(0x24, R.string.activity_type_ice_hockey), + CURLING(0x3D, R.string.activity_type_curling), + SNOWBOARDING(0x3E, R.string.activity_type_snowboarding), + CROSS_COUNTRY_SKIING(0x6E, R.string.activity_type_cross_country_skiing), + SNOW_SPORTS(0x48, R.string.activity_type_snow_sports), + SKIING(0x22, R.string.activity_type_skiing), + // Extreme sports + SKATEBOARDING(0x60, R.string.activity_type_skateboarding), + ROCK_CLIMBING(0x69, R.string.activity_type_rock_climbing), + HUNTING(0x93, R.string.activity_type_hunting), + ; + + private final byte code; + @StringRes + private final int nameRes; + private final int activityKind; + + CmfActivityType(final int code, final int nameRes) { + this(code, nameRes, ActivityKind.TYPE_UNKNOWN); + } + + CmfActivityType(final int code, final int nameRes, final int activityKind) { + this.code = (byte) code; + this.nameRes = nameRes; + this.activityKind = activityKind; + } + + public byte getCode() { + return code; + } + + public int getActivityKind() { + return activityKind; + } + + @StringRes + public int getNameRes() { + return nameRes; + } + + @Nullable + public static CmfActivityType fromCode(final byte code) { + for (final CmfActivityType cmd : CmfActivityType.values()) { + if (cmd.getCode() == code) { + return cmd; + } + } + + return null; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java new file mode 100644 index 000000000..03762047b --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java @@ -0,0 +1,310 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import android.bluetooth.BluetoothGattCharacteristic; + +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.zip.CRC32; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class CmfCharacteristic { + private final Logger LOG = LoggerFactory.getLogger(CmfCharacteristic.class); + + private static final byte[] AES_IV = new byte[]{0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x5a}; + private static final byte PAYLOAD_HEADER = (byte) 0xf5; + + private final BluetoothGattCharacteristic bluetoothGattCharacteristic; + private final UUID characteristicUUID; + + private final Handler handler; + + private byte[] sessionKey; + + private int mtu = 247; + + private final Map chunkBuffers = new HashMap<>(); + + public CmfCharacteristic(final BluetoothGattCharacteristic bluetoothGattCharacteristic, + final Handler handler) { + this.bluetoothGattCharacteristic = bluetoothGattCharacteristic; + this.characteristicUUID = bluetoothGattCharacteristic.getUuid(); + this.handler = handler; + } + + public UUID getCharacteristicUUID() { + return characteristicUUID; + } + + public void setSessionKey(final byte[] sessionKey) { + this.sessionKey = sessionKey; + } + + public void setMtu(final int mtu) { + this.mtu = mtu; + } + + public void sendCommand(final TransactionBuilder builder, final CmfCommand cmd, final byte[] payload) { + final byte[][] chunks; + + if (shouldEncrypt(cmd)) { + chunks = makeChunksEncrypted(payload); + } else { + chunks = makeChunksPlaintext(payload); + } + + if (chunks == null) { + // Something went wrong chunking - error was already printed + return; + } + + for (int i = 0; i < chunks.length; i++) { + final byte[] chunk = chunks[i]; + + final ByteBuffer buf = ByteBuffer.allocate(chunk.length + 11).order(ByteOrder.BIG_ENDIAN); + buf.put(PAYLOAD_HEADER); + buf.putShort((short) chunk.length); + buf.putShort((short) cmd.getCmd1()); + buf.putShort((short) chunks.length); + buf.putShort((short) (i + 1)); + buf.putShort((short) cmd.getCmd2()); + buf.put(chunk); + + builder.write(bluetoothGattCharacteristic, buf.array()); + } + } + + private byte[][] makeChunksPlaintext(final byte[] payload) { + final int chunkSize = mtu - 20; + final int numChunks = (int) Math.ceil(payload.length / (float) chunkSize); + final byte[][] chunks = new byte[numChunks][]; + + final CRC32 crc = new CRC32(); + + for (int i = 0; i < numChunks; i++) { + final int startIdx = i * chunkSize; + final int endIdx = Math.min(startIdx + chunkSize, payload.length); + final byte[] chunk = ArrayUtils.subarray(payload, startIdx, endIdx); + + crc.reset(); + crc.update(chunk, 0, chunk.length); + + chunks[i] = ArrayUtils.addAll( + chunk, + BLETypeConversions.fromUint32((int) crc.getValue()) + ); + } + + return chunks; + } + + @Nullable + private byte[][] makeChunksEncrypted(final byte[] payload) { + if (payload.length == 0) { + return new byte[1][0]; + } + + // AES will output 16-byte blocks, exclude the protocol overhead (11 bytes) + final int maxEncryptedPayloadSize = ((mtu - 11) / 16) * 16; + final int maxPayloadSize = maxEncryptedPayloadSize - 4 - 1; // exclude 4 bytes for crc and 1 byte of aes padding + final int numChunks = (int) Math.ceil(payload.length / (float) (maxPayloadSize)); + final byte[][] chunks = new byte[numChunks][]; + + if (numChunks != 1) { + LOG.debug("Splitting payload into {} chunks of {} bytes", numChunks, maxPayloadSize); + } + + final CRC32 crc = new CRC32(); + + for (int i = 0; i < numChunks; i++) { + final int startIdx = i * maxPayloadSize; + final int endIdx = Math.min(startIdx + maxPayloadSize, payload.length); + final byte[] chunk = ArrayUtils.subarray(payload, startIdx, endIdx); + + crc.reset(); + crc.update(chunk, 0, chunk.length); + + final byte[] payloadToEncrypt = ArrayUtils.addAll( + chunk, + BLETypeConversions.fromUint32((int) crc.getValue()) + ); + + try { + chunks[i] = CryptoUtils.encryptAES_CBC_Pad(payloadToEncrypt, sessionKey, AES_IV); + } catch (final GeneralSecurityException e) { + LOG.error("Failed to encrypt chunk", e); + return null; + } + } + + return chunks; + } + + private boolean shouldEncrypt(final CmfCommand cmd) { + switch (cmd) { + case DATA_CHUNK_WRITE_AGPS: + case DATA_CHUNK_WRITE_WATCHFACE: + return false; + } + + return true; + } + + public void onCharacteristicChanged(final byte[] value) { + final ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN); + + final byte header = buf.get(); + if (header != PAYLOAD_HEADER) { + LOG.error("Unexpected first byte {}", String.format("0x%02x", header)); + return; + } + + final int encryptedPayloadLength = buf.getShort(); + final int cmd1 = buf.getShort() & 0xFFFF; + final int chunkCount = buf.getShort(); + final int chunkIndex = buf.getShort(); + final int cmd2 = buf.getShort() & 0xFFFF; + + final CmfCommand cmd = CmfCommand.fromCodes(cmd1, cmd2); + + final byte[] payload; + if (encryptedPayloadLength > 0) { + final byte[] encryptedPayload = new byte[encryptedPayloadLength]; + buf.get(encryptedPayload); + + try { + final byte[] decryptedPayload = CryptoUtils.decryptAES_CBC_Pad(encryptedPayload, sessionKey, AES_IV); + payload = ArrayUtils.subarray(decryptedPayload, 0, decryptedPayload.length - 4); + final int expectedCrc = BLETypeConversions.toUint32(decryptedPayload, decryptedPayload.length - 4); + final CRC32 crc = new CRC32(); + crc.update(payload, 0, payload.length); + final int actualCrc = (int) crc.getValue(); + if (actualCrc != expectedCrc) { + LOG.error("Payload CRC mismatch for {}: got {}, expected {}", cmd, String.format("%08X", actualCrc), String.format("%08X", expectedCrc)); + if (chunkCount > 1) { + chunkBuffers.remove(cmd); + } + return; + } + } catch (final GeneralSecurityException e) { + LOG.error("Failed to decrypt payload for {}", cmd, e); + if (chunkCount > 1) { + chunkBuffers.remove(cmd); + } + return; + } + } else { + payload = new byte[0]; + } + + LOG.debug( + "Got {}: {}{}", + chunkCount > 1 ? String.format(Locale.ROOT, "chunk %d/%d", chunkIndex, chunkCount) : "command", + cmd != null ? String.format("cmd=%s", cmd) : String.format("cmd1=0x%04x cmd2=0x%04x", cmd1, cmd2), + payload.length > 0 ? " payload=" + GB.hexdump(payload) : "" + ); + + if (cmd == null) { + // Just ignore unknown commands + LOG.warn("Unknown command cmd1={} cmd2={}", String.format("0x%04x", cmd1), String.format("0x%04x", cmd2)); + return; + } + + final byte[] fullPayload; + if (chunkCount == 1) { + // Single-chunk payload - just pass it through + fullPayload = payload; + } else { + final ChunkBuffer buffer; + if (chunkBuffers.containsKey(cmd)) { + buffer = Objects.requireNonNull(chunkBuffers.get(cmd)); + } else { + buffer = new ChunkBuffer(); + } + + if (chunkIndex != buffer.expectedChunk) { + LOG.warn("Got unexpected chunk, expected {}", buffer.expectedChunk); + + if (chunkIndex != 1) { + // This chunk is not the first one and we got out of sync - ignore it and do not proceed + return; + } + + // Just discard whatever we had and start over + buffer.baos.reset(); + } + + try { + buffer.baos.write(payload); + } catch (final IOException e) { + LOG.error("Failed to write payload to chunk buffer", e); + return; + } + + buffer.expectedChunk = chunkIndex + 1; + + if (chunkIndex != chunkCount) { + // Chunk buffer not full yet + return; + } + + LOG.debug("Got all {} chunks for {}", chunkCount, cmd); + + fullPayload = buffer.baos.toByteArray().clone(); + chunkBuffers.remove(cmd); + } + + if (handler == null) { + LOG.error("Handler is null for {}", characteristicUUID); + return; + } + + try { + handler.onCommand(cmd, fullPayload); + } catch (final Exception e) { + LOG.error("Exception while handling command", e); + } + } + + public interface Handler { + void onCommand(CmfCommand cmd, byte[] payload); + } + + private static class ChunkBuffer { + private int expectedChunk = 1; + private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommand.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommand.java new file mode 100644 index 000000000..6f46e3a88 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommand.java @@ -0,0 +1,124 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import androidx.annotation.Nullable; + +public enum CmfCommand { + ACTIVITY_DATA(0x0056, 0x0001), + ACTIVITY_FETCH_1(0xffff, 0x8005), + ACTIVITY_FETCH_2(0xffff, 0x9057), + ACTIVITY_FETCH_ACK_1(0xffff, 0x0005), + ACTIVITY_FETCH_ACK_2(0xffff, 0xa057), + ALARMS_GET(0x0063, 0x0002), + ALARMS_SET(0x0063, 0x0001), + APP_NOTIFICATION(0x0065, 0x0001), + AUTH_NONCE_REPLY(0xffff, 0x004c), + AUTH_NONCE_REQUEST(0xffff, 0x804b), + AUTH_PAIR_REPLY(0xffff, 0x0048), + AUTH_PAIR_REQUEST(0xffff, 0x8047), + AUTH_PHONE_NAME(0xffff, 0x8049), + AUTH_WATCH_MAC(0xffff, 0x0049), + AUTHENTICATED_CONFIRM_REPLY(0xffff, 0x0004), + AUTHENTICATED_CONFIRM_REQUEST(0xffff, 0x804d), + BATTERY(0x005c, 0x0001), + CALL_REMINDER(0xffff, 0x9066), + CONTACTS_GET(0x00d5, 0x0002), + CONTACTS_SET(0x00d5, 0x0001), + DATA_CHUNK_REQUEST_AGPS(0xffff, 0xa05f), + DATA_CHUNK_REQUEST_WATCHFACE(0xffff, 0xa064), + DATA_CHUNK_WRITE_AGPS(0xffff, 0x905f), + DATA_CHUNK_WRITE_WATCHFACE(0xffff, 0x9064), + DATA_TRANSFER_AGPS_FINISH_ACK_1(0xffff, 0xa060), + DATA_TRANSFER_AGPS_FINISH_ACK_2(0xffff, 0x9060), + DATA_TRANSFER_AGPS_INIT_REPLY(0xffff, 0xa05e), + DATA_TRANSFER_AGPS_INIT_REQUEST(0xffff, 0x905e), + DATA_TRANSFER_WATCHFACE_FINISH_ACK_1(0xffff, 0xa065), + DATA_TRANSFER_WATCHFACE_FINISH_ACK_2(0xffff, 0x9065), + DATA_TRANSFER_WATCHFACE_INIT_1_REQUEST(0xffff, 0x8052), + DATA_TRANSFER_WATCHFACE_INIT_1_REPLY(0xffff, 0x0052), + DATA_TRANSFER_WATCHFACE_INIT_2_REPLY(0xffff, 0xa063), + DATA_TRANSFER_WATCHFACE_INIT_2_REQUEST(0xffff, 0x9063), + DO_NOT_DISTURB(0x0099, 0x0001), + FACTORY_RESET(0x009a, 0x0001), + FIND_PHONE(0x005b, 0x0001), + FIND_WATCH(0x005d, 0x0001), + FIRMWARE_VERSION_GET(0xffff, 0x8006), + FIRMWARE_VERSION_RET(0xffff, 0x0006), + GOALS_SET(0x005e, 0x0001), + GPS_COORDS(0xffff, 0x906a), + HEART_MONITORING_ALERTS(0xffff, 0x9059), + HEART_MONITORING_ENABLED_GET(0x009b, 0x0002), + HEART_MONITORING_ENABLED_SET(0x009b, 0x0001), + HEART_RATE_RESTING(0x00da, 0x0001), + HEART_RATE_MANUAL_AUTO(0x0053, 0x0001), + HEART_RATE_WORKOUT(0x00e0, 0x0001), + LANGUAGE_RET(0xffff, 0xa06b), + LANGUAGE_SET(0xffff, 0x9058), + MUSIC_BUTTON(0xffff, 0xa05d), + MUSIC_INFO_ACK(0xffff, 0xa05c), + MUSIC_INFO_SET(0xffff, 0x905c), + SERIAL_NUMBER_GET(0x00de, 0x0002), + SERIAL_NUMBER_RET(0x00de, 0x0001), + SLEEP_DATA(0x0058, 0x0001), + SPO2(0x0055, 0x0001), + SPORTS_SET(0x00dc, 0x0001), + STANDING_REMINDER_GET(0x0060, 0x0002), + STANDING_REMINDER_SET(0x0060, 0x0001), + STRESS(0x009d, 0x0001), + TIME_FORMAT(0x005f, 0x0001), + TIME(0xffff, 0x8004), + TRIGGER_SYNC(0x005c, 0x0002), + UNIT_LENGTH(0xffff, 0x9067), + UNIT_TEMPERATURE(0xffff, 0x9068), + WAKE_ON_WRIST_RAISE(0x0062, 0x0001), + WATCHFACE(0x009f, 0x0001), + WATER_REMINDER_GET(0x0061, 0x0002), + WATER_REMINDER_SET(0x0061, 0x0001), + WEATHER_SET_1(0xffff, 0x906b), + WEATHER_SET_2(0x0066, 0x0001), + WORKOUT_GPS(0xffff, 0xa05a), + WORKOUT_SUMMARY(0x0057, 0x0001), + ; + + private final int cmd1; + private final int cmd2; + + CmfCommand(final int cmd1, final int cmd2) { + this.cmd1 = cmd1; + this.cmd2 = cmd2; + } + + public int getCmd1() { + return cmd1; + } + + public int getCmd2() { + return cmd2; + } + + @Nullable + public static CmfCommand fromCodes(final int cmd1, final int cmd2) { + for (final CmfCommand cmd : CmfCommand.values()) { + if (cmd.getCmd1() == cmd1 && cmd.getCmd2() == cmd2) { + return cmd; + } + } + + return null; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfDataUploader.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfDataUploader.java new file mode 100644 index 000000000..9e4571193 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfDataUploader.java @@ -0,0 +1,198 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import android.net.Uri; + +import org.apache.commons.lang3.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Random; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetProgressAction; + +public class CmfDataUploader implements CmfCharacteristic.Handler { + private static final Logger LOG = LoggerFactory.getLogger(CmfWatchProSupport.class); + + private final CmfWatchProSupport mSupport; + + private CmfFwHelper fwHelper; + + public CmfDataUploader(final CmfWatchProSupport support) { + this.mSupport = support; + } + + @Override + public void onCommand(final CmfCommand cmd, final byte[] payload) { + switch (cmd) { + case DATA_TRANSFER_WATCHFACE_INIT_1_REPLY: + if (payload[0] != 0x01) { + LOG.warn("Got unexpected transfer init 1 reply {}", payload[0]); + fwHelper = null; + return; + } + + final ByteBuffer buf = ByteBuffer.allocate(9).order(ByteOrder.BIG_ENDIAN); + buf.put((byte) (0xa5)); + buf.putInt(fwHelper.getBytes().length); + buf.putInt(new Random().nextInt()); // FIXME watchface ID? + + mSupport.sendData( + "transfer watchface init 2 request", + CmfCommand.DATA_TRANSFER_WATCHFACE_INIT_2_REQUEST, + buf.array() + ); + return; + case DATA_TRANSFER_AGPS_INIT_REPLY: + case DATA_TRANSFER_WATCHFACE_INIT_2_REPLY: + if (payload[0] != 0x01) { + LOG.warn("Got unexpected transfer 2 init reply {}", payload[0]); + fwHelper = null; + return; + } + + setDeviceBusy(); + updateProgress(0, true); + + return; + case DATA_TRANSFER_WATCHFACE_FINISH_ACK_1: + handleAck1(CmfCommand.DATA_TRANSFER_WATCHFACE_FINISH_ACK_2, payload); + return; + case DATA_TRANSFER_AGPS_FINISH_ACK_1: + handleAck1(CmfCommand.DATA_TRANSFER_AGPS_FINISH_ACK_2, payload); + return; + case DATA_CHUNK_REQUEST_AGPS: + if (fwHelper == null || !fwHelper.isAgps()) { + LOG.warn("We are not sending AGPS - refusing request"); + return; + } + handleChunkRequest(CmfCommand.DATA_CHUNK_REQUEST_AGPS, payload); + return; + case DATA_CHUNK_REQUEST_WATCHFACE: + if (fwHelper == null || !fwHelper.isWatchface()) { + LOG.warn("We are not sending a watchface - refusing request"); + return; + } + handleChunkRequest(CmfCommand.DATA_CHUNK_WRITE_WATCHFACE, payload); + return; + } + + LOG.warn("Got unknown data command {}", cmd); + } + + public void onInstallApp(final Uri uri) { + if (fwHelper != null) { + LOG.warn("Already installing {}", fwHelper.getUri()); + return; + } + + fwHelper = new CmfFwHelper(uri, mSupport.getContext()); + if (!fwHelper.isValid()) { + LOG.warn("Uri {} is not valid", uri); + fwHelper = null; + return; + } + + if (fwHelper.isWatchface()) { + mSupport.sendData( + "transfer watchface init request", + CmfCommand.DATA_TRANSFER_WATCHFACE_INIT_1_REQUEST, + (byte) 0xa5 + ); + + return; + } + + LOG.warn("Unsupported fwHelper for {}", fwHelper.getUri()); + fwHelper = null; + } + + private void handleChunkRequest(final CmfCommand commandReply, final byte[] payload) { + final ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN); + final int offset = buf.getInt(); + final int length = buf.getInt(); + final int progress = buf.get(); + + LOG.debug("Got chunk request: offset={}, length={}, progress={}", offset, length, progress); + + final TransactionBuilder builder = mSupport.createTransactionBuilder("send chunk offset " + offset); + updateProgress(builder, progress, true); + mSupport.sendData( + "transfer watchface init request", + commandReply, + ArrayUtils.subarray(fwHelper.getBytes(), offset, offset + length) + ); + } + + private void handleAck1(final CmfCommand commandReply, final byte[] payload) { + if (payload[0] != 0x01) { + LOG.warn("Got unexpected transfer finish reply {}", payload[0]); + fwHelper = null; + } + + LOG.debug("Got transfer finish ack 1"); + + unsetDeviceBusy(); + updateProgress(100, false); + mSupport.sendData("transfer finish", commandReply, (byte) 0xa5); +} + + private void updateProgress(final int progressPercent, boolean ongoing) { + final TransactionBuilder builder = mSupport.createTransactionBuilder("update data upload progress to " + progressPercent); + updateProgress(builder, progressPercent, ongoing); + builder.queue(mSupport.getQueue()); + } + + private void updateProgress(final TransactionBuilder builder, final int progressPercent, boolean ongoing) { + final int uploadMessage; + if (fwHelper != null && fwHelper.isWatchface()) { + uploadMessage = R.string.uploading_watchface; + } else { + uploadMessage = R.string.updating_firmware; + } + + builder.add(new SetProgressAction( + mSupport.getContext().getString(uploadMessage), + ongoing, + progressPercent, + mSupport.getContext() + )); + } + + private void setDeviceBusy() { + final GBDevice device = mSupport.getDevice(); + device.setBusyTask(mSupport.getContext().getString(R.string.updating_firmware)); + device.sendDeviceUpdateIntent(mSupport.getContext()); + } + + private void unsetDeviceBusy() { + final GBDevice device = mSupport.getDevice(); + if (device != null && device.isConnected()) { + if (device.isBusy()) { + device.unsetBusyTask(); + device.sendDeviceUpdateIntent(mSupport.getContext()); + } + device.sendDeviceUpdateIntent(mSupport.getContext()); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfFwHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfFwHelper.java new file mode 100644 index 000000000..95c5ceb9a --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfFwHelper.java @@ -0,0 +1,187 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import android.content.Context; +import android.net.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; + +import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; +import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; +import nodomain.freeyourgadget.gadgetbridge.util.UriHelper; + +public class CmfFwHelper { + private static final Logger LOG = LoggerFactory.getLogger(CmfFwHelper.class); + + private static final byte[] HEADER_WATCHFACE = new byte[]{0x01, 0x00, 0x00, 0x02}; + private static final byte[] HEADER_FIRMWARE = new byte[]{'A', 'O', 'T', 'A'}; + private static final byte[] HEADER_AGPS = new byte[]{0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30}; + + private final Uri uri; + private byte[] fw; + private boolean typeFirmware; + private boolean typeWatchface; + private boolean typeAgps; + + private String name; + private String version; + + public CmfFwHelper(final Uri uri, final Context context) { + this.uri = uri; + + final UriHelper uriHelper; + try { + uriHelper = UriHelper.get(uri, context); + } catch (final IOException e) { + LOG.error("Failed to get uri helper for {}", uri, e); + return; + } + + final int maxExpectedFileSize = 1024 * 1024 * 32; // 32MB + + if (uriHelper.getFileSize() > maxExpectedFileSize) { + LOG.warn("File size is larger than the maximum expected file size of {}", maxExpectedFileSize); + return; + } + + try (final InputStream in = new BufferedInputStream(uriHelper.openInputStream())) { + this.fw = FileUtils.readAll(in, maxExpectedFileSize); + } catch (final IOException e) { + LOG.error("Failed to read bytes from {}", uri, e); + return; + } + + parseBytes(); + } + + public Uri getUri() { + return uri; + } + + public boolean isValid() { + return isWatchface() || isFirmware() || isAgps(); + } + + public boolean isWatchface() { + return typeWatchface; + } + + public boolean isFirmware() { + return typeFirmware; + } + + public boolean isAgps() { + return typeAgps; + } + + public String getDetails() { + return name != null ? name : (version != null ? version : "UNKNOWN"); + } + + public byte[] getBytes() { + return fw; + } + + public String getName() { + return name; + } + + public String getVersion() { + return version; + } + + public void unsetFwBytes() { + this.fw = null; + } + + private void parseBytes() { + if (parseAsWatchface()) { + assert name != null; + typeWatchface = true; + } else if (parseAsFirmware()) { + assert version != null; + typeFirmware = true; + } else if (parseAsAgps()) { + typeAgps = true; + } + } + + private boolean parseAsWatchface() { + if (!ArrayUtils.equals(fw, HEADER_WATCHFACE, 4)) { + LOG.warn("File header not a watchface"); + return false; + } + + final String nameHeader = StringUtils.untilNullTerminator(fw, 8); + if (nameHeader == null) { + LOG.warn("watchface name not found in {}", uri); + return false; + } + + // Confirm it's a watchface by finding the same name at the end + final String nameTrailer = StringUtils.untilNullTerminator(fw, fw.length - 28); + if (nameTrailer == null) { + LOG.warn("watchface name not found at the end of {}", uri); + return false; + } + + if (!nameHeader.equals(nameTrailer)) { + LOG.warn("Names in header and trailer do not match"); + return false; + } + + name = nameHeader; + + return true; + } + + private boolean parseAsFirmware() { + if (!ArrayUtils.equals(fw, HEADER_FIRMWARE, 0)) { + LOG.warn("File header not a firmware"); + return false; + } + + // FIXME: This is not really the version, but build number? + final String versionHeader = StringUtils.untilNullTerminator(fw, 64); + if (versionHeader == null) { + LOG.warn("firmware version not found in {}", uri); + return false; + } + + version = versionHeader; + + return true; + } + + private boolean parseAsAgps() { + if (!ArrayUtils.equals(fw, HEADER_AGPS, 0)) { + LOG.warn("File header not agps"); + return false; + } + + // TODO parse? and set something + + return true; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfInstallHandler.java new file mode 100644 index 000000000..0c9c12475 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfInstallHandler.java @@ -0,0 +1,90 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import android.content.Context; +import android.net.Uri; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.InstallActivity; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.GenericItem; + +public class CmfInstallHandler implements InstallHandler { + protected final Uri mUri; + protected final Context mContext; + protected final CmfFwHelper helper; + + public CmfInstallHandler(final Uri uri, final Context context) { + this.mUri = uri; + this.mContext = context; + this.helper = new CmfFwHelper(uri, context); + } + + @Override + public boolean isValid() { + return helper.isValid(); + } + + @Override + public void validateInstallation(final InstallActivity installActivity, final GBDevice device) { + if (device.isBusy()) { + installActivity.setInfoText(device.getBusyTask()); + installActivity.setInstallEnabled(false); + return; + } + + if (!device.isInitialized()) { + installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_ready)); + installActivity.setInstallEnabled(false); + return; + } + + if (!helper.isValid()) { + installActivity.setInfoText(mContext.getString(R.string.fwapp_install_device_not_supported)); + installActivity.setInstallEnabled(false); + return; + } + + final GenericItem installItem = new GenericItem(); + if (helper.isWatchface()) { + installItem.setIcon(R.drawable.ic_watchface); + installItem.setName(mContext.getString(R.string.kind_watchface)); + } else if (helper.isFirmware()) { + installItem.setIcon(R.drawable.ic_firmware); + installItem.setName(mContext.getString(R.string.kind_firmware)); + } else if (helper.isAgps()) { + installItem.setIcon(R.drawable.ic_firmware); + installItem.setName(mContext.getString(R.string.kind_agps_bundle)); + } else { + installItem.setIcon(R.drawable.ic_device_unknown); + installItem.setName(mContext.getString(R.string.kind_invalid)); + } + + installItem.setDetails(helper.getDetails()); + + installActivity.setInfoText(mContext.getString(R.string.firmware_install_warning, "(unknown)")); + installActivity.setInstallItem(installItem); + installActivity.setInstallEnabled(true); + } + + @Override + public void onStartInstall(final GBDevice device) { + helper.unsetFwBytes(); // free up memory + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfNotificationIcon.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfNotificationIcon.java new file mode 100644 index 000000000..a03e97a76 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfNotificationIcon.java @@ -0,0 +1,75 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; + +public enum CmfNotificationIcon { + GENERIC_SMS(0), + WHATSAPP(8), + SNAPCHAT(9), + WHATSAPP_BUSINESS(10), + TRUECALLER(11), // blue phone + TELEGRAM(12), + FACEBOOK_MESSENGER(13), + IMO(14), + CALLAPP(15), + FACEBOOK(17), + INSTAGRAM(18), + TIKTOK(19), + LINE(20), + DISCORD(21), + GOOGLE_VOICE(22), + GMAIL(27), + OUTLOOK(29), + UNKNOWN(255), + ; + + private final byte code; + + CmfNotificationIcon(final int code) { + this.code = (byte) code; + } + + public byte getCode() { + return code; + } + + public static CmfNotificationIcon forNotification(final NotificationSpec notificationSpec) { + if (notificationSpec.type == null) { + return UNKNOWN; + } + + try { + // If there's a matching enum, just return it + return CmfNotificationIcon.valueOf(notificationSpec.type.name()); + } catch (final IllegalArgumentException ignored) { + // ignored + } + + switch (notificationSpec.type.getGenericType()) { + case "generic_chat": + return GENERIC_SMS; + case "generic_email": + return GMAIL; + case "generic_phone": + return TRUECALLER; + } + + return UNKNOWN; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfPreferences.java new file mode 100644 index 000000000..d241fd0a5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfPreferences.java @@ -0,0 +1,392 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import android.content.Context; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class CmfPreferences { + private static final Logger LOG = LoggerFactory.getLogger(CmfPreferences.class); + + private final CmfWatchProSupport mSupport; + + protected CmfPreferences(final CmfWatchProSupport support) { + this.mSupport = support; + } + + protected void onSetHeartRateMeasurementInterval(final int seconds) { + final boolean enabled = seconds == -1; + LOG.debug("Set HR smart monitoring = {}", enabled); + + final byte[] cmd = new byte[]{0x01, (byte) (enabled ? 0x01 : 0x00)}; + mSupport.sendCommand("set hr monitoring", CmfCommand.HEART_MONITORING_ENABLED_SET, cmd); + } + + protected void onSendConfiguration(final String config) { + switch (config) { + case ActivityUser.PREF_USER_STEPS_GOAL: + case ActivityUser.PREF_USER_DISTANCE_METERS: + case ActivityUser.PREF_USER_CALORIES_BURNT: + setGoals(); + return; + case SettingsActivity.PREF_MEASUREMENT_SYSTEM: + setMeasurementSystem(); + return; + case DeviceSettingsPreferenceConst.PREF_LANGUAGE: + setLanguage(); + return; + case DeviceSettingsPreferenceConst.PREF_TIMEFORMAT: + setTimeFormat(); + return; + case DeviceSettingsPreferenceConst.PREF_LIFTWRIST_NOSHED: + setDisplayOnLift(); + return; + case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD: + case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD: + case DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD: + case DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD: + setHeartAlerts(); + return; + case DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING: + setSpo2MonitoringInterval(); + return; + case DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING: + setStressMonitoringInterval(); + return; + case DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE: + case DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD: + case DeviceSettingsPreferenceConst.PREF_INACTIVITY_START: + case DeviceSettingsPreferenceConst.PREF_INACTIVITY_END: + case DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND: + case DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_START: + case DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_END: + setStandingReminder(); + case DeviceSettingsPreferenceConst.PREF_HYDRATION_SWITCH: + case DeviceSettingsPreferenceConst.PREF_HYDRATION_PERIOD: + case DeviceSettingsPreferenceConst.PREF_HYDRATION_DND: + case DeviceSettingsPreferenceConst.PREF_HYDRATION_DND_START: + case DeviceSettingsPreferenceConst.PREF_HYDRATION_DND_END: + setHydrationReminder(); + return; + case HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE: + setActivityTypes(); + return; + // TODO call reminders + } + + LOG.warn("Unknown config changed: {}", config); + } + + private void setGoals() { + final ActivityUser activityUser = new ActivityUser(); + + if (activityUser.getStepsGoal() <= 0) { + LOG.warn("Invalid steps goal {}", activityUser.getStepsGoal()); + return; + } + + if (activityUser.getDistanceGoalMeters() <= 0) { + LOG.warn("Invalid distance goal {}", activityUser.getDistanceGoalMeters()); + return; + } + + if (activityUser.getCaloriesBurntGoal() <= 0) { + LOG.warn("Invalid calories goal {}", activityUser.getCaloriesBurntGoal()); + return; + } + + LOG.debug( + "Setting goals, steps={}, distance={}, calories={}", + activityUser.getStepsGoal(), + activityUser.getDistanceGoalMeters(), + activityUser.getCaloriesBurntGoal() + ); + + final ByteBuffer buf = ByteBuffer.allocate(10).order(ByteOrder.BIG_ENDIAN); + + buf.put((byte) 0); // ? + buf.put((byte) 0); // ? + buf.putShort((short) activityUser.getStepsGoal()); + buf.put((byte) 0); // ? + buf.put((byte) 0); // ? + buf.putShort((short) activityUser.getDistanceGoalMeters()); + buf.putShort((short) activityUser.getCaloriesBurntGoal()); + + mSupport.sendCommand("set goals", CmfCommand.GOALS_SET, buf.array()); + } + + private void setMeasurementSystem() { + final Prefs prefs = mSupport.getDevicePrefs(); + final String measurementSystem = prefs.getString(SettingsActivity.PREF_MEASUREMENT_SYSTEM, "metric"); + + LOG.debug("Setting measurement system to {}", measurementSystem); + + final byte unitByte = (byte) ("metric".equals(measurementSystem) ? 0x00 : 0x01); + + final byte[] cmd = new byte[]{0x01, unitByte}; + final TransactionBuilder builder = mSupport.createTransactionBuilder("set measurement system"); + mSupport.sendCommand(builder, CmfCommand.UNIT_LENGTH, cmd); + mSupport.sendCommand(builder, CmfCommand.UNIT_TEMPERATURE, cmd); + builder.queue(mSupport.getQueue()); + } + + private void setLanguage() { + String localeString = mSupport.getDevicePrefs().getString( + DeviceSettingsPreferenceConst.PREF_LANGUAGE, DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO + ); + if (DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO.equals(localeString)) { + String language = Locale.getDefault().getLanguage(); + String country = Locale.getDefault().getCountry(); + + if (nodomain.freeyourgadget.gadgetbridge.util.StringUtils.isNullOrEmpty(country)) { + // sometimes country is null, no idea why, guess it. + country = language; + } + localeString = (language + "_" + country).toLowerCase(Locale.ROOT); + } + + String languageCommand = null; + if (LANGUAGES.containsKey(localeString)) { + languageCommand = localeString; + } else { + // Break down the language code and attempt to find it + final String[] languageParts = localeString.split("_"); + for (int i = 0; i < languageParts.length; i++) { + if (LANGUAGES.containsKey(languageParts[0])) { + languageCommand = languageParts[0]; + break; + } + } + } + + if (languageCommand == null) { + LOG.warn("Unknown language {}", localeString); + return; + } + + LOG.info("Set language: {} -> {}", localeString, languageCommand); + + // FIXME watch ignores language? + mSupport.sendCommand("set language", CmfCommand.LANGUAGE_SET, languageCommand.getBytes()); + } + + private void setTimeFormat() { + final GBPrefs gbPrefs = new GBPrefs(mSupport.getDevicePrefs()); + final String timeFormat = gbPrefs.getTimeFormat(); + + LOG.info("Setting time format to {}", timeFormat); + + final byte timeFormatByte = (byte) (timeFormat.equals("24h") ? 0x00 : 0x01); + + mSupport.sendCommand("set time format", CmfCommand.TIME_FORMAT, timeFormatByte); + } + + private void setDisplayOnLift() { + final Prefs prefs = mSupport.getDevicePrefs(); + + boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_LIFTWRIST_NOSHED, false); + + mSupport.sendCommand("set display on lift", CmfCommand.WAKE_ON_WRIST_RAISE, (byte) (enabled ? 0x01 : 0x00)); + } + + private void setHeartAlerts() { + final Prefs prefs = mSupport.getDevicePrefs(); + + final int hrAlertActiveHigh = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ACTIVE_HIGH_THRESHOLD, 0); + final int hrAlertHigh = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD, 0); + final int hrAlertLow = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD, 0); + final int spo2alert = prefs.getInt(DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD, 0); + + final ByteBuffer buf; + if (hrAlertActiveHigh == 0 && hrAlertHigh == 0 && hrAlertLow == 0 && spo2alert == 0) { + buf = ByteBuffer.allocate(1).order(ByteOrder.BIG_ENDIAN); + buf.put((byte) 0x00); + } else { + buf = ByteBuffer.allocate(9).order(ByteOrder.BIG_ENDIAN); + buf.put((byte) 0x01); + buf.put((byte) hrAlertLow); + buf.put((byte) (hrAlertHigh != 0 ? hrAlertHigh : 255)); + buf.put((byte) (hrAlertActiveHigh != 0 ? hrAlertActiveHigh : 255)); + buf.put((byte) spo2alert); + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + } + + mSupport.sendCommand("set heart monitoring alerts", CmfCommand.HEART_MONITORING_ALERTS, buf.array()); + } + + private void setSpo2MonitoringInterval() { + final Prefs prefs = mSupport.getDevicePrefs(); + final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false); + + LOG.debug("Set SpO2 monitoring = {}", enabled); + + final byte[] cmd = new byte[]{0x02, (byte) (enabled ? 0x01 : 0x00)}; + mSupport.sendCommand("set spo2 monitoring", CmfCommand.HEART_MONITORING_ENABLED_SET, cmd); + } + + private void setStressMonitoringInterval() { + final Prefs prefs = mSupport.getDevicePrefs(); + final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING, false); + + LOG.debug("Set stress monitoring = {}", enabled); + + final byte[] cmd = new byte[]{0x04, (byte) (enabled ? 0x01 : 0x00)}; + mSupport.sendCommand("set stress monitoring", CmfCommand.HEART_MONITORING_ENABLED_SET, cmd); + } + + private void setStandingReminder() { + final Prefs prefs = mSupport.getDevicePrefs(); + final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE, false); + final int threshold = prefs.getInt(DeviceSettingsPreferenceConst.PREF_INACTIVITY_THRESHOLD, 60); + final boolean dnd = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND, false); + final Date dndStart = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_START, "12:00"); + final Date dndEnd = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_END, "14:00"); + + final Calendar calendar = GregorianCalendar.getInstance(); + + if (threshold < 0 || threshold > 180) { + LOG.error("Invalid inactivity threshold: {}", threshold); + return; + } + + final ByteBuffer buf = ByteBuffer.allocate(11).order(ByteOrder.BIG_ENDIAN); + buf.put((byte) (enabled ? 0x01 : 0x00)); + buf.putShort((short) threshold); + + if (enabled && dnd) { + calendar.setTime(dndStart); + buf.putInt((calendar.get(Calendar.HOUR_OF_DAY) * 3600 + calendar.get(Calendar.MINUTE) * 60)); + calendar.setTime(dndEnd); + buf.putInt((calendar.get(Calendar.HOUR_OF_DAY) * 3600 + calendar.get(Calendar.MINUTE) * 60)); + } else { + buf.putInt(0); + buf.putInt(0); + } + + mSupport.sendCommand("set standing reminders", CmfCommand.STANDING_REMINDER_SET, buf.array()); + } + + private void setHydrationReminder() { + final Prefs prefs = mSupport.getDevicePrefs(); + final boolean enabled = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HYDRATION_SWITCH, false); + final int threshold = prefs.getInt(DeviceSettingsPreferenceConst.PREF_HYDRATION_PERIOD, 60); + final boolean dnd = prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HYDRATION_DND, false); + final Date dndStart = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_HYDRATION_DND_START, "12:00"); + final Date dndEnd = prefs.getTimePreference(DeviceSettingsPreferenceConst.PREF_HYDRATION_DND_END, "14:00"); + + final Calendar calendar = GregorianCalendar.getInstance(); + + if (threshold < 0 || threshold > 180) { + LOG.error("Invalid hydration threshold: {}", threshold); + return; + } + + final ByteBuffer buf = ByteBuffer.allocate(11).order(ByteOrder.BIG_ENDIAN); + buf.put((byte) (enabled ? 0x01 : 0x00)); + buf.putShort((short) threshold); + + if (enabled && dnd) { + calendar.setTime(dndStart); + buf.putInt((calendar.get(Calendar.HOUR_OF_DAY) * 3600 + calendar.get(Calendar.MINUTE) * 60)); + calendar.setTime(dndEnd); + buf.putInt((calendar.get(Calendar.HOUR_OF_DAY) * 3600 + calendar.get(Calendar.MINUTE) * 60)); + } else { + buf.putInt(0); + buf.putInt(0); + } + + mSupport.sendCommand("set hydration reminders", CmfCommand.WATER_REMINDER_SET, buf.array()); + } + + private void setActivityTypes() { + final Prefs prefs = mSupport.getDevicePrefs(); + List activityTypes = new ArrayList<>(prefs.getList(HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE, Collections.emptyList())); + + if (activityTypes.isEmpty()) { + activityTypes.add(CmfActivityType.OUTDOOR_RUNNING.name().toLowerCase(Locale.ROOT)); + activityTypes.add(CmfActivityType.INDOOR_RUNNING.name().toLowerCase(Locale.ROOT)); + } + + if (activityTypes.size() > 36) { + LOG.warn("Truncating activity types list to 36"); + activityTypes = activityTypes.subList(0, 36); + } + + final ByteBuffer buf = ByteBuffer.allocate(activityTypes.size() + 1); + buf.put((byte) activityTypes.size()); + + for (final String activityType : activityTypes) { + buf.put(CmfActivityType.valueOf(activityType.toUpperCase(Locale.ROOT)).getCode()); + } + + mSupport.sendCommand("set activity types", CmfCommand.SPORTS_SET, buf.array()); + } + + protected boolean onCommand(final CmfCommand cmd, final byte[] payload) { + // TODO handle preference replies from watch + return false; + } + + private Context getContext() { + return mSupport.getContext(); + } + + private GBDevice getDevice() { + return mSupport.getDevice(); + } + + private static final Map LANGUAGES = new HashMap() {{ + put("ar", "ar_SA"); + put("de", "de_DE"); + put("en", "en_US"); + put("es", "es_ES"); + put("fr", "fr_FR"); + put("hi", "hi_IN"); + put("in", "id_ID"); + put("it", "it_IT"); + put("ja", "ja_JP"); + put("ko", "ko_KO"); + put("zh_cn", "zh_CN"); + put("zh_hk", "zh_HK"); + }}; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java new file mode 100644 index 000000000..2325a48a7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java @@ -0,0 +1,594 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.content.Context; +import android.content.SharedPreferences; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; + +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.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.TimeZone; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceInfo; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +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.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; +import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.MediaManager; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; + +public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements CmfCharacteristic.Handler { + private static final Logger LOG = LoggerFactory.getLogger(CmfWatchProSupport.class); + + public static final UUID UUID_SERVICE_CMF_CMD = UUID.fromString("0000fff0-0000-1000-8000-00805f9b34fb"); + public static final UUID UUID_CHARACTERISTIC_CMF_COMMAND_READ = UUID.fromString("0000fff1-0000-1000-8000-00805f9b34fb"); + public static final UUID UUID_CHARACTERISTIC_CMF_COMMAND_WRITE = UUID.fromString("0000fff2-0000-1000-8000-00805f9b34fb"); + + public static final UUID UUID_SERVICE_CMF_DATA = UUID.fromString("02f00000-0000-0000-0000-00000000ffe0"); + public static final UUID UUID_CHARACTERISTIC_CMF_DATA_WRITE = UUID.fromString("02f00000-0000-0000-0000-00000000ffe1"); + public static final UUID UUID_CHARACTERISTIC_CMF_DATA_READ = UUID.fromString("02f00000-0000-0000-0000-00000000ffe2"); + + // An a5 byte is used a lot in single payloads, probably as a "proof of encryption"? + public static final byte A5 = (byte) 0xa5; + + private CmfCharacteristic characteristicCommandRead; + private CmfCharacteristic characteristicCommandWrite; + private CmfCharacteristic characteristicDataRead; + private CmfCharacteristic characteristicDataWrite; + + private final CmfActivitySync activitySync = new CmfActivitySync(this); + private final CmfPreferences preferences = new CmfPreferences(this); + private CmfDataUploader dataUploader; + + protected MediaManager mediaManager = null; + + public CmfWatchProSupport() { + super(LOG); + addSupportedService(UUID_SERVICE_CMF_CMD); + addSupportedService(UUID_SERVICE_CMF_DATA); + } + + @Override + public boolean useAutoConnect() { + return true; + } + + @Override + public boolean getImplicitCallbackModify() { + return false; + } + + @Override + public boolean getSendWriteRequestResponse() { + return true; + } + + @Override + protected TransactionBuilder initializeDevice(final TransactionBuilder builder) { + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + + final BluetoothGattCharacteristic btCharacteristicCommandRead = getCharacteristic(UUID_CHARACTERISTIC_CMF_COMMAND_READ); + if (btCharacteristicCommandRead == null) { + LOG.warn("Characteristic command read is null, will attempt to reconnect"); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext())); + return builder; + } + + final BluetoothGattCharacteristic btCharacteristicCommandWrite = getCharacteristic(UUID_CHARACTERISTIC_CMF_COMMAND_WRITE); + if (btCharacteristicCommandWrite == null) { + LOG.warn("Characteristic command write is null, will attempt to reconnect"); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext())); + return builder; + } + + final BluetoothGattCharacteristic btCharacteristicDataWrite = getCharacteristic(UUID_CHARACTERISTIC_CMF_DATA_WRITE); + if (btCharacteristicDataWrite == null) { + LOG.warn("Characteristic data write is null, will attempt to reconnect"); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext())); + return builder; + } + + final BluetoothGattCharacteristic btCharacteristicDataRead = getCharacteristic(UUID_CHARACTERISTIC_CMF_DATA_READ); + if (btCharacteristicDataRead == null) { + LOG.warn("Characteristic data read is null, will attempt to reconnect"); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.WAITING_FOR_RECONNECT, getContext())); + return builder; + } + + dataUploader = new CmfDataUploader(this); + + characteristicCommandRead = new CmfCharacteristic(btCharacteristicCommandRead, this); + characteristicCommandWrite = new CmfCharacteristic(btCharacteristicCommandWrite, null); + characteristicDataRead = new CmfCharacteristic(btCharacteristicDataRead, dataUploader); + characteristicDataWrite = new CmfCharacteristic(btCharacteristicDataWrite, null); + + final byte[] secretKey = getSecretKey(getDevice()); + characteristicCommandRead.setSessionKey(secretKey); + characteristicCommandWrite.setSessionKey(secretKey); + characteristicDataRead.setSessionKey(secretKey); + characteristicDataWrite.setSessionKey(secretKey); + + builder.notify(btCharacteristicCommandWrite, true); + builder.notify(btCharacteristicCommandRead, true); + builder.notify(btCharacteristicDataWrite, true); + builder.notify(btCharacteristicDataRead, true); + + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.AUTHENTICATING, getContext())); + + sendCommand(builder, CmfCommand.AUTH_PHONE_NAME, ArrayUtils.addAll(new byte[]{A5}, Build.MODEL.getBytes(StandardCharsets.UTF_8))); + + return builder; + } + + @Override + public void setContext(final GBDevice device, final BluetoothAdapter adapter, final Context context) { + super.setContext(device, adapter, context); + + mediaManager = new MediaManager(context); + } + + @Override + protected Prefs getDevicePrefs() { + return super.getDevicePrefs(); + } + + @Override + public boolean onCharacteristicChanged(final BluetoothGatt gatt, + final BluetoothGattCharacteristic characteristic) { + if (super.onCharacteristicChanged(gatt, characteristic)) { + return true; + } + + final UUID characteristicUUID = characteristic.getUuid(); + final byte[] value = characteristic.getValue(); + + if (characteristicUUID.equals(characteristicCommandRead.getCharacteristicUUID())) { + characteristicCommandRead.onCharacteristicChanged(value); + return true; + } else if (characteristicUUID.equals(characteristicDataRead.getCharacteristicUUID())) { + characteristicDataRead.onCharacteristicChanged(value); + return true; + } + + LOG.warn("Unhandled characteristic changed: {} {}", characteristicUUID, GB.hexdump(value)); + return false; + } + + @Override + public void onMtuChanged(final BluetoothGatt gatt, final int mtu, final int status) { + super.onMtuChanged(gatt, mtu, status); + + characteristicCommandRead.setMtu(mtu); + characteristicCommandWrite.setMtu(mtu); + characteristicDataRead.setMtu(mtu); + characteristicDataWrite.setMtu(mtu); + } + + @Override + public void onCommand(final CmfCommand cmd, final byte[] payload) { + if (activitySync.onCommand(cmd, payload)) { + return; + } + + if (preferences.onCommand(cmd, payload)) { + return; + } + + switch (cmd) { + case AUTH_WATCH_MAC: + LOG.debug("Got auth watch mac, requesting nonce"); + sendCommand("auth request nonce", CmfCommand.AUTH_NONCE_REQUEST, A5); + return; + case AUTH_NONCE_REPLY: + LOG.debug("Got auth nonce"); + + try { + final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(payload); + sha256.update(getSecretKey(getDevice())); + final byte[] digest = sha256.digest(); + final byte[] sessionKey = ArrayUtils.subarray(digest, 0, 16); + LOG.debug("New session key: {}", GB.hexdump(sessionKey)); + characteristicCommandRead.setSessionKey(sessionKey); + characteristicCommandWrite.setSessionKey(sessionKey); + characteristicDataRead.setSessionKey(sessionKey); + characteristicDataWrite.setSessionKey(sessionKey); + } catch (final GeneralSecurityException e) { + LOG.error("Failed to compute session key from auth nonce", e); + return; + } + + sendCommand("auth confirm", CmfCommand.AUTHENTICATED_CONFIRM_REQUEST, A5); + return; + case AUTHENTICATED_CONFIRM_REPLY: + LOG.debug("Authentication confirmed, starting phase 2 initialization"); + + final TransactionBuilder phase2builder = createTransactionBuilder("phase 2 initialize"); + setTime(phase2builder); + sendCommand(phase2builder, CmfCommand.FIRMWARE_VERSION_GET); + sendCommand(phase2builder, CmfCommand.SERIAL_NUMBER_GET); + //sendCommand(phase2builder, CmfCommand.STANDING_REMINDER_GET); + //sendCommand(phase2builder, CmfCommand.WATER_REMINDER_GET); + //sendCommand(phase2builder, CmfCommand.CONTACTS_GET); + //sendCommand(phase2builder, CmfCommand.ALARMS_GET); + // TODO premature to mark as initialized? + phase2builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); + phase2builder.queue(getQueue()); + return; + case BATTERY: + final int battery = payload[0] & 0xff; + final boolean charging = payload[1] == 0x01; + LOG.debug("Got battery: level={} charging={}", battery, charging); + final GBDeviceEventBatteryInfo eventBatteryInfo = new GBDeviceEventBatteryInfo(); + eventBatteryInfo.level = battery; + eventBatteryInfo.state = charging ? BatteryState.BATTERY_CHARGING : BatteryState.BATTERY_NORMAL; + evaluateGBDeviceEvent(eventBatteryInfo); + return; + case FIRMWARE_VERSION_RET: + final String[] fwParts = new String[payload.length]; + for (int i = 0; i < payload.length; i++) { + fwParts[i] = String.valueOf(payload[i]); + } + final String fw = String.join(".", fwParts); + LOG.debug("Got firmware version: {}", fw); + final GBDeviceEventVersionInfo gbDeviceEventVersionInfo = new GBDeviceEventVersionInfo(); + gbDeviceEventVersionInfo.fwVersion = fw; + gbDeviceEventVersionInfo.fwVersion2 = "N/A"; + //gbDeviceEventVersionInfo.hwVersion = "?"; // TODO how? + evaluateGBDeviceEvent(gbDeviceEventVersionInfo); + return; + case SERIAL_NUMBER_RET: + if (payload.length != (payload[0] & 0xff) + 1) { + LOG.warn("Unexpected serial number payload length: {}, expected {}", payload.length, (payload[0] & 0xff)); + return; + } + final String serialNumber = new String(ArrayUtils.subarray(payload, 1, payload.length - 2)); + LOG.debug("Got serial number: {}", serialNumber); + final GBDeviceEventUpdateDeviceInfo gbDeviceEventUpdateDeviceInfo = new GBDeviceEventUpdateDeviceInfo("SERIAL: ", serialNumber); + evaluateGBDeviceEvent(gbDeviceEventUpdateDeviceInfo); + return; + case FIND_PHONE: + final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); + if (payload[0] == 1) { + findPhoneEvent.event = GBDeviceEventFindPhone.Event.START; + } else { + findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP; + } + evaluateGBDeviceEvent(findPhoneEvent); + return; + case MUSIC_INFO_ACK: + LOG.debug("Got music info ack"); + break; + case MUSIC_BUTTON: + final GBDeviceEventMusicControl deviceEventMusicControl = new GBDeviceEventMusicControl(); + switch (BLETypeConversions.toUint16(payload)) { + case 0x0003: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEDOWN; + break; + case 0x0103: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEUP; + break; + case 0x0001: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PAUSE; + break; + case 0x0101: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PLAY; + break; + case 0x0102: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.NEXT; + break; + case 0x0002: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PREVIOUS; + break; + default: + LOG.warn("Unexpected media button key {}", GB.hexdump(payload)); + return; + } + LOG.debug("Got media button {}", deviceEventMusicControl.event); + evaluateGBDeviceEvent(deviceEventMusicControl); + break; + default: + LOG.warn("Unhandled command: {}", cmd); + } + } + + public void sendCommand(final String taskName, final CmfCommand cmd, final byte... payload) { + final TransactionBuilder builder = createTransactionBuilder(taskName); + sendCommand(builder, cmd, payload); + builder.queue(getQueue()); + } + + public void sendCommand(final TransactionBuilder builder, final CmfCommand cmd, final byte... payload) { + characteristicCommandWrite.sendCommand(builder, cmd, payload); + } + + public void sendData(final String taskName, final CmfCommand cmd, final byte... payload) { + final TransactionBuilder builder = createTransactionBuilder(taskName); + characteristicDataWrite.sendCommand(builder, cmd, payload); + builder.queue(getQueue()); + } + + private static byte[] getSecretKey(final GBDevice device) { + final byte[] authKeyBytes = new byte[16]; + + final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()); + + final String authKey = sharedPrefs.getString("authkey", "").trim(); + if (StringUtils.isNotBlank(authKey)) { + final byte[] srcBytes; + // Allow both with and without 0x, to avoid user mistakes + if (authKey.length() == 34 && authKey.startsWith("0x")) { + srcBytes = GB.hexStringToByteArray(authKey.trim().substring(2)); + } else { + srcBytes = GB.hexStringToByteArray(authKey.trim()); + } + System.arraycopy(srcBytes, 0, authKeyBytes, 0, Math.min(srcBytes.length, 16)); + } + + return authKeyBytes; + } + + @Override + public void onNotification(final NotificationSpec notificationSpec) { + if (!getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS, true)) { + LOG.debug("App notifications disabled - ignoring"); + return; + } + + final String senderOrTitle = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.getFirstOf( + notificationSpec.sender, + notificationSpec.title + ); + + final String body = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.getFirstOf(notificationSpec.body, ""); + + final byte[] senderOrTitleBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(senderOrTitle, 20); // TODO confirm max + final byte[] bodyBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(body, 128); // TODO confirm max + + final ByteBuffer buf = ByteBuffer.allocate(7 + senderOrTitleBytes.length + bodyBytes.length) + .order(ByteOrder.BIG_ENDIAN); + + buf.put(CmfNotificationIcon.forNotification(notificationSpec).getCode()); + buf.put((byte) 0x00); // ? + buf.putInt((int) (notificationSpec.when / 1000)); + buf.put((byte) senderOrTitleBytes.length); + buf.put(senderOrTitleBytes); + buf.put(bodyBytes); + + sendCommand("send notification", CmfCommand.APP_NOTIFICATION, buf.array()); + } + + @Override + public void onSetContacts(final ArrayList contacts) { + final ByteBuffer buf = ByteBuffer.allocate(57 * contacts.size()).order(ByteOrder.BIG_ENDIAN); + + for (final Contact contact : contacts) { + final byte[] nameBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(contact.getName(), 32); + buf.put(nameBytes); + buf.put(new byte[32 - nameBytes.length]); + + final byte[] numberBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(contact.getNumber(), 25); + buf.put(numberBytes); + buf.put(new byte[25 - numberBytes.length]); + } + + sendCommand("set contacts", CmfCommand.CONTACTS_SET, ArrayUtils.subarray(buf.array(), 0, buf.position())); + } + + @Override + public void onSetTime() { + final TransactionBuilder builder = createTransactionBuilder("set time"); + setTime(builder); + builder.queue(getQueue()); + } + + private void setTime(final TransactionBuilder builder) { + final Calendar cal = Calendar.getInstance(); + final ByteBuffer buf = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN); + buf.putInt((int) (cal.getTimeInMillis() / 1000)); + buf.putInt(TimeZone.getDefault().getOffset(cal.getTimeInMillis())); + sendCommand(builder, CmfCommand.TIME, buf.array()); + } + + @Override + public void onSetAlarms(final ArrayList alarms) { + final ByteBuffer buf = ByteBuffer.allocate(40 * alarms.size()).order(ByteOrder.BIG_ENDIAN); + + int i = 0; + for (final Alarm alarm : alarms) { + if (alarm.getUnused()) { + continue; + } + + buf.putInt(alarm.getHour() * 3600 + alarm.getMinute() * 60); + buf.put((byte) i++); + buf.put((byte) (alarm.getEnabled() ? 0x01 : 0x00)); + buf.put((byte) alarm.getRepetition()); + buf.put((byte) 0xff); // ? + buf.put(new byte[24]); // ? + + // alarm labels do not show up on watch, even in official app + final byte[] labelBytes = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(alarm.getTitle(), 8); + buf.put(new byte[8 - labelBytes.length]); + buf.put(labelBytes); + } + + sendCommand("set alarms", CmfCommand.ALARMS_SET, ArrayUtils.subarray(buf.array(), 0, buf.position())); + } + + @Override + public void onSetCallState(final CallSpec callSpec) { + super.onSetCallState(callSpec); // TODO onSetCallState + } + + @Override + public void onSetCannedMessages(final CannedMessagesSpec cannedMessagesSpec) { + super.onSetCannedMessages(cannedMessagesSpec); // TODO onSetCannedMessages + } + + @Override + public void onSetMusicState(final MusicStateSpec stateSpec) { + if (mediaManager.onSetMusicState(stateSpec)) { + sendMusicStateToDevice(); + } + } + + @Override + public void onSetPhoneVolume(final float ignoredVolume) { + sendMusicStateToDevice(); + } + + @Override + public void onSetMusicInfo(final MusicSpec musicSpec) { + if (mediaManager.onSetMusicInfo(musicSpec)) { + sendMusicStateToDevice(); + } + } + + private void sendMusicStateToDevice() { + final MusicSpec musicSpec = mediaManager.getBufferMusicSpec(); + final MusicStateSpec musicStateSpec = mediaManager.getBufferMusicStateSpec(); + + final byte stateByte; + if (musicSpec == null || musicStateSpec == null) { + stateByte = 0x00; + } else if (musicStateSpec.state == MusicStateSpec.STATE_PLAYING) { + stateByte = 0x01; + } else { + stateByte = 0x02; + } + + final byte[] track; + final byte[] artist; + + if (musicSpec != null) { + track = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(musicSpec.track, 63); + artist = nodomain.freeyourgadget.gadgetbridge.util.StringUtils.truncateToBytes(musicSpec.artist, 63); + } else { + track = new byte[0]; + artist = new byte[0]; + } + + final AudioManager audioManager = (AudioManager) getContext().getSystemService(Context.AUDIO_SERVICE); + final int volumeLevel = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC); + final int volumeMax = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + + final ByteBuffer buf = ByteBuffer.allocate(131); + buf.put(stateByte); + buf.put((byte) volumeLevel); + buf.put((byte) volumeMax); + buf.put(track); + buf.put(new byte[64 - track.length]); + buf.put(artist); + buf.put(new byte[64 - artist.length]); + + sendCommand("set music info", CmfCommand.MUSIC_INFO_SET, buf.array()); + } + + @Override + public void onInstallApp(final Uri uri) { + dataUploader.onInstallApp(uri); + } + + @Override + public void onAppInfoReq() { + super.onAppInfoReq(); // TODO onAppInfoReq + } + + @Override + public void onAppStart(final UUID uuid, final boolean start) { + super.onAppStart(uuid, start); // TODO onAppStart for watchfaces + } + + @Override + public void onFetchRecordedData(final int dataTypes) { + sendCommand("fetch recorded data step 1", CmfCommand.ACTIVITY_FETCH_1, A5); + } + + @Override + public void onReset(final int flags) { + if ((flags & GBDeviceProtocol.RESET_FLAGS_FACTORY_RESET) != 0) { + sendCommand("factory reset", CmfCommand.FACTORY_RESET, A5); + } else { + LOG.warn("Unknown reset flags: {}", String.format("0x%x", flags)); + } + } + + @Override + public void onSetHeartRateMeasurementInterval(final int seconds) { + preferences.onSetHeartRateMeasurementInterval(seconds); + } + + @Override + public void onSendConfiguration(final String config) { + preferences.onSendConfiguration(config); + } + + @Override + public void onFindDevice(final boolean start) { + if (!start) { + return; + } + + sendCommand("find device", CmfCommand.FIND_WATCH); + } + + @Override + public void onSendWeather(final WeatherSpec weatherSpec) { + // TODO onSendWeather + } + + @Override + public void onTestNewFunction() { + + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java index f553034eb..43a201fea 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchSpo2NormalOperation.java @@ -90,7 +90,7 @@ public class FetchSpo2NormalOperation extends AbstractRepeatingFetchOperation { final HuamiSpo2Sample sample = new HuamiSpo2Sample(); sample.setTimestamp(timestamp.getTimeInMillis()); - sample.setType(autoMeasurement ? Spo2Sample.Type.AUTOMATIC : Spo2Sample.Type.MANUAL); + sample.setTypeNum((autoMeasurement ? Spo2Sample.Type.AUTOMATIC : Spo2Sample.Type.MANUAL).getNum()); sample.setSpo2(spo2); samples.add(sample); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java index ceabf1390..b6b43c6f8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressAutoOperation.java @@ -77,7 +77,7 @@ public class FetchStressAutoOperation extends AbstractRepeatingFetchOperation { final HuamiStressSample sample = new HuamiStressSample(); sample.setTimestamp(timestamp.getTimeInMillis()); - sample.setType(StressSample.Type.AUTOMATIC); + sample.setTypeNum(StressSample.Type.AUTOMATIC.getNum()); sample.setStress(stress); samples.add(sample); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java index 2bb0027f3..f13fe7027 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/FetchStressManualOperation.java @@ -85,7 +85,7 @@ public class FetchStressManualOperation extends AbstractRepeatingFetchOperation final HuamiStressSample sample = new HuamiStressSample(); sample.setTimestamp(timestamp.getTimeInMillis()); - sample.setType(StressSample.Type.MANUAL); + sample.setTypeNum(StressSample.Type.MANUAL.getNum()); sample.setStress(stress); samples.add(sample); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java index c7884f6ce..7d6c1a7f8 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/sony/wena3/protocol/logic/parsers/StressPacketParser.java @@ -60,7 +60,7 @@ public class StressPacketParser extends OneBytePerSamplePacketParser { gbSample.setUserId(userId); gbSample.setTimestamp(currentSampleDate.getTime()); gbSample.setStress(rawSample); - gbSample.setType(StressSample.Type.AUTOMATIC); + gbSample.setTypeNum(StressSample.Type.AUTOMATIC.getNum()); samples.add(gbSample); } else { LOG.debug("Discard stress value as out of range: " + rawSample); diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index e1042e8dd..f416de506 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1201,6 +1201,237 @@ indoor_ice_skating + + @string/activity_type_indoor_running + @string/activity_type_outdoor_running + @string/activity_type_outdoor_walking + @string/activity_type_indoor_walking + @string/activity_type_outdoor_cycling + @string/activity_type_indoor_cycling + @string/activity_type_mountain_hike + @string/activity_type_hiking + @string/activity_type_cross_trainer + @string/activity_type_free_training + @string/activity_type_strength_training + @string/activity_type_yoga + @string/activity_type_boxing + @string/activity_type_rower + @string/activity_type_dynamic_cycle + @string/activity_type_stair_stepper + @string/activity_type_treadmill + @string/activity_type_hiit + @string/activity_type_fitness_exercises + @string/activity_type_jump_roping + @string/activity_type_pilates + @string/activity_type_crossfit + @string/activity_type_functional_training + @string/activity_type_physical_training + @string/activity_type_taekwondo + @string/activity_type_cross_country_running + @string/activity_type_karate + @string/activity_type_fencing + @string/activity_type_core_training + @string/activity_type_kendo + @string/activity_type_horizontal_bar + @string/activity_type_parallel_bar + @string/activity_type_cooldown + @string/activity_type_cross_training + @string/activity_type_sit_ups + @string/activity_type_fitness_gaming + @string/activity_type_aerobic_exercise + @string/activity_type_rolling + @string/activity_type_flexibility + @string/activity_type_gymnastics + @string/activity_type_track_and_field + @string/activity_type_push_ups + @string/activity_type_battle_rope + @string/activity_type_smith_machine + @string/activity_type_pull_ups + @string/activity_type_plank + @string/activity_type_javelin + @string/activity_type_long_jump + @string/activity_type_high_jump + @string/activity_type_trampoline + @string/activity_type_dumbbell + @string/activity_type_belly_dance + @string/activity_type_jazz_dance + @string/activity_type_latin_dance + @string/activity_type_ballet + @string/activity_type_street_dance + @string/activity_type_zumba + @string/activity_type_other_dance + @string/activity_type_roller_skating + @string/activity_type_martial_arts + @string/activity_type_tai_chi + @string/activity_type_hula_hooping + @string/activity_type_disc_sports + @string/activity_type_darts + @string/activity_type_archery + @string/activity_type_horse_riding + @string/activity_type_kite_flying + @string/activity_type_swing + @string/activity_type_stairs + @string/activity_type_fishing + @string/activity_type_hand_cycling + @string/activity_type_mind_and_body + @string/activity_type_wrestling + @string/activity_type_kabaddi + @string/activity_type_karting + @string/activity_type_badminton + @string/activity_type_table_tennis + @string/activity_type_tennis + @string/activity_type_billiards + @string/activity_type_bowling + @string/activity_type_volleyball + @string/activity_type_shuttlecock + @string/activity_type_handball + @string/activity_type_baseball + @string/activity_type_softball + @string/activity_type_cricket + @string/activity_type_rugby + @string/activity_type_hockey + @string/activity_type_squash + @string/activity_type_dodgeball + @string/activity_type_soccer + @string/activity_type_basketball + @string/activity_type_australian_football + @string/activity_type_golf + @string/activity_type_pickleball + @string/activity_type_lacross + @string/activity_type_shot + @string/activity_type_sailing + @string/activity_type_surfing + @string/activity_type_jet_skiing + @string/activity_type_skating + @string/activity_type_ice_hockey + @string/activity_type_curling + @string/activity_type_snowboarding + @string/activity_type_cross_country_skiing + @string/activity_type_snow_sports + @string/activity_type_skiing + @string/activity_type_skateboarding + @string/activity_type_rock_climbing + @string/activity_type_hunting + + + + indoor_running + outdoor_running + outdoor_walking + indoor_walking + outdoor_cycling + indoor_cycling + mountain_hike + hiking + cross_trainer + free_training + strength_training + yoga + boxing + rower + dynamic_cycle + stair_stepper + treadmill + hiit + fitness_exercises + jump_roping + pilates + crossfit + functional_training + physical_training + taekwondo + cross_country_running + karate + fencing + core_training + kendo + horizontal_bar + parallel_bar + cooldown + cross_training + sit_ups + fitness_gaming + aerobic_exercise + rolling + flexibility + gymnastics + track_and_field + push_ups + battle_rope + smith_machine + pull_ups + plank + javelin + long_jump + high_jump + trampoline + dumbbell + belly_dance + jazz_dance + latin_dance + ballet + street_dance + zumba + other_dance + roller_skating + martial_arts + tai_chi + hula_hooping + disc_sports + darts + archery + horse_riding + kite_flying + swing + stairs + fishing + hand_cycling + mind_and_body + wrestling + kabaddi + karting + badminton + table_tennis + tennis + billiards + bowling + volleyball + shuttlecock + handball + baseball + softball + cricket + rugby + hockey + squash + dodgeball + soccer + basketball + australian_football + golf + pickleball + lacross + shot + sailing + surfing + jet_skiing + skating + ice_hockey + curling + snowboarding + cross_country_skiing + snow_sports + skiing + skateboarding + rock_climbing + hunting + + + + indoor_run + outdoor_run + + @string/activity_type_outdoor_running @string/activity_type_hiking @@ -2389,6 +2620,26 @@ 150 + + @string/off + @string/heartrate_bpm_155 + @string/heartrate_bpm_165 + @string/heartrate_bpm_175 + @string/heartrate_bpm_185 + @string/heartrate_bpm_195 + @string/heartrate_bpm_205 + + + + 0 + 155 + 165 + 175 + 185 + 195 + 205 + + @string/off @string/heartrate_bpm_40 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 078a7ed5a..c25a1f285 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -712,6 +712,12 @@ 140 bpm 145 bpm 150 bpm + 155 bpm + 165 bpm + 175 bpm + 185 bpm + 195 bpm + 205 bpm 80% 85% 90% @@ -785,6 +791,7 @@ Vibrate the band when the heart rate is over a threshold, without any obvious physical activity in the last 10 minutes. This feature is experimental, and was not extensively tested. Heart rate alert threshold High heart rate alert threshold + High activity heart rate alert threshold Low heart rate alert threshold Stress monitoring Monitor stress level while resting @@ -970,6 +977,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 hydration warnings for a time interval Heart Rate Monitoring Configure heart rate monitoring Phone Silent Mode @@ -1237,7 +1245,83 @@ Device not worn Running Outdoor Running + Indoor Running + Mountain Hike + Cross trainer + Free training + Rower + Dynamic cycle + Stair stepper + Fitness exercises + Crossfit + Functional training + Physical training + Taekwondo + Cross country running + Karate + Fencing + Kendo + Horizontal bar + Parallel bar + Cooldown + Cross training + Sit ups + Fitness gaming + Aerobic exercise + Rolling + Flexibility + Track and field + Push ups + Battle rope + Smith machine + Pull ups + Plank + Javelin + Long jump + High jump + Trampoline + Dumbbell + Belly dance + Jazz dance + Latin dance + Ballet + Other dance + Roller skating + Martial arts + Tai chi + Hula hooping + Disc sports + Darts + Archery + Horse riding + Kite Flying + Swing + Stairs + Fishing + Hand cycling + Mind and body + Kabaddi + Karting + Billiards + Shuttlecock + Softball + Dodgeball + Australian football + Pickleball + Lacross + Shot + Sailing + Jet skiing + Skating + Ice hockey + Curling + Cross country skiing + Snow sports + Skateboarding + Rock climbing + Hunting Walking + Outdoor Walking Indoor Walking Surfing Windsurfing @@ -1991,6 +2075,7 @@ Nothing Ear (1) Nothing Ear (2) Nothing Ear (Stick) + CMF Watch Pro Galaxy Buds Galaxy Buds Live Galaxy Buds Pro diff --git a/app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml b/app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml index b2a84fc37..2cbe5135c 100644 --- a/app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml +++ b/app/src/main/res/xml/devicesettings_heartrate_sleep_alert_activity_stress_spo2.xml @@ -70,6 +70,15 @@ android:key="heartrate_alert_low_threshold" android:summary="%s" android:title="@string/prefs_heartrate_alert_low_threshold" /> + + diff --git a/app/src/main/res/xml/devicesettings_hydration_reminder_dnd.xml b/app/src/main/res/xml/devicesettings_hydration_reminder_dnd.xml new file mode 100644 index 000000000..a0295022e --- /dev/null +++ b/app/src/main/res/xml/devicesettings_hydration_reminder_dnd.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/devicesettings_workout_activity_types.xml b/app/src/main/res/xml/devicesettings_workout_activity_types.xml new file mode 100644 index 000000000..5213d34e2 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_workout_activity_types.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommandTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommandTest.java new file mode 100644 index 000000000..678be855e --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCommandTest.java @@ -0,0 +1,39 @@ +/* Copyright (C) 2024 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.cmfwatchpro; + +import static org.junit.Assert.*; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +public class CmfCommandTest { + @Test + public void commandEnumCheckNoOverlap() { + // Ensure that no 2 commands overlap in codes + final Map knownCodes = new HashMap<>(); + for (final CmfCommand cmd : CmfCommand.values()) { + final Boolean existingCode = knownCodes.put( + String.format("cmd1=0x%04x cmd2=0x%04x", cmd.getCmd1(), cmd.getCmd2()), + true + ); + assertNull("Commands with overlapping codes", existingCode); + } + } +}