diff --git a/README.md b/README.md index 11782aac9..edfa2e8c6 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ vendor's servers. - Mi - [Band, Band 1A, Band 1S](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Mi-Band), [Band 2](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Mi-Band-2), [Band 3](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Mi-Band-3) - [Band 4](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Mi-Band-4), [Band 5](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Mi-Band-5), [Band 6](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Mi-Band-6) [**\[!\]**](#special-pairing-procedures) + - [Xiaomi Smart Band 7](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Mi-Band-7) [**\[!\]**](#special-pairing-procedures) - Scale 2 (Currently only displays a toast after stepping on the scale) - [MyKronoz ZeTime](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/MyKronoz-ZeTime) - NO.1 F1 diff --git a/app/build.gradle b/app/build.gradle index ca87b58cb..df9cedb03 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -264,6 +264,9 @@ dependencies { // JSR-310 timezones backport for Android, since we're still on java 7 implementation 'com.jakewharton.threetenabp:threetenabp:1.4.0' testImplementation 'org.threeten:threetenbp:1.6.0' + + // Android SDK bundles org.json, but we need an actual implementation to replace the stubs in tests + testImplementation 'org.json:json:20180813' } preBuild.dependsOn(":GBDaoGenerator:genSources") diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java index cb43bd095..cb99a2d68 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/GBApplication.java @@ -74,6 +74,7 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceService; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; +import nodomain.freeyourgadget.gadgetbridge.model.Weather; import nodomain.freeyourgadget.gadgetbridge.service.NotificationCollectorMonitorService; import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; @@ -217,6 +218,8 @@ public class GBApplication extends Application { setupExceptionHandler(); + Weather.getInstance().setCacheFile(getCacheDir(), prefs.getBoolean("cache_weather", true)); + deviceManager = new DeviceManager(this); String language = prefs.getString("language", "default"); setLanguage(language); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java index d34fa729f..7abeffff6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/ConfigureReminders.java @@ -84,7 +84,7 @@ public class ConfigureReminders extends AbstractGBActivity { final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); - int reservedSlots = prefs.getInt(DeviceSettingsPreferenceConst.PREF_RESERVER_REMINDERS_CALENDAR, 9); + int reservedSlots = prefs.getInt(DeviceSettingsPreferenceConst.PREF_RESERVER_REMINDERS_CALENDAR, coordinator.supportsCalendarEvents() ? 0 : 9); int deviceSlots = coordinator.getReminderSlotCount() - reservedSlots; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java index bf9dff703..7555eca97 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/DebugActivity.java @@ -76,6 +76,7 @@ import java.util.Objects; import java.util.Random; import java.util.TreeMap; +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.Widget; @@ -100,6 +101,8 @@ import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; +import nodomain.freeyourgadget.gadgetbridge.model.Weather; +import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -179,8 +182,22 @@ public class DebugActivity extends AbstractGBActivity { notificationSpec.body = testString; notificationSpec.sender = testString; notificationSpec.subject = testString; + notificationSpec.sourceAppId = BuildConfig.APPLICATION_ID; + notificationSpec.sourceName = getApplicationContext().getApplicationInfo() + .loadLabel(getApplicationContext().getPackageManager()) + .toString(); notificationSpec.type = NotificationType.sortedValues()[sendTypeSpinner.getSelectedItemPosition()]; notificationSpec.pebbleColor = notificationSpec.type.color; + notificationSpec.attachedActions = new ArrayList<>(); + + if (notificationSpec.type == NotificationType.GENERIC_SMS) { + // REPLY action + NotificationSpec.Action replyAction = new NotificationSpec.Action(); + replyAction.title = "Reply"; + replyAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR; + notificationSpec.attachedActions.add(replyAction); + } + GBApplication.deviceService().onNotification(notificationSpec); } }); @@ -303,6 +320,42 @@ public class DebugActivity extends AbstractGBActivity { } }); + Button setWeatherButton = findViewById(R.id.setWeatherButton); + setWeatherButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + if (Weather.getInstance().getWeatherSpec() == null) { + final WeatherSpec weatherSpec = new WeatherSpec(); + weatherSpec.forecasts = new ArrayList<>(); + + weatherSpec.location = "Green Hill"; + weatherSpec.currentConditionCode = 601; // snow + weatherSpec.currentCondition = Weather.getConditionString(weatherSpec.currentConditionCode); + + weatherSpec.currentTemp = 15 + 273; + weatherSpec.currentHumidity = 30; + + weatherSpec.windSpeed = 10; + weatherSpec.windDirection = 12; + weatherSpec.timestamp = (int) (System.currentTimeMillis() / 1000); + weatherSpec.todayMinTemp = 10 + 273; + weatherSpec.todayMaxTemp = 25 + 273; + + for (int i = 0; i < 5; i++) { + final WeatherSpec.Forecast gbForecast = new WeatherSpec.Forecast(); + gbForecast.minTemp = 10 + i + 273; + gbForecast.maxTemp = 25 + i + 273; + + gbForecast.conditionCode = 800; // clear + weatherSpec.forecasts.add(gbForecast); + } + + Weather.getInstance().setWeatherSpec(weatherSpec); + } + + GBApplication.deviceService().onSendWeather(Weather.getInstance().getWeatherSpec()); + } + }); Button setMusicInfoButton = findViewById(R.id.setMusicInfoButton); setMusicInfoButton.setOnClickListener(new View.OnClickListener() { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FindPhoneActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FindPhoneActivity.java index 1919e1ff7..78406abd4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FindPhoneActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/FindPhoneActivity.java @@ -184,6 +184,8 @@ public class FindPhoneActivity extends AbstractGBActivity { stopVibration(); stopSound(); + GBApplication.deviceService().onPhoneFound(); + LocalBroadcastManager.getInstance(this).unregisterReceiver(mReceiver); unregisterReceiver(mReceiver); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java index 7fb1a72c5..f950170c3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java @@ -67,6 +67,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandPreferencesActivity; import nodomain.freeyourgadget.gadgetbridge.devices.qhybrid.ConfigActivity; import nodomain.freeyourgadget.gadgetbridge.devices.zetime.ZeTimePreferenceActivity; +import nodomain.freeyourgadget.gadgetbridge.model.Weather; import nodomain.freeyourgadget.gadgetbridge.util.AndroidUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -184,6 +185,18 @@ public class SettingsActivity extends AbstractSettingsActivity { }); + pref = findPreference("cache_weather"); + pref.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newVal) { + boolean doEnable = Boolean.TRUE.equals(newVal); + + Weather.getInstance().setCacheFile(getCacheDir(), doEnable); + + return true; + } + }); + // If we didn't manage to initialize file logging, disable the preference if (!GBApplication.getLogging().isFileLoggerInitialized()) { pref.setEnabled(false); 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 e573b1731..6ccf829c4 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 @@ -18,12 +18,18 @@ package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings; public class DeviceSettingsPreferenceConst { public static final String PREF_LANGUAGE = "language"; + public static final String PREF_LANGUAGE_AUTO = "auto"; public static final String PREF_DATEFORMAT = "dateformat"; public static final String PREF_TIMEFORMAT = "timeformat"; + public static final String PREF_TIMEFORMAT_24H = "24h"; + public static final String PREF_TIMEFORMAT_12H = "am/pm"; + public static final String PREF_TIMEFORMAT_AUTO = "auto"; public static final String PREF_WEARLOCATION = "wearlocation"; public static final String PREF_VIBRATION_ENABLE = "vibration_enable"; public static final String PREF_NOTIFICATION_ENABLE = "notification_enable"; + public static final String PREF_SCREEN_BRIGHTNESS = "screen_brightness"; public static final String PREF_SCREEN_ORIENTATION = "screen_orientation"; + public static final String PREF_SCREEN_TIMEOUT = "screen_timeout"; public static final String PREF_RESERVER_ALARMS_CALENDAR = "reserve_alarms_calendar"; public static final String PREF_RESERVER_REMINDERS_CALENDAR = "reserve_reminders_calendar"; public static final String PREF_ALLOW_HIGH_MTU = "allow_high_mtu"; @@ -67,6 +73,14 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_DISPLAY_ON_LIFT_END = "display_on_lift_end"; public static final String PREF_DISPLAY_ON_LIFT_SENSITIVITY = "display_on_lift_sensitivity"; + public static final String PREF_ALWAYS_ON_DISPLAY_MODE = "always_on_display_mode"; + public static final String PREF_ALWAYS_ON_DISPLAY_START = "always_on_display_start"; + public static final String PREF_ALWAYS_ON_DISPLAY_END = "always_on_display_end"; + public static final String PREF_ALWAYS_ON_DISPLAY_OFF = "off"; + public static final String PREF_ALWAYS_ON_DISPLAY_AUTOMATIC = "automatic"; + public static final String PREF_ALWAYS_ON_DISPLAY_ALWAYS = "always"; + public static final String PREF_ALWAYS_ON_DISPLAY_SCHEDULED = "scheduled"; + public static final String PREF_SLEEP_TIME = "prefs_enable_sleep_time"; public static final String PREF_SLEEP_TIME_START = "prefs_sleep_time_start"; public static final String PREF_SLEEP_TIME_END = "prefs_sleep_time_end"; @@ -95,8 +109,13 @@ 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_THRESHOLD = "heartrate_alert_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"; + public static final String PREF_HEARTRATE_STRESS_RELAXATION_REMINDER = "heartrate_stress_relaxation_reminder"; + public static final String PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING = "heartrate_sleep_breathing_quality_monitoring"; + public static final String PREF_SPO2_ALL_DAY_MONITORING = "spo2_all_day_monitoring_enabled"; + public static final String PREF_SPO2_LOW_ALERT_THRESHOLD = "spo2_low_alert_threshold"; public static final String PREF_AUTOHEARTRATE_SWITCH = "pref_autoheartrate_switch"; public static final String PREF_AUTOHEARTRATE_SLEEP = "pref_autoheartrate_sleep"; @@ -116,6 +135,7 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_DO_NOT_DISTURB_LIFT_WRIST = "do_not_disturb_lift_wrist"; public static final String PREF_DO_NOT_DISTURB_OFF = "off"; public static final String PREF_DO_NOT_DISTURB_AUTOMATIC = "automatic"; + public static final String PREF_DO_NOT_DISTURB_ALWAYS = "always"; public static final String PREF_DO_NOT_DISTURB_SCHEDULED = "scheduled"; public static final String PREF_WORKOUT_START_ON_PHONE = "workout_start_on_phone"; @@ -126,6 +146,7 @@ public class DeviceSettingsPreferenceConst { public static final String PREF_AUTOLIGHT = "autolight"; public static final String PREF_AUTOREMOVE_MESSAGE = "autoremove_message"; public static final String PREF_AUTOREMOVE_NOTIFICATIONS = "autoremove_notifications"; + public static final String PREF_SCREEN_ON_ON_NOTIFICATIONS = "screen_on_on_notifications"; public static final String PREF_OPERATING_SOUNDS = "operating_sounds"; public static final String PREF_KEY_VIBRATION = "key_vibration"; public static final String PREF_FAKE_RING_DURATION = "fake_ring_duration"; 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 3737605e6..f11e7ac65 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 @@ -51,7 +51,6 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.CalBlacklistActivity; import nodomain.freeyourgadget.gadgetbridge.activities.ConfigureWorldClocks; -import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager; @@ -361,7 +360,22 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp }); } + addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD); + addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_LOW_THRESHOLD); + final ListPreference heartrateMeasurementInterval = findPreference(PREF_HEARTRATE_MEASUREMENT_INTERVAL); + final ListPreference heartrateAlertHigh = findPreference(PREF_HEARTRATE_ALERT_HIGH_THRESHOLD); + final ListPreference heartrateAlertLow = 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; + if (hrAlertsNeedSmartOrOne) { + final boolean hrMonitoringIsSmartOrOne = heartrateMeasurementInterval.getValue().equals("60") || + heartrateMeasurementInterval.getValue().equals("-1"); + + heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne); + heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne); + } + if (heartrateMeasurementInterval != null) { final SwitchPreference activityMonitoring = findPreference(PREF_HEARTRATE_ACTIVITY_MONITORING); final SwitchPreference heartrateAlertEnabled = findPreference(PREF_HEARTRATE_ALERT_ENABLED); @@ -379,7 +393,15 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp if (heartrateAlertEnabled != null) { heartrateAlertEnabled.setEnabled(isMeasurementIntervalEnabled); } - if (stressMonitoring != null) { + if (hrAlertsNeedSmartOrOne) { + // Same as above, check if smart or 1 minute + final boolean hrMonitoringIsSmartOrOne = newVal.equals("60") || newVal.equals("-1"); + + heartrateAlertHigh.setEnabled(hrMonitoringIsSmartOrOne); + heartrateAlertLow.setEnabled(hrMonitoringIsSmartOrOne); + } + if (stressMonitoring != null && !hrAlertsNeedSmartOrOne) { + // Newer devices (that have hrAlertsNeedSmartOrOne) also don't need HR monitoring for stress monitoring stressMonitoring.setEnabled(isMeasurementIntervalEnabled); } @@ -395,7 +417,8 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp if (heartrateAlertEnabled != null) { heartrateAlertEnabled.setEnabled(isMeasurementIntervalEnabled); } - if (stressMonitoring != null) { + if (stressMonitoring != null && !hrAlertsNeedSmartOrOne) { + // Newer devices (that have hrAlertsNeedSmartOrOne) also don't need HR monitoring for stress monitoring stressMonitoring.setEnabled(isMeasurementIntervalEnabled); } } @@ -414,7 +437,9 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp addPreferenceHandlerFor(PREF_WEARLOCATION); addPreferenceHandlerFor(PREF_VIBRATION_ENABLE); addPreferenceHandlerFor(PREF_NOTIFICATION_ENABLE); + addPreferenceHandlerFor(PREF_SCREEN_BRIGHTNESS); addPreferenceHandlerFor(PREF_SCREEN_ORIENTATION); + addPreferenceHandlerFor(PREF_SCREEN_TIMEOUT); addPreferenceHandlerFor(PREF_TIMEFORMAT); addPreferenceHandlerFor(PREF_BUTTON_1_FUNCTION_SHORT); addPreferenceHandlerFor(PREF_BUTTON_2_FUNCTION_SHORT); @@ -452,21 +477,22 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp addPreferenceHandlerFor(PREF_AUTOHEARTRATE_START); addPreferenceHandlerFor(PREF_AUTOHEARTRATE_END); addPreferenceHandlerFor(PREF_HEARTRATE_ACTIVITY_MONITORING); - addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_THRESHOLD); addPreferenceHandlerFor(PREF_HEARTRATE_ALERT_ENABLED); addPreferenceHandlerFor(PREF_HEARTRATE_STRESS_MONITORING); + addPreferenceHandlerFor(PREF_HEARTRATE_STRESS_RELAXATION_REMINDER); + addPreferenceHandlerFor(PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING); + addPreferenceHandlerFor(PREF_SPO2_ALL_DAY_MONITORING); + addPreferenceHandlerFor(PREF_SPO2_LOW_ALERT_THRESHOLD); addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO); addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO_START); addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_NOAUTO_END); - addPreferenceHandlerFor(PREF_DO_NOT_DISTURB); - addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_START); - addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_END); addPreferenceHandlerFor(PREF_DO_NOT_DISTURB_LIFT_WRIST); addPreferenceHandlerFor(PREF_FIND_PHONE); addPreferenceHandlerFor(PREF_FIND_PHONE_DURATION); addPreferenceHandlerFor(PREF_AUTOLIGHT); addPreferenceHandlerFor(PREF_AUTOREMOVE_MESSAGE); addPreferenceHandlerFor(PREF_AUTOREMOVE_NOTIFICATIONS); + addPreferenceHandlerFor(PREF_SCREEN_ON_ON_NOTIFICATIONS); addPreferenceHandlerFor(PREF_KEY_VIBRATION); addPreferenceHandlerFor(PREF_OPERATING_SOUNDS); addPreferenceHandlerFor(PREF_FAKE_RING_DURATION); @@ -627,6 +653,49 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp }); } + final String alwaysOnDisplayState = prefs.getString(PREF_ALWAYS_ON_DISPLAY_MODE, PREF_ALWAYS_ON_DISPLAY_OFF); + boolean alwaysOnDisplayScheduled = alwaysOnDisplayState.equals(PREF_ALWAYS_ON_DISPLAY_SCHEDULED); + boolean alwaysOnDisplayOff = alwaysOnDisplayState.equals(PREF_ALWAYS_ON_DISPLAY_OFF); + + final Preference alwaysOnDisplayStart = findPreference(PREF_ALWAYS_ON_DISPLAY_START); + if (alwaysOnDisplayStart != null) { + alwaysOnDisplayStart.setEnabled(alwaysOnDisplayScheduled); + alwaysOnDisplayStart.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newVal) { + notifyPreferenceChanged(PREF_ALWAYS_ON_DISPLAY_START); + return true; + } + }); + } + + final Preference alwaysOnDisplayEnd = findPreference(PREF_ALWAYS_ON_DISPLAY_END); + if (alwaysOnDisplayEnd != null) { + alwaysOnDisplayEnd.setEnabled(alwaysOnDisplayScheduled); + alwaysOnDisplayEnd.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newVal) { + notifyPreferenceChanged(PREF_ALWAYS_ON_DISPLAY_END); + return true; + } + }); + } + + final Preference alwaysOnDisplayMode = findPreference(PREF_ALWAYS_ON_DISPLAY_MODE); + if (alwaysOnDisplayMode != null) { + alwaysOnDisplayMode.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(Preference preference, Object newVal) { + final boolean scheduled = PREF_ALWAYS_ON_DISPLAY_SCHEDULED.equals(newVal.toString()); + final boolean off = PREF_ALWAYS_ON_DISPLAY_OFF.equals(newVal.toString()); + alwaysOnDisplayStart.setEnabled(scheduled); + alwaysOnDisplayEnd.setEnabled(scheduled); + notifyPreferenceChanged(PREF_ALWAYS_ON_DISPLAY_MODE); + return true; + } + }); + } + final Preference displayOnLiftStart = findPreference(PREF_DISPLAY_ON_LIFT_START); if (displayOnLiftStart != null) { displayOnLiftStart.setEnabled(displayOnLiftScheduled); @@ -729,6 +798,26 @@ public class DeviceSpecificSettingsFragment extends PreferenceFragmentCompat imp }); } + final Preference cannedMessagesGeneric = findPreference("canned_messages_generic_send"); + if (cannedMessagesGeneric != null) { + cannedMessagesGeneric.setOnPreferenceClickListener(new androidx.preference.Preference.OnPreferenceClickListener() { + public boolean onPreferenceClick(androidx.preference.Preference preference) { + final ArrayList messages = new ArrayList<>(); + for (int i = 1; i <= 16; i++) { + String message = prefs.getString("canned_reply_" + i, null); + if (message != null && !message.equals("")) { + messages.add(message); + } + } + final CannedMessagesSpec cannedMessagesSpec = new CannedMessagesSpec(); + cannedMessagesSpec.type = CannedMessagesSpec.TYPE_GENERIC; + cannedMessagesSpec.cannedMessages = messages.toArray(new String[0]); + GBApplication.deviceService().onSetCannedMessages(cannedMessagesSpec); + return true; + } + }); + } + setInputTypeFor(HuamiConst.PREF_BUTTON_ACTION_BROADCAST_DELAY, InputType.TYPE_CLASS_NUMBER); setInputTypeFor(HuamiConst.PREF_BUTTON_ACTION_PRESS_MAX_INTERVAL, InputType.TYPE_CLASS_NUMBER); setInputTypeFor(HuamiConst.PREF_BUTTON_ACTION_PRESS_COUNT, InputType.TYPE_CLASS_NUMBER); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java index ef5981eae..649a24d09 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/database/DBHelper.java @@ -636,7 +636,7 @@ public class DBHelper { final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); - int reservedSlots = prefs.getInt(DeviceSettingsPreferenceConst.PREF_RESERVER_REMINDERS_CALENDAR, 9); + int reservedSlots = prefs.getInt(DeviceSettingsPreferenceConst.PREF_RESERVER_REMINDERS_CALENDAR, coordinator.supportsCalendarEvents() ? 0 : 9); final int reminderSlots = coordinator.getReminderSlotCount(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java index 9ed55019f..dc9127585 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventCallControl.java @@ -20,6 +20,13 @@ package nodomain.freeyourgadget.gadgetbridge.deviceevents; public class GBDeviceEventCallControl extends GBDeviceEvent { public Event event = Event.UNKNOWN; + public GBDeviceEventCallControl() { + } + + public GBDeviceEventCallControl(final Event event) { + this.event = event; + } + public enum Event { UNKNOWN, ACCEPT, diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java index a467d06ad..fac8f9d02 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventUpdatePreferences.java @@ -68,7 +68,9 @@ public class GBDeviceEventUpdatePreferences extends GBDeviceEvent { LOG.debug("Updating {} = {}", key, value); - if (value instanceof Integer) { + if (value == null) { + editor.remove(key); + } else if (value instanceof Integer) { editor.putInt(key, (Integer) value); } else if (value instanceof Boolean) { editor.putBoolean(key, (Boolean) value); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventVersionInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventVersionInfo.java index ae8b22c96..40ac47764 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventVersionInfo.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/deviceevents/GBDeviceEventVersionInfo.java @@ -20,8 +20,16 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; public class GBDeviceEventVersionInfo extends GBDeviceEvent { - public String fwVersion = GBApplication.getContext().getString(R.string.n_a); - public String hwVersion = GBApplication.getContext().getString(R.string.n_a); + public String fwVersion = "N/A"; + public String hwVersion = "N/A"; + + public GBDeviceEventVersionInfo() { + if (GBApplication.getContext() != null) { + // Only get from context if there is one (eg. not in unit tests) + this.fwVersion = GBApplication.getContext().getString(R.string.n_a); + this.hwVersion = GBApplication.getContext().getString(R.string.n_a); + } + } @Override public String toString() { 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 928beaa05..18a5998a3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/AbstractDeviceCoordinator.java @@ -264,6 +264,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator { return new int[0]; } + @Override + public boolean supportsHeartRateMeasurement(final GBDevice device) { + return false; + } + + @Override + public boolean supportsWeather() { + return false; + } + @Override public boolean supportsUnicodeEmojis() { return false; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java index f8674afd8..f60edb325 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java @@ -92,6 +92,8 @@ public interface EventHandler { void onFindDevice(boolean start); + void onPhoneFound(); + void onSetConstantVibration(int integer); void onScreenshotReq(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/AlwaysOnDisplay.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/AlwaysOnDisplay.java new file mode 100644 index 000000000..75a68c75c --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/AlwaysOnDisplay.java @@ -0,0 +1,24 @@ +/* Copyright (C) 2022 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.huami; + +public enum AlwaysOnDisplay { + OFF, + AUTO, + SCHEDULED, + ALWAYS +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java new file mode 100644 index 000000000..cc984cae4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java @@ -0,0 +1,121 @@ +/* Copyright (C) 2022 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.huami; + +import android.content.Context; +import android.net.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; + +public abstract class Huami2021Coordinator extends HuamiCoordinator { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021Coordinator.class); + + @Override + public boolean supportsHeartRateMeasurement(final GBDevice device) { + // TODO: One-shot HR measures are not working, so let's disable this for now + return false; + } + + @Override + public boolean supportsRealtimeData() { + return true; + } + + @Override + public boolean supportsWeather() { + // TODO: It's supported by the devices, but not yet implemented + return false; + } + + @Override + public boolean supportsUnicodeEmojis() { + return true; + } + + @Override + public boolean supportsActivityTracking() { + // TODO: It's supported by the devices, but not yet implemented + return false; + } + + @Override + public boolean supportsActivityDataFetching() { + // TODO: It's supported by the devices, but not yet implemented + return false; + } + + @Override + public boolean supportsActivityTracks() { + // TODO: It's supported by the devices, but not yet implemented + return false; + } + + @Override + public boolean supportsMusicInfo() { + return true; + } + + @Override + public int getWorldClocksSlotCount() { + // TODO: It's supported, but not implemented - even in the official app + return 0; + } + + @Override + public boolean supportsCalendarEvents() { + return true; + } + + @Override + public SampleProvider getSampleProvider(final GBDevice device, final DaoSession session) { + // TODO: It's supported by the devices, but not yet implemented + return null; + } + + @Override + public boolean supportsAlarmSnoozing() { + // All alarms snooze by default, there doesn't seem to be a flag that disables it + return false; + } + + @Override + public int getReminderSlotCount() { + return 50; + } + + @Override + public int[] getSupportedDeviceSpecificAuthenticationSettings() { + return new int[]{ + R.xml.devicesettings_pairingkey + }; + } + + @Override + public int getBondingStyle() { + return BONDING_STYLE_REQUIRE_KEY; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java new file mode 100644 index 000000000..258fa42f2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java @@ -0,0 +1,261 @@ +/* Copyright (C) 2022 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.huami; + +public class Huami2021Service { + /** + * Endpoints for 2021 chunked protocol + */ + public static final short CHUNKED2021_ENDPOINT_HTTP = 0x0001; + public static final short CHUNKED2021_ENDPOINT_CALENDAR = 0x0007; + public static final short CHUNKED2021_ENDPOINT_CONFIG = 0x000a; + public static final short CHUNKED2021_ENDPOINT_ICONS = 0x000d; + public static final short CHUNKED2021_ENDPOINT_WEATHER = 0x000e; + public static final short CHUNKED2021_ENDPOINT_ALARMS = 0x000f; + public static final short CHUNKED2021_ENDPOINT_CANNED_MESSAGES = 0x0013; + public static final short CHUNKED2021_ENDPOINT_USER_INFO = 0x0017; + public static final short CHUNKED2021_ENDPOINT_STEPS = 0x0016; + public static final short CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS = 0x0018; + public static final short CHUNKED2021_ENDPOINT_WORKOUT = 0x0019; + public static final short CHUNKED2021_ENDPOINT_FIND_DEVICE = 0x001a; + public static final short CHUNKED2021_ENDPOINT_MUSIC = 0x001b; + public static final short CHUNKED2021_ENDPOINT_HEARTRATE = 0x001d; + public static final short CHUNKED2021_ENDPOINT_NOTIFICATIONS = 0x001e; + public static final short CHUNKED2021_ENDPOINT_DISPLAY_ITEMS = 0x0026; + public static final short CHUNKED2021_ENDPOINT_BATTERY = 0x0029; + public static final short CHUNKED2021_ENDPOINT_REMINDERS = 0x0038; + public static final short CHUNKED2021_ENDPOINT_SILENT_MODE = 0x003b; + public static final short CHUNKED2021_ENDPOINT_AUTH = 0x0082; + public static final short CHUNKED2021_ENDPOINT_COMPAT = 0x0090; + + /** + * HTTP, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_HTTP}. + */ + public static final byte HTTP_CMD_REQUEST = 0x01; + public static final byte HTTP_CMD_RESPONSE = 0x02; + public static final byte HTTP_RESPONSE_SUCCESS = 0x01; + public static final byte HTTP_RESPONSE_NO_INTERNET = 0x02; + + /** + * Alarms, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_ALARMS}. + */ + public static final byte ALARMS_CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte ALARMS_CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte ALARMS_CMD_CREATE = 0x03; + public static final byte ALARMS_CMD_CREATE_ACK = 0x04; + public static final byte ALARMS_CMD_DELETE = 0x05; + public static final byte ALARMS_CMD_DELETE_ACK = 0x06; + public static final byte ALARMS_CMD_UPDATE = 0x07; + public static final byte ALARMS_CMD_UPDATE_ACK = 0x08; + public static final byte ALARMS_CMD_REQUEST = 0x09; + public static final byte ALARMS_CMD_RESPONSE = 0x0a; + public static final byte ALARMS_CMD_NOTIFY_CHANGE = 0x0f; + public static final int ALARM_IDX_FLAGS = 0; + public static final int ALARM_IDX_POSITION = 1; + public static final int ALARM_IDX_HOUR = 2; + public static final int ALARM_IDX_MINUTE = 3; + public static final int ALARM_IDX_REPETITION = 4; + public static final int ALARM_FLAG_SMART = 0x01; + public static final int ALARM_FLAG_UNKNOWN_2 = 0x02; + public static final int ALARM_FLAG_ENABLED = 0x04; + + /** + * Display Items, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_DISPLAY_ITEMS}. + */ + public static final byte DISPLAY_ITEMS_CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte DISPLAY_ITEMS_CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte DISPLAY_ITEMS_CMD_REQUEST = 0x03; + public static final byte DISPLAY_ITEMS_CMD_RESPONSE = 0x04; + public static final byte DISPLAY_ITEMS_CMD_CREATE = 0x05; + public static final byte DISPLAY_ITEMS_CMD_CREATE_ACK = 0x06; + public static final byte DISPLAY_ITEMS_MENU = 0x01; + public static final byte DISPLAY_ITEMS_SHORTCUTS = 0x02; + public static final byte DISPLAY_ITEMS_SECTION_MAIN = 0x01; + public static final byte DISPLAY_ITEMS_SECTION_MORE = 0x02; + public static final byte DISPLAY_ITEMS_SECTION_DISABLED = 0x03; + + /** + * Find Device, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_FIND_DEVICE}. + */ + public static final byte FIND_BAND_ONESHOT = 0x03; + public static final byte FIND_BAND_ACK = 0x04; + public static final byte FIND_PHONE_START = 0x11; + public static final byte FIND_PHONE_ACK = 0x12; + public static final byte FIND_PHONE_STOP_FROM_BAND = 0x13; + public static final byte FIND_PHONE_STOP_FROM_PHONE = 0x14; + + /** + * Steps, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_STEPS}. + */ + public static final byte STEPS_CMD_GET = 0x03; + public static final byte STEPS_CMD_REPLY = 0x04; + public static final byte STEPS_CMD_ENABLE_REALTIME = 0x05; + public static final byte STEPS_CMD_ENABLE_REALTIME_ACK = 0x06; + public static final byte STEPS_CMD_REALTIME_NOTIFICATION = 0x07; + + /** + * Vibration Patterns, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS}. + */ + public static final byte VIBRATION_PATTERN_SET = 0x03; + public static final byte VIBRATION_PATTERN_ACK = 0x04; + + /** + * Battery, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_BATTERY}. + */ + public static final byte BATTERY_REQUEST = 0x03; + public static final byte BATTERY_REPLY = 0x04; + + /** + * Silent Mode, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_SILENT_MODE}. + */ + public static final byte SILENT_MODE_CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte SILENT_MODE_CMD_CAPABILITIES_RESPONSE = 0x02; + // Notify silent mode, from phone + public static final byte SILENT_MODE_CMD_NOTIFY_BAND = 0x03; + public static final byte SILENT_MODE_CMD_NOTIFY_BAND_ACK = 0x04; + // Query silent mode on phone, from band + public static final byte SILENT_MODE_CMD_QUERY = 0x05; + public static final byte SILENT_MODE_CMD_REPLY = 0x06; + // Set silent mode on phone, from band + // After this, phone sends ACK + NOTIFY + public static final byte SILENT_MODE_CMD_SET = 0x07; + public static final byte SILENT_MODE_CMD_ACK = 0x08; + + /** + * Canned Messages, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_CANNED_MESSAGES}. + */ + public static final byte CANNED_MESSAGES_CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte CANNED_MESSAGES_CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte CANNED_MESSAGES_CMD_REQUEST = 0x03; + public static final byte CANNED_MESSAGES_CMD_RESPONSE = 0x04; + public static final byte CANNED_MESSAGES_CMD_SET = 0x05; + public static final byte CANNED_MESSAGES_CMD_SET_ACK = 0x06; + public static final byte CANNED_MESSAGES_CMD_DELETE = 0x07; + public static final byte CANNED_MESSAGES_CMD_DELETE_ACK = 0x08; + public static final byte CANNED_MESSAGES_CMD_REPLY_SMS = 0x0b; + public static final byte CANNED_MESSAGES_CMD_REPLY_SMS_ACK = 0x0c; + public static final byte CANNED_MESSAGES_CMD_REPLY_SMS_CHECK = 0x0d; + public static final byte CANNED_MESSAGES_CMD_REPLY_SMS_ALLOW = 0x0e; + + /** + * Notifications, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_HEARTRATE}. + */ + public static final byte HEART_RATE_CMD_REALTIME_SET = 0x04; + public static final byte HEART_RATE_CMD_REALTIME_ACK = 0x05; + public static final byte HEART_RATE_CMD_SLEEP = 0x06; + public static final byte HEART_RATE_FALL_ASLEEP = 0x01; + public static final byte HEART_RATE_WAKE_UP = 0x00; + public static final byte HEART_RATE_REALTIME_MODE_STOP = 0x00; + public static final byte HEART_RATE_REALTIME_MODE_START = 0x01; + public static final byte HEART_RATE_REALTIME_MODE_CONTINUE = 0x02; + + /** + * Notifications, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_NOTIFICATIONS}. + */ + public static final byte NOTIFICATION_CMD_SEND = 0x03; + public static final byte NOTIFICATION_CMD_REPLY = 0x04; + public static final byte NOTIFICATION_CMD_DISMISS = 0x05; + public static final byte NOTIFICATION_CMD_REPLY_ACK = 0x06; + public static final byte NOTIFICATION_CMD_ICON_REQUEST = 0x10; + public static final byte NOTIFICATION_CMD_ICON_REQUEST_ACK = 0x11; + public static final byte NOTIFICATION_TYPE_NORMAL = (byte) 0xfa; + public static final byte NOTIFICATION_TYPE_CALL = 0x03; + public static final byte NOTIFICATION_TYPE_SMS = (byte) 0x05; + public static final byte NOTIFICATION_SUBCMD_SHOW = 0x00; + public static final byte NOTIFICATION_SUBCMD_DISMISS_FROM_PHONE = 0x02; + public static final byte NOTIFICATION_DISMISS_NOTIFICATION = 0x03; + public static final byte NOTIFICATION_DISMISS_MUTE_CALL = 0x02; + public static final byte NOTIFICATION_DISMISS_REJECT_CALL = 0x01; + public static final byte NOTIFICATION_CALL_STATE_START = 0x00; + public static final byte NOTIFICATION_CALL_STATE_END = 0x02; + + /** + * Workout, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_WORKOUT}. + */ + public static final byte WORKOUT_CMD_GPS_LOCATION = 0x04; + public static final byte WORKOUT_CMD_APP_OPEN = 0x20; + public static final byte WORKOUT_CMD_STATUS = 0x11; + public static final int WORKOUT_GPS_FLAG_STATUS = 0x1; + public static final int WORKOUT_GPS_FLAG_POSITION = 0x40000; + public static final byte WORKOUT_STATUS_START = 0x01; + public static final byte WORKOUT_STATUS_END = 0x04; + + /** + * Music, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_MUSIC}. + */ + public static final byte MUSIC_CMD_MEDIA_INFO = 0x03; + public static final byte MUSIC_CMD_APP_STATE = 0x04; + public static final byte MUSIC_CMD_BUTTON_PRESS = 0x05; + public static final byte MUSIC_APP_OPEN = 0x01; + public static final byte MUSIC_APP_CLOSE = 0x02; + public static final byte MUSIC_BUTTON_PLAY = 0x00; + public static final byte MUSIC_BUTTON_PAUSE = 0x01; + public static final byte MUSIC_BUTTON_NEXT = 0x03; + public static final byte MUSIC_BUTTON_PREVIOUS = 0x04; + public static final byte MUSIC_BUTTON_VOLUME_UP = 0x05; + public static final byte MUSIC_BUTTON_VOLUME_DOWN = 0x06; + + /** + * Config, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_CONFIG}. + */ + public static final byte CONFIG_CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte CONFIG_CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte CONFIG_CMD_REQUEST = 0x03; + public static final byte CONFIG_CMD_RESPONSE = 0x04; + public static final byte CONFIG_CMD_SET = 0x05; + public static final byte CONFIG_CMD_ACK = 0x06; + public static final byte CONFIG_REQUEST_TYPE_SPECIFIC = 0x00; + public static final byte CONFIG_REQUEST_TYPE_ALL = 0x01; // Don't know how to parse them properly + + /** + * Config, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_ICONS}. + */ + public static final byte ICONS_CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte ICONS_CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte ICONS_CMD_SEND_REQUEST = 0x03; + public static final byte ICONS_CMD_SEND_RESPONSE = 0x04; + public static final byte ICONS_CMD_DATA_SEND = 0x10; + public static final byte ICONS_CMD_DATA_ACK = 0x11; + + /** + * Reminders, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_REMINDERS}. + */ + public static final byte REMINDERS_CMD_REQUEST = 0x03; + public static final byte REMINDERS_CMD_RESPONSE = 0x04; + public static final byte REMINDERS_CMD_CREATE = 0x05; + public static final byte REMINDERS_CMD_CREATE_ACK = 0x06; + public static final byte REMINDERS_CMD_UPDATE = 0x07; + public static final byte REMINDERS_CMD_UPDATE_ACK = 0x08; + public static final byte REMINDERS_CMD_DELETE = 0x09; + public static final byte REMINDERS_CMD_DELETE_ACK = 0x0a; + public static final int REMINDER_FLAG_ENABLED = 0x0001; + public static final int REMINDER_FLAG_TEXT = 0x0008; + public static final int REMINDER_FLAG_REPEAT_MONTH = 0x1000; + public static final int REMINDER_FLAG_REPEAT_YEAR = 0x2000; + + /** + * Calendar, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_CALENDAR}. + */ + public static final byte CALENDAR_CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte CALENDAR_CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte CALENDAR_CMD_EVENTS_REQUEST = 0x05; + public static final byte CALENDAR_CMD_EVENTS_RESPONSE = 0x06; + public static final byte CALENDAR_CMD_CREATE_EVENT = 0x07; + public static final byte CALENDAR_CMD_CREATE_EVENT_ACK = 0x08; + public static final byte CALENDAR_CMD_DELETE_EVENT = 0x09; + public static final byte CALENDAR_CMD_DELETE_EVENT_ACK = 0x0a; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiConst.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiConst.java index 1b388391c..6476e928a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiConst.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiConst.java @@ -56,6 +56,7 @@ public class HuamiConst { public static final String AMAZFIT_NEO_NAME = "Amazfit Neo"; public static final String AMAZFIT_X = "Amazfit X"; + public static final String XIAOMI_SMART_BAND7_NAME = "Xiaomi Smart Band 7"; public static final String PREF_DISPLAY_ITEMS = "display_items"; public static final String PREF_DISPLAY_ITEMS_SORTABLE = "display_items_sortable"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java index c7603835c..9c5daeb67 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiCoordinator.java @@ -97,7 +97,9 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { } @Override - public boolean supportsFlashing() { return true; } + public boolean supportsFlashing() { + return true; + } @Override public Class getAppsManagementActivity() { @@ -148,22 +150,26 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { return DateTimeDisplay.DATE_TIME; } + public static AlwaysOnDisplay getAlwaysOnDisplay(final String deviceAddress) { + final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress); + final String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_MODE, DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_OFF); + return AlwaysOnDisplay.valueOf(pref.toUpperCase(Locale.ROOT)); + } + + public static Date getAlwaysOnDisplayStart(final String deviceAddress) { + return getTimePreference(DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_START, "00:00", deviceAddress); + } + + public static Date getAlwaysOnDisplayEnd(final String deviceAddress) { + return getTimePreference(DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_END, "00:00", deviceAddress); + } + public static ActivateDisplayOnLift getActivateDisplayOnLiftWrist(Context context, String deviceAddress) { SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(deviceAddress); - String liftOff = context.getString(R.string.p_off); - String liftOn = context.getString(R.string.p_on); - String liftScheduled = context.getString(R.string.p_scheduled); - String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_ACTIVATE_DISPLAY_ON_LIFT, liftOff); - if (liftOn.equals(pref)) { - return ActivateDisplayOnLift.ON; - } else if (liftScheduled.equals(pref)) { - return ActivateDisplayOnLift.SCHEDULED; - } - - return ActivateDisplayOnLift.OFF; + return ActivateDisplayOnLift.valueOf(pref.toUpperCase(Locale.ROOT)); } public static Date getDisplayOnLiftStart(String deviceAddress) { @@ -184,20 +190,10 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { public static DisconnectNotificationSetting getDisconnectNotificationSetting(Context context, String deviceAddress) { Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); - String liftOff = context.getString(R.string.p_off); - String liftOn = context.getString(R.string.p_on); - String liftScheduled = context.getString(R.string.p_scheduled); - String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_DISCONNECT_NOTIFICATION, liftOff); - if (liftOn.equals(pref)) { - return DisconnectNotificationSetting.ON; - } else if (liftScheduled.equals(pref)) { - return DisconnectNotificationSetting.SCHEDULED; - } - - return DisconnectNotificationSetting.OFF; + return DisconnectNotificationSetting.valueOf(pref.toUpperCase(Locale.ROOT)); } public static Date getDisconnectNotificationStart(String deviceAddress) { @@ -267,6 +263,21 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { return prefs.getBoolean(MiBandConst.PREF_SWIPE_UNLOCK, false); } + public static boolean getScreenOnOnNotification(String deviceAddress) { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS, false); + } + + public static int getScreenBrightness(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getInt(DeviceSettingsPreferenceConst.PREF_SCREEN_BRIGHTNESS, 50); + } + + public static int getScreenTimeout(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getInt(DeviceSettingsPreferenceConst.PREF_SCREEN_TIMEOUT, 5); + } + public static boolean getExposeHRThirdParty(String deviceAddress) { Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); return prefs.getBoolean(HuamiConst.PREF_EXPOSE_HR_THIRDPARTY, false); @@ -297,9 +308,29 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED, false); } - public static int getHeartrateAlertThreshold(String deviceAddress) throws IllegalArgumentException { + public static int getHeartrateAlertHighThreshold(String deviceAddress) throws IllegalArgumentException { Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); - return prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_THRESHOLD, 150); + return prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_HIGH_THRESHOLD, 150); + } + + public static int getHeartrateAlertLowThreshold(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getInt(DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_LOW_THRESHOLD, 45); + } + + public static boolean getHeartrateSleepBreathingQualityMonitoring(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING, false); + } + + public static boolean getSPO2AllDayMonitoring(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING, false); + } + + public static int getSPO2AlertThreshold(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getInt(DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD, 0); } public static boolean getHeartrateStressMonitoring(String deviceAddress) throws IllegalArgumentException { @@ -307,6 +338,11 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING, false); } + public static boolean getHeartrateStressRelaxationReminder(String deviceAddress) throws IllegalArgumentException { + Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); + return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER, false); + } + public static boolean getBtConnectedAdvertising(String deviceAddress) { Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(deviceAddress)); return prefs.getBoolean(DeviceSettingsPreferenceConst.PREF_BT_CONNECTED_ADVERTISEMENT, false); @@ -362,13 +398,7 @@ public abstract class HuamiCoordinator extends AbstractBLEDeviceCoordinator { String pref = prefs.getString(DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB, DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_OFF); - if (DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_AUTOMATIC.equals(pref)) { - return DoNotDisturb.AUTOMATIC; - } else if (DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_SCHEDULED.equals(pref)) { - return DoNotDisturb.SCHEDULED; - } - - return DoNotDisturb.OFF; + return DoNotDisturb.valueOf(pref.toUpperCase(Locale.ROOT)); } public static boolean getDoNotDisturbLiftWrist(String deviceAddress) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiFWHelper.java index f35e5dc3b..6e7ed3f7a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiFWHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiFWHelper.java @@ -27,11 +27,12 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiFirmwareInfo; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareInfo; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType; public abstract class HuamiFWHelper extends AbstractMiBandFWHelper { - protected HuamiFirmwareInfo firmwareInfo; + protected AbstractHuamiFirmwareInfo firmwareInfo; public HuamiFWHelper(Uri uri, Context context) throws IOException { super(uri, context); @@ -68,6 +69,7 @@ public abstract class HuamiFWHelper extends AbstractMiBandFWHelper { resId = R.string.kind_resources; break; case FIRMWARE: + case FIRMWARE_UIHH_2021_ZIP_WITH_CHANGELOG: resId = R.string.kind_firmware; break; case WATCHFACE: @@ -118,8 +120,8 @@ public abstract class HuamiFWHelper extends AbstractMiBandFWHelper { public HuamiFirmwareType getFirmwareType() { return firmwareInfo.getFirmwareType(); } - public HuamiFirmwareInfo getFirmwareInfo() { + + public AbstractHuamiFirmwareInfo getFirmwareInfo() { return firmwareInfo; } - } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java index e22ad6033..472571659 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/HuamiService.java @@ -206,6 +206,14 @@ public class HuamiService { public static byte DND_BYTE_END_HOURS = 4; public static byte DND_BYTE_END_MINUTES = 5; + public static final byte MUSIC_FLAG_STATE = 0x01; + public static final byte MUSIC_FLAG_ARTIST = 0x02; + public static final byte MUSIC_FLAG_ALBUM = 0x04; + public static final byte MUSIC_FLAG_TRACK = 0x08; + public static final byte MUSIC_FLAG_DURATION = 0x10; + public static final byte MUSIC_FLAG_NOTHING_PLAYING = 0x20; + public static final byte MUSIC_FLAG_VOLUME = 0x40; + public static final byte RESPONSE = 0x10; public static final byte SUCCESS = 0x01; @@ -238,13 +246,6 @@ public class HuamiService { public static final byte[] COMMAND_TEXT_NOTIFICATION = new byte[] {0x05, 0x01}; - /** - * Endpoints for 2021 chunked protocol - */ - public static final short CHUNKED2021_ENDPOINT_AUTH = 0x0082; - public static final short CHUNKED2021_ENDPOINT_COMPAT = 0x0090; - public static final short CHUNKED2021_ENDPOINT_SMSREPLY = 0x0013; - public static final byte[] COMMAND_ENABLE_HOURLY_CHIME = new byte[] { (byte) 0xfe, 0x0b, 0x00, 0x01, 0x0a, 0x00, 0x16, 0x00 }; public static final byte[] COMMAND_DISABLE_HOURLY_CHIME = new byte[] { (byte) 0xfe, 0x0b, 0x00, 0x00 }; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7Coordinator.java new file mode 100644 index 000000000..3cb3c2ad4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7Coordinator.java @@ -0,0 +1,142 @@ +/* Copyright (C) 2022 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.huami.miband7; + +import android.bluetooth.BluetoothDevice; +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Coordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; +import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; + +public class MiBand7Coordinator extends Huami2021Coordinator { + private static final Logger LOG = LoggerFactory.getLogger(MiBand7Coordinator.class); + + @NonNull + @Override + public DeviceType getSupportedType(final GBDeviceCandidate candidate) { + try { + final BluetoothDevice device = candidate.getDevice(); + final String name = device.getName(); + if (name != null && name.startsWith(HuamiConst.XIAOMI_SMART_BAND7_NAME)) { + return DeviceType.MIBAND7; + } + } catch (final Exception e) { + LOG.error("unable to check device support", e); + } + + return DeviceType.UNKNOWN; + } + + @Override + public DeviceType getDeviceType() { + return DeviceType.MIBAND7; + } + + @Override + public InstallHandler findInstallHandler(final Uri uri, final Context context) { + final MiBand7FWInstallHandler handler = new MiBand7FWInstallHandler(uri, context); + return handler.isValid() ? handler : null; + } + + @Override + public boolean supportsSmartWakeup(final GBDevice device) { + return true; + } + + @Override + public int[] getSupportedDeviceSpecificSettings(GBDevice device) { + return new int[]{ + R.xml.devicesettings_header_time, + //R.xml.devicesettings_timeformat, + R.xml.devicesettings_dateformat_2, + // TODO R.xml.devicesettings_world_clocks, + + R.xml.devicesettings_header_display, + R.xml.devicesettings_miband7_displayitems, + R.xml.devicesettings_miband7_shortcuts, + R.xml.devicesettings_nightmode, + R.xml.devicesettings_liftwrist_display_sensitivity, + R.xml.devicesettings_password, + R.xml.devicesettings_always_on_display, + R.xml.devicesettings_screen_timeout_5_to_15, + R.xml.devicesettings_screen_brightness, + + R.xml.devicesettings_header_health, + R.xml.devicesettings_heartrate_sleep_alert_activity_stress_spo2, + R.xml.devicesettings_inactivity_dnd_no_threshold, + R.xml.devicesettings_goal_notification, + + R.xml.devicesettings_header_workout, + R.xml.devicesettings_workout_start_on_phone, + R.xml.devicesettings_workout_send_gps_to_band, + + R.xml.devicesettings_header_notifications, + R.xml.devicesettings_vibrationpatterns, + R.xml.devicesettings_donotdisturb_withauto_and_always, + R.xml.devicesettings_screen_on_on_notifications, + R.xml.devicesettings_autoremove_notifications, + R.xml.devicesettings_canned_reply_16, + R.xml.devicesettings_transliteration, + + R.xml.devicesettings_header_calendar, + R.xml.devicesettings_sync_calendar, + + R.xml.devicesettings_header_other, + R.xml.devicesettings_device_actions_without_not_wear, + + R.xml.devicesettings_header_connection, + R.xml.devicesettings_expose_hr_thirdparty, + R.xml.devicesettings_bt_connected_advertisement, + R.xml.devicesettings_high_mtu, + }; + } + + @Override + public String[] getSupportedLanguageSettings(GBDevice device) { + return new String[]{ + "auto", + "de_DE", + "en_US", + "es_ES", + "fr_FR", + "it_IT", + "nl_NL", + "pt_PT", + "tr_TR", + }; + } + + @Override + public PasswordCapabilityImpl.Mode getPasswordCapability() { + return PasswordCapabilityImpl.Mode.NUMBERS_6; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7FWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7FWHelper.java new file mode 100644 index 000000000..ed9bf60da --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7FWHelper.java @@ -0,0 +1,39 @@ +/* Copyright (C) 2022 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.huami.miband7; + +import android.content.Context; +import android.net.Uri; + +import java.io.IOException; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband7.MiBand7FirmwareInfo; + +public class MiBand7FWHelper extends HuamiFWHelper { + public MiBand7FWHelper(final Uri uri, final Context context) throws IOException { + super(uri, context); + } + + @Override + protected void determineFirmwareInfo(final byte[] wholeFirmwareBytes) { + firmwareInfo = new MiBand7FirmwareInfo(wholeFirmwareBytes); + if (!firmwareInfo.isHeaderValid()) { + throw new IllegalArgumentException("Not a Xiaomi Smart Band 7 firmware"); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7FWInstallHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7FWInstallHandler.java new file mode 100644 index 000000000..465b616c2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/miband7/MiBand7FWInstallHandler.java @@ -0,0 +1,49 @@ +/* Copyright (C) 2022 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.huami.miband7; + +import android.content.Context; +import android.net.Uri; + +import java.io.IOException; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.AbstractMiBandFWInstallHandler; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; + +class MiBand7FWInstallHandler extends AbstractMiBandFWInstallHandler { + MiBand7FWInstallHandler(Uri uri, Context context) { + super(uri, context); + } + + @Override + protected String getFwUpgradeNotice() { + return mContext.getString(R.string.fw_upgrade_notice_miband7, helper.getHumanFirmwareVersion()); + } + + @Override + protected AbstractMiBandFWHelper createHelper(Uri uri, Context context) throws IOException { + return new MiBand7FWHelper(uri, context); + } + + @Override + protected boolean isSupportedDeviceType(GBDevice device) { + return device.getType() == DeviceType.MIBAND7; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWHelper.java index bbb78e261..38e781711 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/AbstractMiBandFWHelper.java @@ -54,7 +54,7 @@ public abstract class AbstractMiBandFWHelper { } try (InputStream in = new BufferedInputStream(uriHelper.openInputStream())) { - this.fw = FileUtils.readAll(in, 1024 * 1024 * 8); // 8.0 MB + this.fw = FileUtils.readAll(in, 1024 * 1024 * 16); // 16.0 MB determineFirmwareInfo(fw); } catch (IOException ex) { throw ex; // pass through diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DoNotDisturb.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DoNotDisturb.java index ee7a03b3e..e1c39b25d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DoNotDisturb.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/miband/DoNotDisturb.java @@ -19,5 +19,6 @@ package nodomain.freeyourgadget.gadgetbridge.devices.miband; public enum DoNotDisturb { OFF, AUTOMATIC, - SCHEDULED + SCHEDULED, + ALWAYS, } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java index e6775d69f..24213b3d1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/NotificationListener.java @@ -207,7 +207,7 @@ public class NotificationListener extends NotificationListenerService { PendingIntent actionIntent = wearableAction.getActionIntent(); Intent localIntent = new Intent(); localIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - if(wearableAction.getRemoteInputs()!=null) { + if (wearableAction.getRemoteInputs() != null && wearableAction.getRemoteInputs().length > 0) { RemoteInput[] remoteInputs = wearableAction.getRemoteInputs(); Bundle extras = new Bundle(); extras.putCharSequence(remoteInputs[0].getResultKey(), reply); @@ -409,7 +409,7 @@ public class NotificationListener extends NotificationListenerService { if (act != null) { NotificationSpec.Action wearableAction = new NotificationSpec.Action(); wearableAction.title = act.getTitle().toString(); - if(act.getRemoteInputs()!=null) { + if (act.getRemoteInputs() != null && act.getRemoteInputs().length > 0) { wearableAction.type = NotificationSpec.Action.TYPE_WEARABLE_REPLY; } else { wearableAction.type = NotificationSpec.Action.TYPE_WEARABLE_SIMPLE; @@ -780,8 +780,12 @@ public class NotificationListener extends NotificationListenerService { } private void logNotification(StatusBarNotification sbn, boolean posted) { - String infoMsg = (posted ? "Notification posted" : "Notification removed") - + ": " + sbn.getPackageName(); + String infoMsg = String.format( + "Notification %d %s: %s", + sbn.getId(), + posted ? "posted" : "removed", + sbn.getPackageName() + ); if (GBApplication.isRunningLollipopOrLater()) { infoMsg += ": " + sbn.getNotification().category; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java index 48b532fbf..d644e63be 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java @@ -26,6 +26,7 @@ import android.database.Cursor; import android.location.Location; import android.net.Uri; import android.os.Build; +import android.os.Parcelable; import android.provider.ContactsContract; import java.util.ArrayList; @@ -346,6 +347,12 @@ public class GBDeviceService implements DeviceService { invokeService(intent); } + @Override + public void onPhoneFound() { + Intent intent = createIntent().setAction(ACTION_PHONE_FOUND); + invokeService(intent); + } + @Override public void onSetConstantVibration(int intensity) { Intent intent = createIntent().setAction(ACTION_SET_CONSTANT_VIBRATION) @@ -431,7 +438,7 @@ public class GBDeviceService implements DeviceService { @Override public void onSendWeather(WeatherSpec weatherSpec) { Intent intent = createIntent().setAction(ACTION_SEND_WEATHER) - .putExtra(EXTRA_WEATHER, weatherSpec); + .putExtra(EXTRA_WEATHER, (Parcelable) weatherSpec); invokeService(intent); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java index e48ac8c88..520bc2375 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java @@ -53,6 +53,7 @@ public interface DeviceService extends EventHandler { String ACTION_FETCH_RECORDED_DATA = PREFIX + ".action.fetch_activity_data"; String ACTION_DISCONNECT = PREFIX + ".action.disconnect"; String ACTION_FIND_DEVICE = PREFIX + ".action.find_device"; + String ACTION_PHONE_FOUND = PREFIX + ".action.phone_found"; String ACTION_SET_CONSTANT_VIBRATION = PREFIX + ".action.set_constant_vibration"; String ACTION_SET_ALARMS = PREFIX + ".action.set_alarms"; String ACTION_SAVE_ALARMS = PREFIX + ".action.save_alarms"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java index e35906031..ebda0e846 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceType.java @@ -65,6 +65,7 @@ public enum DeviceType { AMAZFITTREXPRO(38, R.drawable.ic_device_zetime, R.drawable.ic_device_zetime_disabled, R.string.devicetype_amazfit_trex_pro), AMAZFITPOP(39, R.drawable.ic_device_amazfit_bip, R.drawable.ic_device_amazfit_bip_disabled, R.string.devicetype_amazfit_pop), AMAZFITPOPPRO(10040, R.drawable.ic_device_amazfit_bip, R.drawable.ic_device_amazfit_bip_disabled, R.string.devicetype_amazfit_pop_pro), + MIBAND7(10041, R.drawable.ic_device_miband6, R.drawable.ic_device_miband6_disabled, R.string.devicetype_miband7), HPLUS(40, R.drawable.ic_device_hplus, R.drawable.ic_device_hplus_disabled, R.string.devicetype_hplus), MAKIBESF68(41, R.drawable.ic_device_hplus, R.drawable.ic_device_hplus_disabled, R.string.devicetype_makibes_f68), EXRIZUK8(42, R.drawable.ic_device_hplus, R.drawable.ic_device_hplus_disabled, R.string.devicetype_exrizu_k8), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RecordedDataTypes.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RecordedDataTypes.java index 5b12d4caa..79d469b1e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RecordedDataTypes.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/RecordedDataTypes.java @@ -18,11 +18,11 @@ package nodomain.freeyourgadget.gadgetbridge.model; public class RecordedDataTypes { - public static int TYPE_ACTIVITY = 0x00000001; - public static int TYPE_WORKOUTS = 0x00000002; - public static int TYPE_GPS_TRACKS = 0x00000004; - public static int TYPE_TEMPERATURE = 0x00000008; - public static int TYPE_DEBUGLOGS = 0x00000010; + public static final int TYPE_ACTIVITY = 0x00000001; + public static final int TYPE_WORKOUTS = 0x00000002; + public static final int TYPE_GPS_TRACKS = 0x00000004; + public static final int TYPE_TEMPERATURE = 0x00000008; + public static final int TYPE_DEBUGLOGS = 0x00000010; - public static int TYPE_ALL = (int)0xffffffff; + public static final int TYPE_ALL = (int)0xffffffff; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Weather.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Weather.java index a8989b56d..fd8f5dd14 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Weather.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/Weather.java @@ -23,6 +23,12 @@ import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; + public class Weather { private static final Logger LOG = LoggerFactory.getLogger(Weather.class); @@ -30,12 +36,15 @@ public class Weather { private JSONObject reconstructedOWMForecast = null; + private File cacheFile; + public WeatherSpec getWeatherSpec() { return weatherSpec; } public void setWeatherSpec(WeatherSpec weatherSpec) { this.weatherSpec = weatherSpec; + saveToCache(); } public JSONObject createReconstructedOWMWeatherReply() { @@ -971,4 +980,70 @@ public class Weather { return 3; } } + + /** + * Set the weather cache file. If enabled and the current weather is null, load the cache file. + * + * @param cacheDir the cache directory, where the cache file will be created + * @param enabled whether caching is enabled + */ + public void setCacheFile(final File cacheDir, final boolean enabled) { + cacheFile = new File(cacheDir, "weatherCache.bin"); + + if (enabled) { + LOG.info("Setting weather cache file to {}", cacheFile.getPath()); + + if (cacheFile.isFile() && weatherSpec == null) { + try { + final FileInputStream f = new FileInputStream(cacheFile); + final ObjectInputStream o = new ObjectInputStream(f); + + weatherSpec = (WeatherSpec) o.readObject(); + + o.close(); + f.close(); + } catch (final Throwable e) { + LOG.error("Failed to read weather from cache", e); + weatherSpec = null; + cacheFile = null; + } + } else if (weatherSpec != null) { + saveToCache(); + } + } else { + if (cacheFile.isFile()) { + LOG.info("Deleting weather cache file {}", cacheFile.getPath()); + + try { + cacheFile.delete(); + } catch (final Throwable e) { + LOG.error("Failed to delete cache file", e); + cacheFile = null; + } + } + } + } + + /** + * Save the current weather to cache, if a cache file is enabled and the weather is not null. + */ + public void saveToCache() { + if (weatherSpec == null || cacheFile == null) { + return; + } + + LOG.info("Loading weather from cache {}", cacheFile.getPath()); + + try { + final FileOutputStream f = new FileOutputStream(cacheFile); + final ObjectOutputStream o = new ObjectOutputStream(f); + + o.writeObject(weatherSpec); + + o.close(); + f.close(); + } catch (final Throwable e) { + LOG.error("Failed to save weather to cache", e); + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeatherSpec.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeatherSpec.java index b35a51a57..e10aa46b3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeatherSpec.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/WeatherSpec.java @@ -21,11 +21,12 @@ package nodomain.freeyourgadget.gadgetbridge.model; import android.os.Parcel; import android.os.Parcelable; +import java.io.Serializable; import java.util.ArrayList; -import java.util.Arrays; // FIXME: document me and my fields, including units -public class WeatherSpec implements Parcelable { +public class WeatherSpec implements Parcelable, Serializable { + public static final Creator CREATOR = new Creator() { @Override public WeatherSpec createFromParcel(Parcel in) { @@ -38,6 +39,7 @@ public class WeatherSpec implements Parcelable { } }; public static final int VERSION = 2; + private static final long serialVersionUID = VERSION; public int timestamp; public String location; public int currentTemp; @@ -106,7 +108,9 @@ public class WeatherSpec implements Parcelable { dest.writeList(forecasts); } - public static class Forecast implements Parcelable { + public static class Forecast implements Parcelable, Serializable { + private static final long serialVersionUID = 1L; + public static final Creator CREATOR = new Creator() { @Override public Forecast createFromParcel(Parcel in) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java index e90558292..f67cd94c0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java @@ -108,6 +108,7 @@ import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_FI import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_HEARTRATE_TEST; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_INSTALL; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_NOTIFICATION; +import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_PHONE_FOUND; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_POWER_OFF; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_READ_CONFIGURATION; import static nodomain.freeyourgadget.gadgetbridge.model.DeviceService.ACTION_REQUEST_APPINFO; @@ -693,6 +694,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere deviceSupport.onFindDevice(start); break; } + case ACTION_PHONE_FOUND: { + deviceSupport.onPhoneFound(); + break; + } case ACTION_SET_CONSTANT_VIBRATION: { int intensity = intent.getIntExtra(EXTRA_VIBRATION_INTENSITY, 0); deviceSupport.onSetConstantVibration(intensity); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java index a9eb794a1..0daeb73da 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceSupportFactory.java @@ -71,6 +71,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband3.MiBand import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband4.MiBand4Support; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband5.MiBand5Support; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband6.MiBand6Support; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.miband7.MiBand7Support; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppe.ZeppESupport; import nodomain.freeyourgadget.gadgetbridge.service.devices.id115.ID115Support; import nodomain.freeyourgadget.gadgetbridge.service.devices.itag.ITagSupport; @@ -179,6 +180,8 @@ public class DeviceSupportFactory { return new ServiceDeviceSupport(new MiBand5Support()); case MIBAND6: return new ServiceDeviceSupport(new MiBand6Support()); + case MIBAND7: + return new ServiceDeviceSupport(new MiBand7Support()); case AMAZFITBIP: return new ServiceDeviceSupport(new AmazfitBipSupport()); case AMAZFITBIP_LITE: diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java index 2bc337687..563063d4c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java @@ -302,6 +302,14 @@ public class ServiceDeviceSupport implements DeviceSupport { delegate.onFindDevice(start); } + @Override + public void onPhoneFound() { + if (checkBusy("phone found")) { + return; + } + delegate.onPhoneFound(); + } + @Override public void onSetConstantVibration(int intensity) { if (checkBusy("set constant vibration")) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java index 28cabe7c4..12f940454 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/AbstractBTLEDeviceSupport.java @@ -355,6 +355,11 @@ public abstract class AbstractBTLEDeviceSupport extends AbstractDeviceSupport im } } + @Override + public void onPhoneFound() { + + } + @Override public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java index b32affc9f..fde1dae34 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/btle/BLETypeConversions.java @@ -157,6 +157,10 @@ public class BLETypeConversions { return (bytes[0] & 0xff) | ((bytes[1] & 0xff) << 8) | ((bytes[2] & 0xff) << 16) | ((bytes[3] & 0xff) << 24); } + public static int toUint32(byte[] bytes, int offset) { + return (bytes[offset + 0] & 0xff) | ((bytes[offset + 1] & 0xff) << 8) | ((bytes[offset + 2] & 0xff) << 16) | ((bytes[offset + 3] & 0xff) << 24); + } + public static byte[] fromUint16(int value) { return new byte[] { (byte) (value & 0xff), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiFirmwareInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiFirmwareInfo.java new file mode 100644 index 000000000..4ccd608e8 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/AbstractHuamiFirmwareInfo.java @@ -0,0 +1,110 @@ +/* Copyright (C) 2017-2022 Andreas Shimokawa, 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.huami; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; + +public abstract class AbstractHuamiFirmwareInfo { + private final byte[] bytes; + + private final int crc16; + private final int crc32; + + protected final HuamiFirmwareType firmwareType; + + public AbstractHuamiFirmwareInfo(byte[] bytes) { + this.bytes = bytes; + this.crc16 = CheckSums.getCRC16(bytes); + this.crc32 = CheckSums.getCRC32(bytes); + this.firmwareType = determineFirmwareType(bytes); + } + + public boolean isHeaderValid() { + return getFirmwareType() != HuamiFirmwareType.INVALID; + } + + public void checkValid() throws IllegalArgumentException { + } + + public int[] getWhitelistedVersions() { + return ArrayUtils.toIntArray(getCrcMap().keySet()); + } + + /** + * @return the size of the firmware in number of bytes. + */ + public int getSize() { + return bytes.length; + } + + public byte[] getBytes() { + return bytes; + } + + public int getCrc16() { + return crc16; + } + + public int getCrc32() { + return crc32; + } + + public int getFirmwareVersion() { + return getCrc16(); // HACK until we know how to determine the version from the fw bytes + } + + public HuamiFirmwareType getFirmwareType() { + return firmwareType; + } + + public abstract String toVersion(int crc16); + + public abstract boolean isGenerallyCompatibleWith(GBDevice device); + + protected abstract Map getCrcMap(); + + protected abstract HuamiFirmwareType determineFirmwareType(byte[] bytes); + + public static boolean searchString32BitAligned(byte[] fwbytes, String findString) { + ByteBuffer stringBuf = ByteBuffer.wrap((findString + "\0").getBytes()); + stringBuf.order(ByteOrder.BIG_ENDIAN); + int[] findArray = new int[stringBuf.remaining() / 4]; + for (int i = 0; i < findArray.length; i++) { + findArray[i] = stringBuf.getInt(); + } + + ByteBuffer buf = ByteBuffer.wrap(fwbytes); + buf.order(ByteOrder.BIG_ENDIAN); + while (buf.remaining() > 3) { + int arrayPos = 0; + while (arrayPos < findArray.length && buf.remaining() > 3 && (buf.getInt() == findArray[arrayPos])) { + arrayPos++; + } + if (arrayPos == findArray.length) { + return true; + } + } + return false; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ChunkedDecoder.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ChunkedDecoder.java new file mode 100644 index 000000000..3e9ba61b7 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ChunkedDecoder.java @@ -0,0 +1,126 @@ +/* Copyright (C) 2022 Andreas Shimokawa + + 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.huami; + +import org.apache.commons.lang3.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; + +import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class Huami2021ChunkedDecoder { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021ChunkedDecoder.class); + + private Byte currentHandle; + private int currentType; + private int currentLength; + ByteBuffer reassemblyBuffer; + + private volatile byte[] sharedSessionKey; + + private Huami2021Handler huami2021Handler; + private final boolean force2021Protocol; + + public Huami2021ChunkedDecoder(final Huami2021Handler huami2021Handler, + final boolean force2021Protocol) { + this.huami2021Handler = huami2021Handler; + this.force2021Protocol = force2021Protocol; + } + + public void setEncryptionParameters(final byte[] sharedSessionKey) { + this.sharedSessionKey = sharedSessionKey; + } + + public void setHuami2021Handler(final Huami2021Handler huami2021Handler) { + this.huami2021Handler = huami2021Handler; + } + + public void decode(final byte[] data) { + int i = 0; + if (data[i++] != 0x03) { + //LOG.warn("Ignoring non-chunked payload"); + return; + } + final byte flags = data[i++]; + final boolean encrypted = ((flags & 0x08) == 0x08); + final boolean firstChunk = ((flags & 0x01) == 0x01); + final boolean lastChunk = ((flags & 0x02) == 0x02); + + if (force2021Protocol) { + i++; // skip extended header + } + final byte handle = data[i++]; + if (currentHandle != null && currentHandle != handle) { + LOG.warn("ignoring handle {}, expected {}", handle, currentHandle); + return; + } + byte count = data[i++]; + if (firstChunk) { // beginning + int full_length = (data[i++] & 0xff) | ((data[i++] & 0xff) << 8) | ((data[i++] & 0xff) << 16) | ((data[i++] & 0xff) << 24); + currentLength = full_length; + if (encrypted) { + int encrypted_length = full_length + 8; + int overflow = encrypted_length % 16; + if (overflow > 0) { + encrypted_length += (16 - overflow); + } + full_length = encrypted_length; + } + reassemblyBuffer = ByteBuffer.allocate(full_length); + currentType = (data[i++] & 0xff) | ((data[i++] & 0xff) << 8); + currentHandle = handle; + } + reassemblyBuffer.put(data, i, data.length - i); + if (lastChunk) { // end + byte[] buf = reassemblyBuffer.array(); + if (encrypted) { + if (sharedSessionKey == null) { + // Should never happen + LOG.warn("Got encrypted message, but there's no shared session key"); + currentHandle = null; + currentType = 0; + return; + } + + byte[] messagekey = new byte[16]; + for (int j = 0; j < 16; j++) { + messagekey[j] = (byte) (sharedSessionKey[j] ^ handle); + } + try { + buf = CryptoUtils.decryptAES(buf, messagekey); + buf = ArrayUtils.subarray(buf, 0, currentLength); + LOG.debug("decrypted data {}: {}", String.format("0x%04x", currentType), GB.hexdump(buf)); + } catch (Exception e) { + LOG.warn("error decrypting " + e); + currentHandle = null; + currentType = 0; + return; + } + } + try { + huami2021Handler.handle2021Payload(currentType, buf); + } catch (final Exception e) { + LOG.error("Failed to handle payload", e); + } + currentHandle = null; + currentType = 0; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ChunkedEncoder.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ChunkedEncoder.java new file mode 100644 index 000000000..126b4a988 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021ChunkedEncoder.java @@ -0,0 +1,163 @@ +/* Copyright (C) 2022 Andreas Shimokawa + + 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.huami; + +import android.bluetooth.BluetoothGattCharacteristic; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; +import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils; + +public class Huami2021ChunkedEncoder { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021ChunkedEncoder.class); + + private final BluetoothGattCharacteristic characteristicChunked2021Write; + + private byte writeHandle; + + // These must be volatile, since they are set by a different thread. Sometimes, GB might + // attempt to encode a payload before they were set, which will make them not be propagated + // to that thread later. + private volatile int encryptedSequenceNr; + private volatile byte[] sharedSessionKey; + + private final boolean force2021Protocol; + private volatile int mMTU = 23; + + public Huami2021ChunkedEncoder(final BluetoothGattCharacteristic characteristicChunked2021Write, + final boolean force2021Protocol, + final int mMTU) { + this.characteristicChunked2021Write = characteristicChunked2021Write; + this.force2021Protocol = force2021Protocol; + this.mMTU = mMTU; + } + + public synchronized void setEncryptionParameters(final int encryptedSequenceNr, final byte[] sharedSessionKey) { + this.encryptedSequenceNr = encryptedSequenceNr; + this.sharedSessionKey = sharedSessionKey; + } + + public synchronized void setMTU(int mMTU) { + this.mMTU = mMTU; + } + + public synchronized void write(final TransactionBuilder builder, + final short type, + byte[] data, + final boolean extended_flags, + final boolean encrypt) { + if (encrypt && sharedSessionKey == null) { + LOG.error("Can't encrypt without the shared session key"); + return; + } + + writeHandle++; + + int remaining = data.length; + int length = data.length; + byte count = 0; + int header_size = 10; + + if (extended_flags) { + header_size++; + } + + if (extended_flags && encrypt) { + byte[] messagekey = new byte[16]; + for (int i = 0; i < 16; i++) { + messagekey[i] = (byte) (sharedSessionKey[i] ^ writeHandle); + } + int encrypted_length = length + 8; + int overflow = encrypted_length % 16; + if (overflow > 0) { + encrypted_length += (16 - overflow); + } + + byte[] encryptable_payload = new byte[encrypted_length]; + System.arraycopy(data, 0, encryptable_payload, 0, length); + encryptable_payload[length] = (byte) (encryptedSequenceNr & 0xff); + encryptable_payload[length + 1] = (byte) ((encryptedSequenceNr >> 8) & 0xff); + encryptable_payload[length + 2] = (byte) ((encryptedSequenceNr >> 16) & 0xff); + encryptable_payload[length + 3] = (byte) ((encryptedSequenceNr >> 24) & 0xff); + encryptedSequenceNr++; + int checksum = CheckSums.getCRC32(encryptable_payload, 0, length + 4); + encryptable_payload[length + 4] = (byte) (checksum & 0xff); + encryptable_payload[length + 5] = (byte) ((checksum >> 8) & 0xff); + encryptable_payload[length + 6] = (byte) ((checksum >> 16) & 0xff); + encryptable_payload[length + 7] = (byte) ((checksum >> 24) & 0xff); + remaining = encrypted_length; + try { + data = CryptoUtils.encryptAES(encryptable_payload, messagekey); + } catch (Exception e) { + LOG.error("error while encrypting", e); + return; + } + + } + + while (remaining > 0) { + int MAX_CHUNKLENGTH = mMTU - 3 - header_size; + int copybytes = Math.min(remaining, MAX_CHUNKLENGTH); + byte[] chunk = new byte[copybytes + header_size]; + + byte flags = 0; + if (encrypt) { + flags |= 0x08; + } + if (count == 0) { + flags |= 0x01; + int i = 4; + if (extended_flags) { + i++; + } + chunk[i++] = (byte) (length & 0xff); + chunk[i++] = (byte) ((length >> 8) & 0xff); + chunk[i++] = (byte) ((length >> 16) & 0xff); + chunk[i++] = (byte) ((length >> 24) & 0xff); + chunk[i++] = (byte) (type & 0xff); + chunk[i] = (byte) ((type >> 8) & 0xff); + } + if (remaining <= MAX_CHUNKLENGTH) { + flags |= 0x06; // last chunk? + } + chunk[0] = 0x03; + chunk[1] = flags; + if (extended_flags) { + chunk[2] = 0; + chunk[3] = writeHandle; + chunk[4] = count; + } else { + chunk[2] = writeHandle; + chunk[3] = count; + } + + System.arraycopy(data, data.length - remaining, chunk, header_size, copybytes); + builder.write(characteristicChunked2021Write, chunk); + remaining -= copybytes; + header_size = 4; + + if (extended_flags) { + header_size++; + } + + count++; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Config.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Config.java new file mode 100644 index 000000000..145a9a901 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Config.java @@ -0,0 +1,667 @@ +/* Copyright (C) 2022 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.huami; + +import static org.apache.commons.lang3.ArrayUtils.subarray; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ACTIVATE_DISPLAY_ON_LIFT; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_END; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_MODE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_START; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BT_CONNECTED_ADVERTISEMENT; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DATEFORMAT; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DISPLAY_ON_LIFT_END; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DISPLAY_ON_LIFT_SENSITIVITY; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DISPLAY_ON_LIFT_START; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_END; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_START; +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; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_MEASUREMENT_INTERVAL; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_END; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_INACTIVITY_DND_START; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_INACTIVITY_ENABLE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_INACTIVITY_END; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_INACTIVITY_START; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_LANGUAGE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SCREEN_BRIGHTNESS; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SCREEN_TIMEOUT; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_TIMEFORMAT; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_NOTIFICATION; +import static nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl.PREF_PASSWORD; +import static nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl.PREF_PASSWORD_ENABLED; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.CHUNKED2021_ENDPOINT_CONFIG; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.CONFIG_CMD_SET; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_EXPOSE_HR_THIRDPARTY; +import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE; +import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_END; +import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_START; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +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.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.ActivateDisplayOnLift; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.ActivateDisplayOnLiftSensitivity; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.AlwaysOnDisplay; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.DoNotDisturb; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.util.MapUtils; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class Huami2021Config { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021Config.class); + + public enum ConfigType { + DISPLAY(0x01, 0x02), + LOCKSCREEN(0x04, 0x01), + LANGUAGE(0x07, 0x02), + HEALTH(0x08, 0x02), + SYSTEM(0x0a, 0x01), + BLUETOOTH(0x0b, 0x01), + ; + + private final byte value; + private final byte nextByte; // FIXME what does this byte mean? + + ConfigType(int value, int nextByte) { + this.value = (byte) value; + this.nextByte = (byte) nextByte; + } + + public byte getValue() { + return value; + } + + public byte getNextByte() { + return nextByte; + } + + public static ConfigType fromValue(final byte value) { + for (final ConfigType configType : values()) { + if (configType.getValue() == value) { + return configType; + } + } + + return null; + } + } + + public enum ArgType { + BOOL(0x0b), + STRING(0x20), + SHORT(0x01), + INT(0x03), + BYTE(0x10), + DATETIME_HH_MM(0x30), + ; + + private final byte value; + + ArgType(int value) { + this.value = (byte) value; + } + + public byte getValue() { + return value; + } + } + + public enum ConfigArg { + // Display + SCREEN_BRIGHTNESS(ConfigType.DISPLAY, ArgType.SHORT, 0x02, PREF_SCREEN_BRIGHTNESS), + SCREEN_TIMEOUT(ConfigType.DISPLAY, ArgType.BYTE, 0x03, PREF_SCREEN_TIMEOUT), + ALWAYS_ON_DISPLAY_MODE(ConfigType.DISPLAY, ArgType.BYTE, 0x04, PREF_ALWAYS_ON_DISPLAY_MODE), + ALWAYS_ON_DISPLAY_SCHEDULED_START(ConfigType.DISPLAY, ArgType.DATETIME_HH_MM, 0x05, PREF_ALWAYS_ON_DISPLAY_START), + ALWAYS_ON_DISPLAY_SCHEDULED_END(ConfigType.DISPLAY, ArgType.DATETIME_HH_MM, 0x06, PREF_ALWAYS_ON_DISPLAY_END), + LIFT_WRIST_MODE(ConfigType.DISPLAY, ArgType.BYTE, 0x08, PREF_ACTIVATE_DISPLAY_ON_LIFT), + LIFT_WRIST_SCHEDULED_START(ConfigType.DISPLAY, ArgType.DATETIME_HH_MM, 0x09, PREF_DISPLAY_ON_LIFT_START), + LIFT_WRIST_SCHEDULED_END(ConfigType.DISPLAY, ArgType.DATETIME_HH_MM, 0x0a, PREF_DISPLAY_ON_LIFT_END), + LIFT_WRIST_RESPONSE_SENSITIVITY(ConfigType.DISPLAY, ArgType.BYTE, 0x0b, PREF_DISPLAY_ON_LIFT_SENSITIVITY), + SCREEN_ON_ON_NOTIFICATIONS(ConfigType.DISPLAY, ArgType.BOOL, 0x0c, PREF_SCREEN_ON_ON_NOTIFICATIONS), + + // Lock Screen + PASSWORD_ENABLED(ConfigType.LOCKSCREEN, ArgType.BOOL, 0x01, PREF_PASSWORD_ENABLED), + PASSWORD_TEXT(ConfigType.LOCKSCREEN, ArgType.STRING, 0x02, PREF_PASSWORD), + + // Language + LANGUAGE(ConfigType.LANGUAGE, ArgType.BYTE, 0x01, PREF_LANGUAGE), + LANGUAGE_FOLLOW_PHONE(ConfigType.LANGUAGE, ArgType.BOOL, 0x02, null), + + // Health + HEART_RATE_ALL_DAY_MONITORING(ConfigType.HEALTH, ArgType.BYTE, 0x01, PREF_HEARTRATE_MEASUREMENT_INTERVAL), + HEART_RATE_HIGH_ALERTS(ConfigType.HEALTH, ArgType.BYTE, 0x02, PREF_HEARTRATE_ALERT_HIGH_THRESHOLD), + HEART_RATE_LOW_ALERTS(ConfigType.HEALTH, ArgType.BYTE, 0x03, PREF_HEARTRATE_ALERT_LOW_THRESHOLD), + THIRD_PARTY_HR_SHARING(ConfigType.HEALTH, ArgType.BOOL, 0x05, PREF_EXPOSE_HR_THIRDPARTY), + SLEEP_HIGH_ACCURACY_MONITORING(ConfigType.HEALTH, ArgType.BOOL, 0x11, PREF_HEARTRATE_USE_FOR_SLEEP_DETECTION), + SLEEP_BREATHING_QUALITY_MONITORING(ConfigType.HEALTH, ArgType.BOOL, 0x12, PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING), + STRESS_MONITORING(ConfigType.HEALTH, ArgType.BOOL, 0x13, PREF_HEARTRATE_STRESS_MONITORING), + STRESS_RELAXATION_REMINDER(ConfigType.HEALTH, ArgType.BOOL, 0x14, PREF_HEARTRATE_STRESS_RELAXATION_REMINDER), + SPO2_ALL_DAY_MONITORING(ConfigType.HEALTH, ArgType.BOOL, 0x31, PREF_SPO2_ALL_DAY_MONITORING), + SPO2_LOW_ALERT(ConfigType.HEALTH, ArgType.BYTE, 0x32, PREF_SPO2_LOW_ALERT_THRESHOLD), + FITNESS_GOAL_NOTIFICATION(ConfigType.HEALTH, ArgType.BOOL, 0x51, PREF_USER_FITNESS_GOAL_NOTIFICATION), // TODO is it? + FITNESS_GOAL_STEPS(ConfigType.HEALTH, ArgType.INT, 0x52, null), // TODO needs to be handled globally + INACTIVITY_WARNINGS_ENABLED(ConfigType.HEALTH, ArgType.BOOL, 0x41, PREF_INACTIVITY_ENABLE), + INACTIVITY_WARNINGS_SCHEDULED_START(ConfigType.HEALTH, ArgType.DATETIME_HH_MM, 0x42, PREF_INACTIVITY_START), + INACTIVITY_WARNINGS_SCHEDULED_END(ConfigType.HEALTH, ArgType.DATETIME_HH_MM, 0x43, PREF_INACTIVITY_END), + INACTIVITY_WARNINGS_DND_ENABLED(ConfigType.HEALTH, ArgType.BOOL, 0x44, PREF_INACTIVITY_DND), + INACTIVITY_WARNINGS_DND_SCHEDULED_START(ConfigType.HEALTH, ArgType.DATETIME_HH_MM, 0x45, PREF_INACTIVITY_DND_START), + INACTIVITY_WARNINGS_DND_SCHEDULED_END(ConfigType.HEALTH, ArgType.DATETIME_HH_MM, 0x46, PREF_INACTIVITY_DND_END), + + // System + TIME_FORMAT(ConfigType.SYSTEM, ArgType.BYTE, 0x01, PREF_TIMEFORMAT), + DATE_FORMAT(ConfigType.SYSTEM, ArgType.STRING, 0x02, null), + DND_MODE(ConfigType.SYSTEM, ArgType.BYTE, 0x0a, PREF_DO_NOT_DISTURB), + DND_SCHEDULED_START(ConfigType.SYSTEM, ArgType.DATETIME_HH_MM, 0x0b, PREF_DO_NOT_DISTURB_START), + DND_SCHEDULED_END(ConfigType.SYSTEM, ArgType.DATETIME_HH_MM, 0x0c, PREF_DO_NOT_DISTURB_END), + TEMPERATURE_UNIT(ConfigType.SYSTEM, ArgType.BYTE, 0x12, null), + TIME_FORMAT_FOLLOWS_PHONE(ConfigType.SYSTEM, ArgType.BOOL, 0x13, null), + DISPLAY_CALLER(ConfigType.SYSTEM, ArgType.BOOL, 0x18, null), // TODO Handle + NIGHT_MODE_MODE(ConfigType.SYSTEM, ArgType.BYTE, 0x1b, PREF_NIGHT_MODE), + NIGHT_MODE_SCHEDULED_START(ConfigType.SYSTEM, ArgType.DATETIME_HH_MM, 0x1c, PREF_NIGHT_MODE_START), + NIGHT_MODE_SCHEDULED_END(ConfigType.SYSTEM, ArgType.DATETIME_HH_MM, 0x1d, PREF_NIGHT_MODE_END), + + // Bluetooth + BLUETOOTH_CONNECTED_ADVERTISING(ConfigType.BLUETOOTH, ArgType.BOOL, 0x02, PREF_BT_CONNECTED_ADVERTISEMENT), + ; + + private final ConfigType configType; + private final ArgType argType; + private final byte code; + private final String prefKey; + + ConfigArg(final ConfigType configType, final ArgType argType, final int code, final String prefKey) { + this.configType = configType; + this.argType = argType; + this.code = (byte) code; + this.prefKey = prefKey; + } + + public ConfigType getConfigType() { + return configType; + } + + public ArgType getArgType() { + return argType; + } + + public byte getCode() { + return code; + } + + public String getPrefKey() { + return prefKey; + } + + public static ConfigArg fromCode(final ConfigType configType, final byte code) { + for (final Huami2021Config.ConfigArg arg : values()) { + if (arg.getConfigType().equals(configType) && arg.getCode() == code) { + return arg; + } + } + return null; + } + + public static List getAllArgsForConfigType(final ConfigType configType) { + final List configArgs = new ArrayList<>(); + for (final Huami2021Config.ConfigArg arg : values()) { + if (arg.getConfigType().equals(configType)) { + configArgs.add(arg); + } + } + return configArgs; + } + } + + public static class ConfigSetter { + private final ConfigType configType; + private final Map arguments = new LinkedHashMap<>(); + + public ConfigSetter(final ConfigType configType) { + this.configType = configType; + } + + public ConfigSetter setBoolean(final ConfigArg arg, final boolean value) { + checkArg(arg, ArgType.BOOL); + + arguments.put(arg, new byte[]{(byte) (value ? 0x01 : 0x00)}); + + return this; + } + + public ConfigSetter setString(final ConfigArg arg, final String value) { + checkArg(arg, ArgType.STRING); + + arguments.put(arg, (value + "\0").getBytes(StandardCharsets.UTF_8)); + + return this; + } + + public ConfigSetter setShort(final ConfigArg arg, final short value) { + checkArg(arg, ArgType.SHORT); + + arguments.put(arg, BLETypeConversions.fromUint16(value)); + + return this; + } + + public ConfigSetter setInt(final ConfigArg arg, final int value) { + checkArg(arg, ArgType.INT); + + arguments.put(arg, BLETypeConversions.fromUint32(value)); + + return this; + } + + public ConfigSetter setByte(final ConfigArg arg, final byte value) { + checkArg(arg, ArgType.BYTE); + + arguments.put(arg, new byte[]{value}); + + return this; + } + + public ConfigSetter setHourMinute(final ConfigArg arg, final Date date) { + checkArg(arg, ArgType.DATETIME_HH_MM); + + final Calendar calendar = GregorianCalendar.getInstance(); + calendar.setTime(date); + + arguments.put(arg, new byte[]{ + (byte) calendar.get(Calendar.HOUR_OF_DAY), + (byte) calendar.get(Calendar.MINUTE) + }); + + return this; + } + + public byte[] encode() { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + baos.write(CONFIG_CMD_SET); + baos.write(configType.getValue()); + baos.write(configType.getNextByte()); + baos.write(0x00); // ? + baos.write(arguments.size()); + for (final Map.Entry arg : arguments.entrySet()) { + final ArgType argType = arg.getKey().getArgType(); + baos.write(arg.getKey().getCode()); + baos.write(argType.getValue()); + baos.write(arg.getValue()); + } + } catch (final IOException e) { + LOG.error("Failed to encode command", e); + } + + return baos.toByteArray(); + } + + public void write(final Huami2021Support support, final TransactionBuilder builder) { + support.writeToChunked2021(builder, CHUNKED2021_ENDPOINT_CONFIG, encode(), true); + } + + public void write(final Huami2021Support support) { + try { + final TransactionBuilder builder = support.performInitialized("write config"); + support.writeToChunked2021(builder, CHUNKED2021_ENDPOINT_CONFIG, encode(), true); + builder.queue(support.getQueue()); + } catch (final Exception e) { + LOG.error("Failed to write config", e); + } + } + + private void checkArg(final ConfigArg arg, final ArgType expectedArgType) { + try { + if (!configType.equals(arg.getConfigType())) { + throw new IllegalArgumentException("Unexpected config type " + arg.getConfigType()); + } + + if (!expectedArgType.equals(arg.getArgType())) { + throw new IllegalArgumentException( + String.format( + "Invalid arg type %s for %s, expected %s", + expectedArgType, + arg, + arg.getArgType() + ) + ); + } + } catch (final IllegalArgumentException e) { + if (!BuildConfig.DEBUG) { + // Crash + throw e; + } else { + LOG.error(e.getMessage()); + } + } + } + } + + public static class ConfigParser { + private static final Logger LOG = LoggerFactory.getLogger(ConfigParser.class); + + private final ConfigType configType; + + public ConfigParser(final ConfigType configType) { + this.configType = configType; + } + + public Map parse(final int expectedNumConfigs, final byte[] bytes) { + final Map prefs = new HashMap<>(); + + int configCount = 0; + int pos = 0; + + while (pos < bytes.length) { + if (configCount > expectedNumConfigs) { + LOG.error("Got more configs than {}", expectedNumConfigs); + return null; + } + + final Huami2021Config.ConfigArg configArg = Huami2021Config.ConfigArg.fromCode(configType, bytes[pos]); + if (configArg == null) { + LOG.error("Unknown config {} for {} at {}", String.format("0x%02x", bytes[pos]), configType, pos); + return null; + } + + pos++; + + final boolean unexpectedType = (bytes[pos] != configArg.getArgType().getValue()); + if (unexpectedType) { + LOG.warn("Unexpected arg type {} for {}, expected {}", String.format("0x%02x", bytes[pos]), configArg, configArg.getArgType()); + } + + pos++; + + final Map argPrefs; + + switch (configArg.getArgType()) { + case BOOL: + final boolean valBoolean = bytes[pos] == 1; + LOG.info("Got {} for {} = {}", configArg.getArgType(), configArg, valBoolean); + argPrefs = convertBooleanToPrefs(configArg, valBoolean); + pos += 1; + break; + case STRING: + final String valString = StringUtils.untilNullTerminator(bytes, pos); + LOG.info("Got {} for {} = {}", configArg.getArgType(), configArg, valString); + argPrefs = convertStringToPrefs(configArg, valString); + pos += valString.length() + 1; + break; + case SHORT: + final int valShort = BLETypeConversions.toUint16(subarray(bytes, pos, pos + 2)); + LOG.info("Got {} for {} = {}", configArg.getArgType(), configArg, valShort); + argPrefs = convertNumberToPrefs(configArg, valShort); + pos += 2; + break; + case INT: + final int valInt = BLETypeConversions.toUint32(subarray(bytes, pos, pos + 4)); + LOG.info("Got {} for {} = {}", configArg.getArgType(), configArg, valInt); + argPrefs = convertNumberToPrefs(configArg, valInt); + pos += 4; + break; + case BYTE: + final byte valByte = bytes[pos]; + LOG.info("Got {} for {} = {}", configArg.getArgType(), configArg, valByte); + argPrefs = convertByteToPrefs(configArg, valByte); + pos += 1; + break; + case DATETIME_HH_MM: + final DateFormat df = new SimpleDateFormat("HH:mm", Locale.getDefault()); + final String hhmm = String.format(Locale.ROOT, "%02d:%02d", bytes[pos], bytes[pos + 1]); + try { + df.parse(hhmm); + } catch (final ParseException e) { + LOG.error("Failed to parse HH:mm from {}", hhmm); + return null; + } + LOG.info("Got {} for {} = {}", configArg.getArgType(), configArg, hhmm); + argPrefs = convertDatetimeHhMmToPrefs(configArg, hhmm); + pos += 2; + break; + default: + LOG.error("Unknown arg type {}", configArg); + configCount++; + continue; + } + + if (argPrefs != null && !unexpectedType) { + // Special cases for "follow phone" preferences. We need to ensure that "auto" + // always has precedence + if (argPrefs.containsKey(PREF_LANGUAGE) && prefs.containsKey(PREF_LANGUAGE)) { + if (prefs.get(PREF_LANGUAGE).equals(DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO)) { + argPrefs.remove(PREF_LANGUAGE); + } + } + if (argPrefs.containsKey(PREF_TIMEFORMAT) && prefs.containsKey(PREF_TIMEFORMAT)) { + if (prefs.get(PREF_TIMEFORMAT).equals(DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_AUTO)) { + argPrefs.remove(PREF_TIMEFORMAT); + } + } + + prefs.putAll(argPrefs); + } + + configCount++; + } + + return prefs; + } + + private static Map convertBooleanToPrefs(final ConfigArg configArg, final boolean value) { + if (configArg.getPrefKey() != null) { + // The arg maps to a boolean pref directly + return singletonMap(configArg.getPrefKey(), value); + } + + switch(configArg) { + case LANGUAGE_FOLLOW_PHONE: + if (value) { + return singletonMap(PREF_LANGUAGE, DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO); + } else { + // If not following phone, we'll receive the actual value in LANGUAGE + return Collections.emptyMap(); + } + case TIME_FORMAT_FOLLOWS_PHONE: + if (value) { + return singletonMap(PREF_TIMEFORMAT, DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_AUTO); + } else { + // If not following phone, we'll receive the actual value in TIME_FORMAT + return Collections.emptyMap(); + } + default: + break; + } + + LOG.warn("Unhandled Boolean pref {}", configArg); + return null; + } + + private static Map convertStringToPrefs(final ConfigArg configArg, final String str) { + if (configArg.getPrefKey() != null) { + // The arg maps to a string pref directly + return singletonMap(configArg.getPrefKey(), str); + } + + switch(configArg) { + case DATE_FORMAT: + return singletonMap(PREF_DATEFORMAT, str.replace(".", "/").toUpperCase(Locale.ROOT)); + default: + break; + } + + LOG.warn("Unhandled String pref {}", configArg); + return null; + } + + private static Map convertNumberToPrefs(final ConfigArg configArg, final int value) { + if (configArg.getPrefKey() != null) { + // The arg maps to a number pref directly + return singletonMap(configArg.getPrefKey(), value); + } + + LOG.warn("Unhandled number pref {}", configArg); + return null; + } + + private static Map convertDatetimeHhMmToPrefs(final ConfigArg configArg, final String hhmm) { + if (configArg.getPrefKey() != null) { + // The arg maps to a hhmm pref directly + return singletonMap(configArg.getPrefKey(), hhmm); + } + + LOG.warn("Unhandled datetime pref {}", configArg); + return null; + } + + private static Map convertByteToPrefs(final ConfigArg configArg, final byte b) { + switch(configArg) { + case ALWAYS_ON_DISPLAY_MODE: + switch(b) { + case 0x00: + return singletonMap(configArg.getPrefKey(), AlwaysOnDisplay.OFF.name().toLowerCase(Locale.ROOT)); + case 0x01: + return singletonMap(configArg.getPrefKey(), AlwaysOnDisplay.AUTO.name().toLowerCase(Locale.ROOT)); + case 0x02: + return singletonMap(configArg.getPrefKey(), AlwaysOnDisplay.SCHEDULED.name().toLowerCase(Locale.ROOT)); + case 0x03: + return singletonMap(configArg.getPrefKey(), AlwaysOnDisplay.ALWAYS.name().toLowerCase(Locale.ROOT)); + } + break; + case LIFT_WRIST_MODE: + switch(b) { + case 0x00: + return singletonMap(configArg.getPrefKey(), ActivateDisplayOnLift.OFF.name().toLowerCase(Locale.ROOT)); + case 0x01: + return singletonMap(configArg.getPrefKey(), ActivateDisplayOnLift.SCHEDULED.name().toLowerCase(Locale.ROOT)); + case 0x02: + return singletonMap(configArg.getPrefKey(), ActivateDisplayOnLift.ON.name().toLowerCase(Locale.ROOT)); + } + break; + case LIFT_WRIST_RESPONSE_SENSITIVITY: + switch(b) { + case 0x00: + return singletonMap(configArg.getPrefKey(), ActivateDisplayOnLiftSensitivity.NORMAL.name().toLowerCase(Locale.ROOT)); + case 0x01: + return singletonMap(configArg.getPrefKey(), ActivateDisplayOnLiftSensitivity.SENSITIVE.name().toLowerCase(Locale.ROOT)); + } + break; + case LANGUAGE: + final Map reverseLanguageLookup = MapUtils.reverse(HuamiLanguageType.idLookup); + final String language = reverseLanguageLookup.get(b & 0xff); + if (language != null) { + return singletonMap(configArg.getPrefKey(), language); + } + break; + case HEART_RATE_ALL_DAY_MONITORING: + if (b > 0) { + return singletonMap(configArg.getPrefKey(), String.format("%d", (b & 0xff) * 60)); + } else { + return singletonMap(configArg.getPrefKey(), String.format("%d", b)); + } + case SCREEN_TIMEOUT: + case HEART_RATE_HIGH_ALERTS: + case HEART_RATE_LOW_ALERTS: + case SPO2_LOW_ALERT: + return singletonMap(configArg.getPrefKey(), String.format("%d", b & 0xff)); + case TIME_FORMAT: + switch(b) { + case 0x00: + return singletonMap(configArg.getPrefKey(), DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_24H); + case 0x01: + return singletonMap(configArg.getPrefKey(), DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_12H); + } + break; + case DND_MODE: + switch(b) { + case 0x00: + return singletonMap(configArg.getPrefKey(), DoNotDisturb.OFF.name().toLowerCase(Locale.ROOT)); + case 0x01: + return singletonMap(configArg.getPrefKey(), DoNotDisturb.SCHEDULED.name().toLowerCase(Locale.ROOT)); + case 0x02: + return singletonMap(configArg.getPrefKey(), DoNotDisturb.AUTOMATIC.name().toLowerCase(Locale.ROOT)); + case 0x03: + return singletonMap(configArg.getPrefKey(), DoNotDisturb.ALWAYS.name().toLowerCase(Locale.ROOT)); + } + break; + case TEMPERATURE_UNIT: + // TODO: This should be per device... + //switch(b) { + // case 0x00: + // return singletonMap(SettingsActivity.PREF_MEASUREMENT_SYSTEM, METRIC); + // case 0x01: + // return singletonMap(SettingsActivity.PREF_MEASUREMENT_SYSTEM, IMPERIAL); + //} + break; + case NIGHT_MODE_MODE: + switch(b) { + case 0x00: + return singletonMap(configArg.getPrefKey(), MiBandConst.PREF_NIGHT_MODE_OFF); + case 0x01: + return singletonMap(configArg.getPrefKey(), MiBandConst.PREF_NIGHT_MODE_SUNSET); + case 0x02: + return singletonMap(configArg.getPrefKey(), MiBandConst.PREF_NIGHT_MODE_SCHEDULED); + } + break; + default: + break; + } + + LOG.warn("Unhandled byte pref {}", configArg); + return null; + } + + private static Map singletonMap(final String key, final Object value) { + if (key == null && BuildConfig.DEBUG) { + // Crash + throw new IllegalStateException("Null key in prefs update"); + } + + return Collections.singletonMap(key, value); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021FirmwareInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021FirmwareInfo.java new file mode 100644 index 000000000..6104a40ef --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021FirmwareInfo.java @@ -0,0 +1,265 @@ +/* Copyright (C) 2022 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.huami; + +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + + +public abstract class Huami2021FirmwareInfo extends AbstractHuamiFirmwareInfo { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021FirmwareInfo.class); + + public static final byte[] ZIP_HEADER = new byte[]{ + 0x50, 0x4B, 0x03, 0x04 + }; + + public static final byte[] FW_HEADER = new byte[]{ + 0x51, 0x71 + }; + + public Huami2021FirmwareInfo(final byte[] bytes) { + super(bytes); + } + + public abstract String deviceName(); + + @Override + protected HuamiFirmwareType determineFirmwareType(final byte[] bytes) { + if (ArrayUtils.equals(bytes, UIHHContainer.UIHH_HEADER, 0)) { + final UIHHContainer uihh = UIHHContainer.fromRawBytes(bytes); + if (uihh == null) { + LOG.warn("Invalid UIHH file"); + return HuamiFirmwareType.INVALID; + } + + UIHHContainer.FileEntry uihhFirmwareZipFile = null; + boolean hasChangelog = false; + for (final UIHHContainer.FileEntry file : uihh.getFiles()) { + switch(file.getType()) { + case FIRMWARE_ZIP: + uihhFirmwareZipFile = file; + continue; + case FIRMWARE_CHANGELOG: + hasChangelog = true; + continue; + default: + LOG.warn("Unexpected file for {}", file.getType()); + } + } + + if (uihhFirmwareZipFile != null && hasChangelog) { + final byte[] firmwareBin = getFileFromZip(uihhFirmwareZipFile.getContent(), "META/firmware.bin"); + + if (isCompatibleFirmwareBin(firmwareBin)) { + // TODO: Firmware upgrades are untested, so they are disabled + return HuamiFirmwareType.INVALID; + //return HuamiFirmwareType.FIRMWARE_UIHH_2021_ZIP_WITH_CHANGELOG; + } + } + + return HuamiFirmwareType.INVALID; + } + + if (!ArrayUtils.equals(bytes, ZIP_HEADER, 0)) { + return HuamiFirmwareType.INVALID; + } + + final byte[] firmwareBin = getFileFromZip(bytes, "META/firmware.bin"); + if (isCompatibleFirmwareBin(firmwareBin)) { + // TODO: Firmware upgrades are untested, so they are disabled + return HuamiFirmwareType.INVALID; + //return HuamiFirmwareType.FIRMWARE; + } + + final String appType = getAppType(); + if ("watchface".equals(appType)) { + return HuamiFirmwareType.WATCHFACE; + } + + return HuamiFirmwareType.INVALID; + } + + @Override + public String toVersion(int crc16) { + final String crcMapVersion = getCrcMap().get(crc16); + if (crcMapVersion != null) { + return crcMapVersion; + } + + switch (firmwareType) { + case FIRMWARE_UIHH_2021_ZIP_WITH_CHANGELOG: + final UIHHContainer uihh = UIHHContainer.fromRawBytes(getBytes()); + if (uihh == null) { + return null; + } + return getFirmwareVersion(uihh.getFile(UIHHContainer.FileType.FIRMWARE_ZIP)); + case FIRMWARE: + return getFirmwareVersion(getBytes()); + case WATCHFACE: + final String appName = getAppName(); + if (appName == null) { + return "(unknown watchface)"; + } + return String.format("%s (watchface)", appName); + } + + return null; + } + + private boolean isCompatibleFirmwareBin(final byte[] firmwareBin) { + if (firmwareBin == null) { + return false; + } + + if (!ArrayUtils.equals(firmwareBin, FW_HEADER, 0)) { + LOG.warn("Unexpected firmware header: {}", GB.hexdump(Arrays.copyOfRange(firmwareBin, 0, FW_HEADER.length + 1))); + return false; + } + + // On the MB7, this only works for firmwares > 1.8.5.1, not for any older firmware + if (!searchString32BitAligned(firmwareBin, deviceName())) { + LOG.warn("Failed to find {} in fwBytes", deviceName()); + return false; + } + + return true; + } + + public String getFirmwareVersion(final byte[] fwbytes) { + final byte[] firmwareBin = getFileFromZip(fwbytes, "META/firmware.bin"); + + if (firmwareBin == null) { + LOG.warn("Failed to read firmware.bin"); + return null; + } + + int startIdx = 10; + int endIdx = -1; + + for (int i = startIdx; i < startIdx + 20; i++) { + byte c = firmwareBin[i]; + + if (c == 0) { + endIdx = i; + break; + } + + if (c != '.' && (c < '0' || c > '9')) { + // not a valid version character + break; + } + } + + if (endIdx == -1) { + LOG.warn("Failed to find firmware version in expected offset"); + return null; + } + + return new String(Arrays.copyOfRange(firmwareBin, startIdx, endIdx)); + } + + public String getAppName() { + final byte[] appJsonBin = getFileFromZip(getBytes(), "app.json"); + if (appJsonBin == null) { + LOG.warn("Failed to get app.json from zip"); + return null; + } + + try { + final String appJsonString = new String(appJsonBin, StandardCharsets.UTF_8) + // Remove UTF-8 BOM if present + .replace("\uFEFF", ""); + final JSONObject jsonObject = new JSONObject(appJsonString); + // TODO check i18n section? + // TODO Show preview icon? + final String appName = jsonObject.getJSONObject("app").getString("appName"); + + return String.format("%s (watchface)", appName); + } catch (final Exception e) { + LOG.error("Failed to parse app.json", e); + } + + return null; + } + + public String getAppType() { + final byte[] appJsonBin = getFileFromZip(getBytes(), "app.json"); + if (appJsonBin == null) { + LOG.warn("Failed to get app.json from zip"); + return null; + } + + try { + final String appJsonString = new String(appJsonBin, StandardCharsets.UTF_8) + // Remove UTF-8 BOM if present + .replace("\uFEFF", ""); + final JSONObject jsonObject = new JSONObject(appJsonString); + return jsonObject.getJSONObject("app").getString("appType"); + } catch (final Exception e) { + LOG.error("Failed to parse app.json", e); + } + + return null; + } + + public static byte[] getFileFromZip(final byte[] zipBytes, final String path) { + try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipBytes))) { + ZipEntry zipEntry; + while ((zipEntry = zipInputStream.getNextEntry()) != null) { + if (zipEntry.getName().equals(path)) { + return readAllBytes(zipInputStream); + } + } + } catch (final IOException e) { + LOG.error(String.format("Failed to read %s from zip", path), e); + return null; + } + + LOG.debug("{} not found in zip", path); + + return null; + } + + public static byte[] readAllBytes(final InputStream is) throws IOException { + final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + int n; + byte[] buf = new byte[16384]; + + while ((n = is.read(buf, 0, buf.length)) != -1) { + buffer.write(buf, 0, n); + } + + return buffer.toByteArray(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Handler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Handler.java new file mode 100644 index 000000000..bd0b2af00 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Handler.java @@ -0,0 +1,21 @@ +/* Copyright (C) 2022 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.huami; + +public interface Huami2021Handler { + void handle2021Payload(int type, byte[] payload); +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021MenuType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021MenuType.java new file mode 100644 index 000000000..7b79e2e11 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021MenuType.java @@ -0,0 +1,73 @@ +/* Copyright (C) 2022 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.huami; + +import java.util.HashMap; +import java.util.Map; + +public class Huami2021MenuType { + /** + * These somewhat match the ones in {@link HuamiMenuType}, but not all. The band sends and + * receives those as 8-digit upper case hex strings. + */ + public static final Map displayItemIdLookup = new HashMap() {{ + put("personal_activity_intelligence", 0x01); + put("hr", 0x02); + put("workout", 0x03); + put("weather", 0x04); + put("alarm", 0x09); + put("worldclock", 0x1A); + put("music", 0x0B); + put("stopwatch", 0x0C); + put("countdown", 0x0D); + put("findphone", 0x0E); + put("mutephone", 0x0F); + put("settings", 0x13); + put("workout_history", 0x14); + put("eventreminder", 0x15); + put("pai", 0x19); + put("takephoto", 0x0A); + put("stress", 0x1C); + put("female_health", 0x1D); + put("workout_status", 0x1E); + put("sleep", 0x23); + put("spo2", 0x24); + put("events", 0x26); + put("breathing", 0x33); + put("pomodoro", 0x38); + put("flashlight", 0x0102); + }}; + + public static final Map shortcutsIdLookup = new HashMap() {{ + put("hr", 0x01); + put("workout", 0x0A); + put("workout_status", 0x0C); + put("weather", 0x02); + put("worldclock", 0x1A); + put("alarm", 0x16); + put("music", 0x04); + put("activity", 0x20); + put("eventreminder", 0x21); + put("female_health", 0x11); + put("pai", 0x03); + put("stress", 0x0F); + put("sleep", 0x05); + put("spo2", 0x13); + put("events", 0x18); + put("breathing", 0x12); + }}; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Support.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Support.java new file mode 100644 index 000000000..d39c5525d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Support.java @@ -0,0 +1,2926 @@ +/* Copyright (C) 2022 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.huami; + +import static org.apache.commons.lang3.ArrayUtils.subarray; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_LANGUAGE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_LANGUAGE_AUTO; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_TIMEFORMAT; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_AUTO; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.*; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.SUCCESS; +import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_NAME; +import static nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions.fromUint16; +import static nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions.fromUint8; +import static nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions.mapTimeZone; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.ALWAYS_ON_DISPLAY_MODE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.ALWAYS_ON_DISPLAY_SCHEDULED_END; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.ALWAYS_ON_DISPLAY_SCHEDULED_START; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.BLUETOOTH_CONNECTED_ADVERTISING; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.DATE_FORMAT; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.DISPLAY_CALLER; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.DND_MODE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.DND_SCHEDULED_END; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.DND_SCHEDULED_START; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.FITNESS_GOAL_NOTIFICATION; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.FITNESS_GOAL_STEPS; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.HEART_RATE_ALL_DAY_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.HEART_RATE_HIGH_ALERTS; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.HEART_RATE_LOW_ALERTS; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.INACTIVITY_WARNINGS_DND_ENABLED; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.INACTIVITY_WARNINGS_DND_SCHEDULED_END; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.INACTIVITY_WARNINGS_DND_SCHEDULED_START; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.INACTIVITY_WARNINGS_ENABLED; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.INACTIVITY_WARNINGS_SCHEDULED_END; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.INACTIVITY_WARNINGS_SCHEDULED_START; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.LANGUAGE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.LANGUAGE_FOLLOW_PHONE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.LIFT_WRIST_MODE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.LIFT_WRIST_RESPONSE_SENSITIVITY; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.LIFT_WRIST_SCHEDULED_END; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.LIFT_WRIST_SCHEDULED_START; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.NIGHT_MODE_MODE; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.NIGHT_MODE_SCHEDULED_END; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.NIGHT_MODE_SCHEDULED_START; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.PASSWORD_ENABLED; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.PASSWORD_TEXT; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.SCREEN_BRIGHTNESS; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.SCREEN_ON_ON_NOTIFICATIONS; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.SCREEN_TIMEOUT; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.SLEEP_BREATHING_QUALITY_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.SLEEP_HIGH_ACCURACY_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.SPO2_ALL_DAY_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.SPO2_LOW_ALERT; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.STRESS_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.STRESS_RELAXATION_REMINDER; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.TEMPERATURE_UNIT; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.THIRD_PARTY_HR_SHARING; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigArg.TIME_FORMAT; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigSetter; +import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Config.ConfigType; + +import android.Manifest; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.location.Location; +import android.net.Uri; +import android.widget.Toast; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.apache.commons.lang3.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Queue; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.ActivateDisplayOnLift; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.ActivateDisplayOnLiftSensitivity; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.AlwaysOnDisplay; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband3.MiBand3Coordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.DoNotDisturb; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.miband.VibrationProfile; +import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; +import nodomain.freeyourgadget.gadgetbridge.model.Alarm; +import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec; +import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; +import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; +import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; +import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; +import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; +import nodomain.freeyourgadget.gadgetbridge.model.Reminder; +import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WorldClock; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.FetchActivityOperation; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.FetchSportsSummaryOperation; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.HuamiFetchDebugLogsOperation; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.UpdateFirmwareOperation; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.UpdateFirmwareOperation2020; +import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; +import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; +import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue; +import nodomain.freeyourgadget.gadgetbridge.util.MapUtils; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public abstract class Huami2021Support extends HuamiSupport { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021Support.class); + + // Keep track of Notification ID -> action handle, as BangleJSDeviceSupport. + // This needs to be simplified. + private final LimitedQueue mNotificationReplyAction = new LimitedQueue(16); + + // Tracks whether realtime HR monitoring is already started, so we can just + // send CONTINUE commands + private boolean heartRateRealtimeStarted; + + public Huami2021Support() { + this(LOG); + } + + public Huami2021Support(final Logger logger) { + super(logger); + } + + @Override + protected byte getAuthFlags() { + return 0x00; + } + + @Override + public byte getCryptFlags() { + return (byte) 0x80; + } + + @Override + public void onTestNewFunction() { + try { + final TransactionBuilder builder = performInitialized("test"); + findBandOneShot(builder); + builder.queue(getQueue()); + } catch (final Exception e) { + LOG.error("Failed to test new function", e); + } + } + + @Override + protected void acknowledgeFindPhone() { + LOG.info("Acknowledging find phone"); + + final byte[] cmd = new byte[]{FIND_PHONE_ACK, SUCCESS}; + + writeToChunked2021("ack find phone", CHUNKED2021_ENDPOINT_FIND_DEVICE, cmd, true); + } + + protected void findBandOneShot(final TransactionBuilder builder) { + LOG.info("Sending one-shot find band"); + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_FIND_DEVICE, new byte[]{FIND_BAND_ONESHOT}, true); + } + + @Override + public void onFindDevice(final boolean start) { + // FIXME: This does not work while band is in DND (#752) + final CallSpec callSpec = new CallSpec(); + callSpec.command = start ? CallSpec.CALL_INCOMING : CallSpec.CALL_END; + callSpec.name = "Gadgetbridge"; + onSetCallState(callSpec); + } + + @Override + public void onPhoneFound() { + LOG.info("Sending phone found"); + + final byte[] cmd = new byte[]{FIND_PHONE_STOP_FROM_PHONE}; + + writeToChunked2021("found phone", CHUNKED2021_ENDPOINT_FIND_DEVICE, cmd, true); + } + + @Override + public void onSetHeartRateMeasurementInterval(final int seconds) { + try { + int minuteInterval; + if (seconds == -1) { + // Smart + minuteInterval = -1; + } else { + minuteInterval = seconds / 60; + minuteInterval = Math.min(minuteInterval, 120); + minuteInterval = Math.max(0, minuteInterval); + } + + final TransactionBuilder builder = performInitialized(String.format("set heart rate interval to: %d minutes", minuteInterval)); + setHeartrateMeasurementInterval(builder, minuteInterval); + builder.queue(getQueue()); + } catch (final IOException e) { + GB.toast(getContext(), "Error toggling heart measurement interval: " + e.getLocalizedMessage(), Toast.LENGTH_LONG, GB.ERROR); + } + } + + @Override + protected Huami2021Support sendCalendarEvents(final TransactionBuilder builder) { + // We have native calendar sync + return this; + } + + protected void requestCalendarEvents() { + LOG.info("Requesting calendar events from band"); + + writeToChunked2021( + "request calendar events", + CHUNKED2021_ENDPOINT_CALENDAR, + new byte[]{CALENDAR_CMD_EVENTS_REQUEST, 0x00, 0x00}, + false + ); + } + + @Override + public void onAddCalendarEvent(final CalendarEventSpec calendarEventSpec) { + if (calendarEventSpec.type != CalendarEventSpec.TYPE_UNKNOWN) { + LOG.warn("Unsupported calendar event type {}", calendarEventSpec.type); + return; + } + + LOG.info("Sending calendar event {} to band", calendarEventSpec.id); + + int length = 34; + if (calendarEventSpec.title != null) { + length += calendarEventSpec.title.length(); + } + if (calendarEventSpec.description != null) { + length += calendarEventSpec.description.length(); + } + + final ByteBuffer buf = ByteBuffer.allocate(length); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(CALENDAR_CMD_CREATE_EVENT); + buf.putInt((int) calendarEventSpec.id); + + if (calendarEventSpec.title != null) { + buf.put(calendarEventSpec.title.getBytes(StandardCharsets.UTF_8)); + } + buf.put((byte) 0x00); + + if (calendarEventSpec.description != null) { + buf.put(calendarEventSpec.description.getBytes(StandardCharsets.UTF_8)); + } + buf.put((byte) 0x00); + + buf.putInt(calendarEventSpec.timestamp); + buf.putInt(calendarEventSpec.timestamp + calendarEventSpec.durationInSeconds); + + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0xff); // ? + buf.put((byte) 0xff); // ? + buf.put((byte) 0xff); // ? + buf.put((byte) 0xff); // ? + buf.put(bool(calendarEventSpec.allDay)); + buf.put((byte) 0x00); // ? + buf.put((byte) 130); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + + writeToChunked2021("delete calendar event", CHUNKED2021_ENDPOINT_CALENDAR, buf.array(), false); + } + + @Override + public void onDeleteCalendarEvent(final byte type, final long id) { + if (type != CalendarEventSpec.TYPE_UNKNOWN) { + LOG.warn("Unsupported calendar event type {}", type); + return; + } + + LOG.info("Deleting calendar event {} from band", id); + + final ByteBuffer buf = ByteBuffer.allocate(5); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(CALENDAR_CMD_DELETE_EVENT); + buf.putInt((int) id); + + writeToChunked2021("delete calendar event", CHUNKED2021_ENDPOINT_CALENDAR, buf.array(), false); + } + + @Override + public void onFetchRecordedData(final int dataTypes) { + try { + // FIXME: currently only one data type supported, these are meant to be flags + switch (dataTypes) { + case RecordedDataTypes.TYPE_ACTIVITY: + new FetchActivityOperation(this).perform(); + break; + case RecordedDataTypes.TYPE_GPS_TRACKS: + new FetchSportsSummaryOperation(this).perform(); + break; + case RecordedDataTypes.TYPE_DEBUGLOGS: + new HuamiFetchDebugLogsOperation(this).perform(); + break; + default: + LOG.warn("fetching multiple data types at once is not supported yet"); + } + } catch (final Exception e) { + LOG.error("Unable to fetch recorded data types {}", dataTypes, e); + } + } + + @Override + public void onHeartRateTest() { + // TODO onHeartRateTest - what modes? this only works sometimes + + try { + final TransactionBuilder builder = performInitialized("HeartRateTest"); + enableNotifyHeartRateMeasurements(true, builder); + //writeToChunked2021(builder, CHUNKED2021_ENDPOINT_HEARTRATE, new byte[]{HEART_RATE_CMD_REALTIME_SET, HEART_RATE_REALTIME_MODE_START}, false); + builder.queue(getQueue()); + } catch (final IOException e) { + LOG.error("Unable to read heart rate from Huami 2021 device", e); + } + } + + @Override + public void onEnableRealtimeHeartRateMeasurement(final boolean enable) { + final byte hrcmd; + if (!enable) { + hrcmd = HEART_RATE_REALTIME_MODE_STOP; + } else if (heartRateRealtimeStarted == enable) { + hrcmd = HEART_RATE_REALTIME_MODE_CONTINUE; + } else { + // enable == true, for the first time + hrcmd = HEART_RATE_REALTIME_MODE_START; + } + + heartRateRealtimeStarted = enable; + + try { + final TransactionBuilder builder = performInitialized("Set realtime heart rate measurement = " + enable); + enableNotifyHeartRateMeasurements(enable, builder); + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_HEARTRATE, new byte[]{HEART_RATE_CMD_REALTIME_SET, hrcmd}, false); + builder.queue(getQueue()); + enableRealtimeSamplesTimer(enable); + } catch (final IOException e) { + LOG.error("Unable to set realtime heart rate measurement", e); + } + } + + @Override + protected Huami2021Support requestBatteryInfo(TransactionBuilder builder) { + LOG.debug("Requesting Battery Info"); + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_BATTERY, new byte[]{BATTERY_REQUEST}, false); + + return this; + } + + @Override + protected Huami2021Support setFitnessGoal(final TransactionBuilder builder) { + final int fitnessGoal = GBApplication.getPrefs().getInt(ActivityUser.PREF_USER_STEPS_GOAL, ActivityUser.defaultUserStepsGoal); + LOG.info("Setting Fitness Goal to {}", fitnessGoal); + + new ConfigSetter(ConfigType.HEALTH) + .setInt(FITNESS_GOAL_STEPS, fitnessGoal) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setUserInfo(final TransactionBuilder builder) { + LOG.info("Attempting to set user info..."); + + final Prefs prefs = GBApplication.getPrefs(); + final String alias = prefs.getString(PREF_USER_NAME, null); + final ActivityUser activityUser = new ActivityUser(); + final int height = activityUser.getHeightCm(); + final int weight = activityUser.getWeightKg(); + final int birthYear = activityUser.getYearOfBirth(); + final byte birthMonth = 7; // not in user attributes + final byte birthDay = 1; // not in user attributes + + if (alias == null || weight == 0 || height == 0 || birthYear == 0) { + LOG.warn("Unable to set user info, make sure it is set up"); + return this; + } + + byte genderByte = 2; // other + switch (activityUser.getGender()) { + case ActivityUser.GENDER_MALE: + genderByte = 0; + break; + case ActivityUser.GENDER_FEMALE: + genderByte = 1; + } + final int userid = alias.hashCode(); + + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + baos.write(new byte[]{0x01, 0x4f, 0x07, 0x00, 0x00}); + baos.write(fromUint16(birthYear)); + baos.write(birthMonth); + baos.write(birthDay); + baos.write(genderByte); + baos.write((byte) height); + baos.write((byte) 0); // TODO ? + baos.write(fromUint16(weight * 200)); + baos.write(BLETypeConversions.fromUint32(userid)); + baos.write(new byte[]{0x00, 0x00, 0x00, 0x00, 0x75, 0x6e, 0x6b, 0x6e, 0x6f, 0x77, 0x6e, 0x00, 0x09}); // TODO ? + baos.write(alias.getBytes(StandardCharsets.UTF_8)); + baos.write((byte) 0); + + writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_USER_INFO, baos.toByteArray(), true); + } catch (final Exception e) { + LOG.error("Failed to send user info", e); + } + + return this; + } + + @Override + protected Huami2021Support setWearLocation(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("Function not implemented"); + return this; + } + + @Override + protected Huami2021Support setPassword(final TransactionBuilder builder) { + final boolean passwordEnabled = HuamiCoordinator.getPasswordEnabled(gbDevice.getAddress()); + final String password = HuamiCoordinator.getPassword(gbDevice.getAddress()); + + LOG.info("Setting password: {}, {}", passwordEnabled, password); + + if (password == null || password.isEmpty()) { + LOG.warn("Invalid password: {}", password); + return this; + } + + new ConfigSetter(ConfigType.LOCKSCREEN) + .setBoolean(PASSWORD_ENABLED, passwordEnabled) + .setString(PASSWORD_TEXT, password) + .write(this, builder); + + return this; + } + + @Override + protected void queueAlarm(final Alarm alarm, final TransactionBuilder builder) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); + + final Calendar calendar = AlarmUtils.toCalendar(alarm); + + final byte[] alarmMessage; + if (!alarm.getUnused()) { + int alarmFlags = 0; + if (alarm.getEnabled()) { + alarmFlags = ALARM_FLAG_ENABLED; + } + if (coordinator.supportsSmartWakeup(gbDevice) && alarm.getSmartWakeup()) { + alarmFlags |= ALARM_FLAG_SMART; + } + alarmMessage = new byte[]{ + ALARMS_CMD_CREATE, + (byte) 0x01, // ? + (byte) alarmFlags, + (byte) alarm.getPosition(), + (byte) calendar.get(Calendar.HOUR_OF_DAY), + (byte) calendar.get(Calendar.MINUTE), + (byte) alarm.getRepetition(), + (byte) 0x00, // ? + (byte) 0x00, // ? + (byte) 0x00, // ? + (byte) 0x00, // ?, this is usually 0 in the create command, 1 in the watch response + (byte) 0x00, // ? + }; + } else { + // Delete it from the band + alarmMessage = new byte[]{ + ALARMS_CMD_DELETE, + (byte) 0x01, // ? + (byte) alarm.getPosition() + }; + } + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_ALARMS, alarmMessage, false); + } + + @Override + public void onSetCallState(final CallSpec callSpec) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + final TransactionBuilder builder = performInitialized("send notification"); + + baos.write(NOTIFICATION_CMD_SEND); + + // ID + baos.write(BLETypeConversions.fromUint32(0)); + + baos.write(NOTIFICATION_TYPE_CALL); + if (callSpec.command == CallSpec.CALL_INCOMING) { + baos.write(NOTIFICATION_CALL_STATE_START); + } else if ((callSpec.command == CallSpec.CALL_START) || (callSpec.command == CallSpec.CALL_END)) { + baos.write(NOTIFICATION_CALL_STATE_END); + } + + baos.write(0x00); // ? + if (callSpec.name != null) { + baos.write(callSpec.name.getBytes(StandardCharsets.UTF_8)); + } + baos.write(0x00); + + baos.write(0x00); // ? + baos.write(0x00); // ? + + if (callSpec.number != null) { + baos.write(callSpec.number.getBytes(StandardCharsets.UTF_8)); + } + baos.write(0x00); + + // TODO put this behind a setting? + baos.write(callSpec.number != null ? 0x01 : 0x00); // reply from watch + + writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_NOTIFICATIONS, baos.toByteArray(), true); + builder.queue(getQueue()); + } catch (final Exception e) { + LOG.error("Failed to send call", e); + } + } + + @Override + public void onNotification(final NotificationSpec notificationSpec) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + final String senderOrTitle = StringUtils.getFirstOf(notificationSpec.sender, notificationSpec.title); + + // TODO Check real limit for notificationMaxLength / respect across all fields + + try { + final TransactionBuilder builder = performInitialized("send notification"); + + baos.write(NOTIFICATION_CMD_SEND); + baos.write(BLETypeConversions.fromUint32(notificationSpec.getId())); + if (notificationSpec.type == NotificationType.GENERIC_SMS) { + baos.write(NOTIFICATION_TYPE_SMS); + } else { + baos.write(NOTIFICATION_TYPE_NORMAL); + } + baos.write(NOTIFICATION_SUBCMD_SHOW); + + // app package + if (notificationSpec.sourceAppId != null) { + baos.write(notificationSpec.sourceAppId.getBytes(StandardCharsets.UTF_8)); + } + baos.write(0); + + // sender/title + if (!senderOrTitle.isEmpty()) { + baos.write(senderOrTitle.getBytes(StandardCharsets.UTF_8)); + } + baos.write(0); + + // body + if (notificationSpec.body != null) { + baos.write(StringUtils.truncate(notificationSpec.body, notificationMaxLength()).getBytes(StandardCharsets.UTF_8)); + } + baos.write(0); + + // app name + if (notificationSpec.sourceName != null) { + baos.write(notificationSpec.sourceName.getBytes(StandardCharsets.UTF_8)); + } + baos.write(0); + + // reply + boolean hasReply = false; + if (notificationSpec.attachedActions != null && notificationSpec.attachedActions.size() > 0) { + for (int i = 0; i < notificationSpec.attachedActions.size(); i++) { + final NotificationSpec.Action action = notificationSpec.attachedActions.get(i); + + switch (action.type) { + case NotificationSpec.Action.TYPE_WEARABLE_REPLY: + case NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR: + hasReply = true; + mNotificationReplyAction.add(notificationSpec.getId(), ((long) notificationSpec.getId() << 4) + i + 1); + break; + default: + break; + } + } + } + + baos.write((byte) (hasReply ? 1 : 0)); + + writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_NOTIFICATIONS, baos.toByteArray(), true); + builder.queue(getQueue()); + } catch (final Exception e) { + LOG.error("Failed to send notification", e); + } + + } + + @Override + protected int notificationMaxLength() { + return 512; + } + + protected Huami2021Support requestReminders(final TransactionBuilder builder) { + LOG.info("Requesting reminders"); + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_REMINDERS, new byte[]{REMINDERS_CMD_REQUEST}, false); + + return this; + } + + @Override + protected void sendReminderToDevice(final TransactionBuilder builder, int position, final Reminder reminder) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); + if (position + 1 > coordinator.getReminderSlotCount()) { + LOG.error("Reminder for position {} is over the limit of {} reminders", position, coordinator.getReminderSlotCount()); + return; + } + + if (reminder == null) { + // Delete reminder + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_REMINDERS, new byte[]{REMINDERS_CMD_DELETE, (byte) (position & 0xFF)}, false); + + return; + } + + final ByteBuffer buf = ByteBuffer.allocate(1 + 10 + reminder.getMessage().getBytes().length + 1); + buf.order(ByteOrder.LITTLE_ENDIAN); + + // Update does an upsert, so let's use it. If we call create twice on the same ID, it becomes weird + buf.put(REMINDERS_CMD_UPDATE); + buf.put((byte) (position & 0xFF)); + + final Calendar cal = Calendar.getInstance(); + cal.setTime(reminder.getDate()); + + int reminderFlags = REMINDER_FLAG_ENABLED | REMINDER_FLAG_TEXT; + + switch (reminder.getRepetition()) { + case Reminder.ONCE: + // Default is once, nothing to do + break; + case Reminder.EVERY_DAY: + reminderFlags |= 0x0fe0; // all week day bits set + break; + case Reminder.EVERY_WEEK: + int dayOfWeek = BLETypeConversions.dayOfWeekToRawBytes(cal) - 1; // Monday = 0 + reminderFlags |= 0x20 << dayOfWeek; + break; + case Reminder.EVERY_MONTH: + reminderFlags |= REMINDER_FLAG_REPEAT_MONTH; + break; + case Reminder.EVERY_YEAR: + reminderFlags |= REMINDER_FLAG_REPEAT_YEAR; + break; + default: + LOG.warn("Unknown repetition for reminder in position {}, defaulting to once", position); + } + + buf.putInt(reminderFlags); + + buf.putInt((int) (cal.getTimeInMillis() / 1000L)); + buf.put((byte) 0x00); + + if (reminder.getMessage().getBytes().length > coordinator.getMaximumReminderMessageLength()) { + LOG.warn("The reminder message length {} is longer than {}, will be truncated", + reminder.getMessage().getBytes().length, + coordinator.getMaximumReminderMessageLength() + ); + buf.put(Arrays.copyOf(reminder.getMessage().getBytes(), coordinator.getMaximumReminderMessageLength())); + } else { + buf.put(reminder.getMessage().getBytes()); + } + buf.put((byte) 0x00); + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_REMINDERS, buf.array(), false); + } + + @Override + protected void sendWorldClocks(final TransactionBuilder builder, + final List clocks) { + // TODO not yet supported by the official app, but menu option shows up on the band + } + + @Override + public void onDeleteNotification(final int id) { + LOG.info("Deleting notification {} from band", id); + + final ByteBuffer buf = ByteBuffer.allocate(12); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(NOTIFICATION_CMD_SEND); + buf.putInt(id); + buf.put(NOTIFICATION_TYPE_NORMAL); + buf.put(NOTIFICATION_SUBCMD_DISMISS_FROM_PHONE); + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + + writeToChunked2021("delete notification", CHUNKED2021_ENDPOINT_NOTIFICATIONS, buf.array(), true); + } + + @Override + protected void sendPhoneGps(final HuamiPhoneGpsStatus status, final Location location) { + final byte[] locationBytes = encodePhoneGpsPayload(status, location); + + final ByteBuffer buf = ByteBuffer.allocate(2 + locationBytes.length); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(WORKOUT_CMD_GPS_LOCATION); + buf.put((byte) 0x00); // ? + buf.put(locationBytes); + + writeToChunked2021("send phone gps", CHUNKED2021_ENDPOINT_WORKOUT, buf.array(), true); + } + + @Override + public void onSetCannedMessages(final CannedMessagesSpec cannedMessagesSpec) { + if (cannedMessagesSpec.type != CannedMessagesSpec.TYPE_GENERIC) { + LOG.warn("Got unsupported canned messages type: {}", cannedMessagesSpec.type); + return; + } + + try { + final TransactionBuilder builder = performInitialized("set canned messages"); + + for (int i = 0; i < 16; i++) { + LOG.debug("Deleting canned message {}", i); + final ByteBuffer buf = ByteBuffer.allocate(5); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(CANNED_MESSAGES_CMD_DELETE); + buf.putInt(i); + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_CANNED_MESSAGES, buf.array(), false); + } + + int i = 0; + for (String cannedMessage : cannedMessagesSpec.cannedMessages) { + cannedMessage = StringUtils.truncate(cannedMessage, 140); + LOG.debug("Setting canned message {} = '{}'", i, cannedMessage); + + final int length = cannedMessage.getBytes().length + 7; + final ByteBuffer buf = ByteBuffer.allocate(length); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(CANNED_MESSAGES_CMD_SET); + buf.putInt(i++); + buf.put((byte) cannedMessage.length()); + buf.put((byte) 0x00); + buf.put(cannedMessage.getBytes()); + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_CANNED_MESSAGES, buf.array(), false); + } + builder.queue(getQueue()); + } catch (IOException ex) { + LOG.error("Unable to set canned messages on Huami device", ex); + } + } + + protected void requestCannedMessages(final TransactionBuilder builder) { + LOG.info("Requesting canned messages"); + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_CANNED_MESSAGES, new byte[]{CANNED_MESSAGES_CMD_REQUEST}, false); + } + + protected void requestCannedMessages() { + try { + final TransactionBuilder builder = performInitialized("request canned messages"); + requestCannedMessages(builder); + builder.queue(getQueue()); + } catch (final Exception e) { + LOG.error("Failed to request canned messages", e); + } + } + + @Override + public void onSetPhoneVolume(final float volume) { + // FIXME: we need to send the music info and state as well, or it breaks the info + sendMusicStateToDevice(bufferMusicSpec, bufferMusicStateSpec); + } + + protected void sendMusicStateToDevice(final MusicSpec musicSpec, + final MusicStateSpec musicStateSpec) { + byte[] cmd = ArrayUtils.addAll(new byte[]{MUSIC_CMD_MEDIA_INFO}, encodeMusicState(musicSpec, musicStateSpec, true)); + + LOG.info("sendMusicStateToDevice: {}, {}", musicSpec, musicStateSpec); + + writeToChunked2021("send playback info", CHUNKED2021_ENDPOINT_MUSIC, cmd, false); + } + + @Override + public void onEnableRealtimeSteps(final boolean enable) { + final byte[] cmd = {STEPS_CMD_ENABLE_REALTIME, bool(enable)}; + + writeToChunked2021("toggle realtime steps", CHUNKED2021_ENDPOINT_STEPS, cmd, false); + } + + @Override + protected Huami2021Support setHeartrateSleepSupport(final TransactionBuilder builder) { + final boolean enableHrSleepSupport = MiBandCoordinator.getHeartrateSleepSupport(gbDevice.getAddress()); + + new ConfigSetter(ConfigType.HEALTH) + .setBoolean(SLEEP_HIGH_ACCURACY_MONITORING, enableHrSleepSupport) + .write(this, builder); + + return this; + } + + @Override + public Huami2021Support setCurrentTimeWithService(TransactionBuilder builder) { + // It seems that the format sent to the Current Time characteristic changed in newer devices + // to kind-of match the GATT spec, but it doesn't quite respect it? + // - 11 bytes get sent instead of 10 (extra byte at the end for the offset in quarter-hours?) + // - Day of week starts at 0 + // Otherwise, the command gets rejected with an "Out of Range" error and init fails. + + final Calendar timestamp = Calendar.getInstance(); + final byte[] year = fromUint16(timestamp.get(Calendar.YEAR)); + + final byte[] cmd = { + year[0], + year[1], + fromUint8(timestamp.get(Calendar.MONTH) + 1), + fromUint8(timestamp.get(Calendar.DATE)), + fromUint8(timestamp.get(Calendar.HOUR_OF_DAY)), + fromUint8(timestamp.get(Calendar.MINUTE)), + fromUint8(timestamp.get(Calendar.SECOND)), + fromUint8(timestamp.get(Calendar.DAY_OF_WEEK) - 1), + 0x00, // Fractions256? + 0x08, // Reason for change? + mapTimeZone(timestamp, BLETypeConversions.TZ_FLAG_INCLUDE_DST_IN_TZ), // TODO: Confirm this + }; + + builder.write(getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_CURRENT_TIME), cmd); + + return this; + } + + @Override + public Huami2021Support enableFurtherNotifications(final TransactionBuilder builder, + final boolean enable) { + builder.notify(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ), enable); + + return this; + } + + @Override + protected Huami2021Support setHeartrateActivityMonitoring(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("setHeartrateActivityMonitoring not implemented"); + return null; + } + + @Override + protected Huami2021Support setHeartrateAlert(final TransactionBuilder builder) { + final int hrAlertThresholdHigh = HuamiCoordinator.getHeartrateAlertHighThreshold(gbDevice.getAddress()); + final int hrAlertThresholdLow = HuamiCoordinator.getHeartrateAlertLowThreshold(gbDevice.getAddress()); + + LOG.info("Setting heart rate alert thresholds to {}, {}", hrAlertThresholdHigh, hrAlertThresholdLow); + + new ConfigSetter(ConfigType.HEALTH) + .setByte(HEART_RATE_HIGH_ALERTS, (byte) hrAlertThresholdHigh) + .setByte(HEART_RATE_LOW_ALERTS, (byte) hrAlertThresholdLow) + .write(this, builder); + + return null; + } + + @Override + protected HuamiSupport setHeartrateSleepBreathingQualityMonitoring(TransactionBuilder builder) { + final boolean enable = HuamiCoordinator.getHeartrateSleepBreathingQualityMonitoring(gbDevice.getAddress()); + LOG.info("Setting stress relaxation reminder to {}", enable); + + new ConfigSetter(ConfigType.HEALTH) + .setBoolean(SLEEP_BREATHING_QUALITY_MONITORING, enable) + .write(this, builder); + + return this; + } + + @Override + protected HuamiSupport setSPO2AllDayMonitoring(TransactionBuilder builder) { + final boolean enable = HuamiCoordinator.getSPO2AllDayMonitoring(gbDevice.getAddress()); + LOG.info("Setting SPO2 All-day monitoring to {}", enable); + + new ConfigSetter(ConfigType.HEALTH) + .setBoolean(SPO2_ALL_DAY_MONITORING, enable) + .write(this, builder); + + return this; + } + + @Override + protected HuamiSupport setSPO2AlertThreshold(TransactionBuilder builder) { + final int spo2threshold = HuamiCoordinator.getSPO2AlertThreshold(gbDevice.getAddress()); + LOG.info("Setting SPO2 alert threshold to {}", spo2threshold); + + new ConfigSetter(ConfigType.HEALTH) + .setByte(SPO2_LOW_ALERT, (byte) spo2threshold) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setHeartrateStressMonitoring(final TransactionBuilder builder) { + final boolean enableHrStressMonitoring = HuamiCoordinator.getHeartrateStressMonitoring(gbDevice.getAddress()); + LOG.info("Setting heart rate stress monitoring to {}", enableHrStressMonitoring); + + new ConfigSetter(ConfigType.HEALTH) + .setBoolean(STRESS_MONITORING, enableHrStressMonitoring) + .write(this, builder); + + return this; + } + + @Override + protected HuamiSupport setHeartrateStressRelaxationReminder(TransactionBuilder builder) { + final boolean enable = HuamiCoordinator.getHeartrateStressRelaxationReminder(gbDevice.getAddress()); + LOG.info("Setting stress relaxation reminder to {}", enable); + + new ConfigSetter(ConfigType.HEALTH) + .setBoolean(STRESS_RELAXATION_REMINDER, enable) + .write(this, builder); + + return this; + } + + @Override + protected HuamiSupport setHeartrateMeasurementInterval(TransactionBuilder builder, int minutes) { + new ConfigSetter(ConfigType.HEALTH) + .setByte(HEART_RATE_ALL_DAY_MONITORING, (byte) minutes) + .write(this, builder); + + return this; + } + + @Override + public Huami2021Support sendFactoryReset(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("sendFactoryReset not implemented"); + return null; + } + + @Override + protected void setVibrationPattern(final TransactionBuilder builder, + final HuamiVibrationPatternNotificationType notificationType, + final boolean test, + final VibrationProfile profile) { + final int MAX_TOTAL_LENGTH_MS = 10_000; // 10 seconds, about as long as Mi Fit allows + + // The on-off sequence, until the max total length is reached + final List onOff = truncateVibrationsOnOff(profile, MAX_TOTAL_LENGTH_MS); + + final ByteBuffer buf = ByteBuffer.allocate(5 + 2 * onOff.size()); + buf.order(ByteOrder.LITTLE_ENDIAN); + + buf.put(VIBRATION_PATTERN_SET); + buf.put(notificationType.getCode()); + buf.put((byte) 0x01); + buf.put((byte) (test ? 1 : 0)); + buf.put((byte) (onOff.size() / 2)); + + for (Short time : onOff) { + buf.putShort(time); + } + + writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS, buf.array(), true); + } + + @Override + public void onSendWeather(final WeatherSpec weatherSpec) { + // Weather is not sent directly to the bands, they send HTTP requests for each location. + // When we have a weather update, set the default location to that location on the band. + // TODO: Support for multiple weather locations + + final String locationKey = "1.234,-5.678,xiaomi_accu:" + System.currentTimeMillis(); // dummy + final String locationName = weatherSpec.location; + + try { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + baos.write((byte) 0x09); + baos.write((byte) 0x02); // ? 2 for current, 4 for default + baos.write((byte) 0x00); // ? + baos.write((byte) 0x00); // ? + baos.write((byte) 0x00); // ? + baos.write(locationKey.getBytes(StandardCharsets.UTF_8)); + baos.write((byte) 0x00); // ? + baos.write(locationName.getBytes(StandardCharsets.UTF_8)); + baos.write((byte) 0x00); // ? + + final TransactionBuilder builder = performInitialized("set weather location"); + writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_WEATHER, baos.toByteArray(), false); + builder.queue(getQueue()); + } catch (final Exception e) { + LOG.error("Failed to set weather location", e); + } + } + + @Override + protected Huami2021Support setDateDisplay(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("Request GPS version not implemented"); + return this; + } + + @Override + protected Huami2021Support setDateFormat(final TransactionBuilder builder) { + final String dateFormat = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()).getString("dateformat", "MM/dd/yyyy"); + if (dateFormat == null) { + return this; + } + + switch (dateFormat) { + case "YYYY/MM/DD": + case "yyyy/mm/dd": + case "YYYY.MM.DD": + case "yyyy.mm.dd": + case "MM/DD/YYYY": + case "MM.DD.YYYY": + case "mm/dd/yyyy": + case "mm.dd.yyyy": + case "DD/MM/YYYY": + case "DD.MM.YYYY": + case "dd/mm/yyyy": + case "dd.mm.yyyy": + break; + default: + LOG.warn("unsupported date format " + dateFormat); + return this; + } + + new ConfigSetter(ConfigType.SYSTEM) + .setString(DATE_FORMAT, dateFormat.replace("/", ".").toLowerCase(Locale.ROOT)) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setTimeFormat(final TransactionBuilder builder) { + final GBPrefs gbPrefs = new GBPrefs(new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()))); + final String timeFormat = gbPrefs.getTimeFormat(); + + // FIXME: This "works", but the band does not update when the setting changes, so we don't do anything + if (true) { + LOG.warn("setDateTime is disabled"); + return this; + } + + LOG.info("Setting time format to {}", timeFormat); + + final byte timeFormatByte; + if (timeFormat.equals("24h")) { + timeFormatByte = 0x01; + } else { + timeFormatByte = 0x00; + } + + new ConfigSetter(ConfigType.SYSTEM) + .setByte(TIME_FORMAT, timeFormatByte) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setGoalNotification(final TransactionBuilder builder) { + final boolean enable = HuamiCoordinator.getGoalNotification(gbDevice.getAddress()); + LOG.info("Setting goal notification to {}", enable); + + // TODO confirm this works + + new ConfigSetter(ConfigType.HEALTH) + .setBoolean(FITNESS_GOAL_NOTIFICATION, enable) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setAlwaysOnDisplay(final TransactionBuilder builder) { + final AlwaysOnDisplay alwaysOnDisplay = HuamiCoordinator.getAlwaysOnDisplay(gbDevice.getAddress()); + LOG.info("Setting always on display mode {}", alwaysOnDisplay); + + final byte aodByte; + switch (alwaysOnDisplay) { + case AUTO: + aodByte = 0x01; + break; + case SCHEDULED: + aodByte = 0x02; + break; + case ALWAYS: + aodByte = 0x03; + break; + case OFF: + default: + aodByte = 0x00; + break; + } + + final Date start = HuamiCoordinator.getAlwaysOnDisplayStart(gbDevice.getAddress()); + final Date end = HuamiCoordinator.getAlwaysOnDisplayEnd(gbDevice.getAddress()); + + new ConfigSetter(ConfigType.DISPLAY) + .setByte(ALWAYS_ON_DISPLAY_MODE, aodByte) + .setHourMinute(ALWAYS_ON_DISPLAY_SCHEDULED_START, start) + .setHourMinute(ALWAYS_ON_DISPLAY_SCHEDULED_END, end) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setActivateDisplayOnLiftWrist(final TransactionBuilder builder) { + final ActivateDisplayOnLift displayOnLift = HuamiCoordinator.getActivateDisplayOnLiftWrist(getContext(), gbDevice.getAddress()); + LOG.info("Setting activate display on lift wrist to {}", displayOnLift); + + final byte liftWristByte; + switch (displayOnLift) { + case SCHEDULED: + liftWristByte = 0x01; + break; + case ON: + liftWristByte = 0x02; + break; + case OFF: + default: + liftWristByte = 0x00; + break; + } + + final Date start = HuamiCoordinator.getDisplayOnLiftStart(gbDevice.getAddress()); + final Date end = HuamiCoordinator.getDisplayOnLiftEnd(gbDevice.getAddress()); + + new ConfigSetter(ConfigType.DISPLAY) + .setByte(LIFT_WRIST_MODE, liftWristByte) + .setHourMinute(LIFT_WRIST_SCHEDULED_START, start) + .setHourMinute(LIFT_WRIST_SCHEDULED_END, end) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setActivateDisplayOnLiftWristSensitivity(final TransactionBuilder builder) { + final ActivateDisplayOnLiftSensitivity sensitivity = HuamiCoordinator.getDisplayOnLiftSensitivity(gbDevice.getAddress()); + LOG.info("Setting activate display on lift wrist sensitivity to {}", sensitivity); + + final byte sensitivityByte; + switch (sensitivity) { + case SENSITIVE: + sensitivityByte = 0x01; + break; + case NORMAL: + default: + sensitivityByte = 0x00; + break; + } + + new ConfigSetter(ConfigType.DISPLAY) + .setByte(LIFT_WRIST_RESPONSE_SENSITIVITY, sensitivityByte) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setDisplayItems(final TransactionBuilder builder) { + setDisplayItems2021(builder, false, getAllDisplayItems(), getDefaultDisplayItems()); + return this; + } + + @Override + protected Huami2021Support setShortcuts(final TransactionBuilder builder) { + setDisplayItems2021(builder, true, getAllShortcutItems(), getDefaultShortcutItems()); + return this; + } + + /** + * Get the array of all possible display items. + */ + protected int getAllDisplayItems() { + return 0; + } + + /** + * Get the array of default display items. + */ + protected int getDefaultDisplayItems() { + return 0; + } + + /** + * Get the array of all possible shortcuts. + */ + protected int getAllShortcutItems() { + return 0; + } + + /** + * Get the array of default shortcuts. + */ + protected int getDefaultShortcutItems() { + return 0; + } + + private Huami2021Support setDisplayItems2021(final TransactionBuilder builder, + final boolean isShortcuts, final int allSettings, final int defaultSettings) { + if (allSettings == 0) { + LOG.warn("List of all display items is missing"); + return this; + } + + final SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()); + final String pages; + final byte menuType; + final Map idLookup; + + if (isShortcuts) { + menuType = Huami2021Service.DISPLAY_ITEMS_SHORTCUTS; + pages = prefs.getString(HuamiConst.PREF_SHORTCUTS_SORTABLE, null); + idLookup = Huami2021MenuType.shortcutsIdLookup; + LOG.info("Setting shortcuts"); + } else { + menuType = Huami2021Service.DISPLAY_ITEMS_MENU; + pages = prefs.getString(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE, null); + idLookup = Huami2021MenuType.displayItemIdLookup; + LOG.info("Setting menu items"); + } + + final List allSettingsList = new ArrayList<>(Arrays.asList(getContext().getResources().getStringArray(allSettings))); + List enabledList; + if (pages != null) { + enabledList = new ArrayList<>(Arrays.asList(pages.split(","))); + } else if (defaultSettings != 0) { + enabledList = new ArrayList<>(Arrays.asList(getContext().getResources().getStringArray(defaultSettings))); + } else { + enabledList = new ArrayList<>(); + } + + // Remove unknown items, so that we can configure even if some are unknown + for (int i = 0; i < allSettingsList.size(); i++) { + final String key = allSettingsList.get(i); + if (!idLookup.containsKey(key) && !key.equals("more")) { + LOG.warn("Unknown display item {}, ignoring", key); + allSettingsList.remove(i--); + } + } + + for (int i = 0; i < enabledList.size(); i++) { + final String key = enabledList.get(i); + if (!idLookup.containsKey(key) && !key.equals("more")) { + LOG.warn("Unknown display item {}, ignoring", key); + enabledList.remove(i--); + } + } + + if (!isShortcuts && !enabledList.contains("settings")) { + // Settings can't be disabled + enabledList.add("settings"); + } + + if (isShortcuts && enabledList.size() > 10) { + // Enforced by official app + LOG.warn("Truncating shortcuts list to 10"); + enabledList = enabledList.subList(0, 10); + } + + LOG.info("Setting display items (shortcuts={}): {}", isShortcuts, enabledList); + + int numItems = allSettingsList.size(); + if (!isShortcuts) { + // Exclude the "more" item from the main menu, since it's not a real item + numItems--; + } + + final ByteBuffer buf = ByteBuffer.allocate(4 + numItems * 12); + buf.order(ByteOrder.LITTLE_ENDIAN); + + buf.put((byte) 0x05); + buf.put(menuType); + buf.put((byte) numItems); + buf.put((byte) 0x00); + + byte pos = 0; + boolean inMoreSection = false; + + for (final String key : enabledList) { + if (key.equals("more")) { + inMoreSection = true; + pos = 0; + continue; + } + + final Integer id = idLookup.get(key); + if (id == null) { + LOG.error("Invalid id {} for {}", id, key); + return this; + } + + final byte sectionKey; + if (inMoreSection) { + // In more section + sectionKey = DISPLAY_ITEMS_SECTION_MORE; + } else { + // In main section + sectionKey = DISPLAY_ITEMS_SECTION_MAIN; + } + + // Screen IDs are sent as literal hex strings + buf.put(String.format("%08X", id).getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0); + buf.put(sectionKey); + buf.put(pos++); + buf.put((byte) (key.equals("settings") ? 1 : 0)); + } + + // Set all disabled items + pos = 0; + for (final String key : allSettingsList) { + if (enabledList.contains(key) || key.equals("more")) { + continue; + } + + final Integer id = idLookup.get(key); + if (id == null) { + LOG.error("Invalid id {} for {}", id, key); + return this; + } + + // Screen IDs are sent as literal hex strings + buf.put(String.format("%08X", id).getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0); + buf.put(DISPLAY_ITEMS_SECTION_DISABLED); + buf.put(pos++); + buf.put((byte) (key.equals("settings") ? 1 : 0)); + } + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_DISPLAY_ITEMS, buf.array(), true); + + return this; + } + + @Override + protected Huami2021Support setWorkoutActivityTypes(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("Function not implemented"); + return this; + } + + @Override + protected Huami2021Support setBeepSounds(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("Function not implemented"); + return this; + } + + @Override + protected Huami2021Support setRotateWristToSwitchInfo(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("Function not implemented"); + return this; + } + + @Override + protected Huami2021Support setDisplayCaller(final TransactionBuilder builder) { + // TODO: Make this configurable + + LOG.info("Enabling caller display"); + + new ConfigSetter(ConfigType.SYSTEM) + .setBoolean(DISPLAY_CALLER, true) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setDoNotDisturb(final TransactionBuilder builder) { + final DoNotDisturb doNotDisturb = HuamiCoordinator.getDoNotDisturb(gbDevice.getAddress()); + LOG.info("Setting do not disturb to {}", doNotDisturb); + + final byte dndByte; + switch (doNotDisturb) { + case SCHEDULED: + dndByte = 0x01; + break; + case AUTOMATIC: + dndByte = 0x02; + break; + case ALWAYS: + dndByte = 0x03; + break; + case OFF: + default: + dndByte = 0x00; + break; + } + + final Date start = HuamiCoordinator.getDoNotDisturbStart(gbDevice.getAddress()); + final Date end = HuamiCoordinator.getDoNotDisturbEnd(gbDevice.getAddress()); + + new ConfigSetter(ConfigType.SYSTEM) + .setByte(DND_MODE, dndByte) + .setHourMinute(DND_SCHEDULED_START, start) + .setHourMinute(DND_SCHEDULED_END, end) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setNightMode(final TransactionBuilder builder) { + final String nightMode = MiBand3Coordinator.getNightMode(gbDevice.getAddress()); + LOG.info("Setting night mode to {}", nightMode); + + final byte nightModeByte; + switch (nightMode) { + case MiBandConst.PREF_NIGHT_MODE_SUNSET: + nightModeByte = 0x01; + break; + case MiBandConst.PREF_NIGHT_MODE_SCHEDULED: + nightModeByte = 0x02; + break; + case MiBandConst.PREF_NIGHT_MODE_OFF: + default: + nightModeByte = 0x00; + } + + final Date start = MiBand3Coordinator.getNightModeStart(gbDevice.getAddress()); + final Date end = MiBand3Coordinator.getNightModeEnd(gbDevice.getAddress()); + + new ConfigSetter(ConfigType.SYSTEM) + .setByte(NIGHT_MODE_MODE, nightModeByte) + .setHourMinute(NIGHT_MODE_SCHEDULED_START, start) + .setHourMinute(NIGHT_MODE_SCHEDULED_END, end) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setInactivityWarnings(final TransactionBuilder builder) { + final boolean enable = HuamiCoordinator.getInactivityWarnings(gbDevice.getAddress()); + LOG.info("Setting inactivity warnings to {}", enable); + + final Date intervalStart = HuamiCoordinator.getInactivityWarningsStart(gbDevice.getAddress()); + final Date intervalEnd = HuamiCoordinator.getInactivityWarningsEnd(gbDevice.getAddress()); + boolean enableDnd = HuamiCoordinator.getInactivityWarningsDnd(gbDevice.getAddress()); + final Date dndStart = HuamiCoordinator.getInactivityWarningsDndStart(gbDevice.getAddress()); + final Date dndEnd = HuamiCoordinator.getInactivityWarningsDndEnd(gbDevice.getAddress()); + + new ConfigSetter(ConfigType.HEALTH) + .setBoolean(INACTIVITY_WARNINGS_ENABLED, enable) + .setHourMinute(INACTIVITY_WARNINGS_SCHEDULED_START, intervalStart) + .setHourMinute(INACTIVITY_WARNINGS_SCHEDULED_END, intervalEnd) + .setBoolean(INACTIVITY_WARNINGS_DND_ENABLED, enableDnd) + .setHourMinute(INACTIVITY_WARNINGS_DND_SCHEDULED_START, dndStart) + .setHourMinute(INACTIVITY_WARNINGS_DND_SCHEDULED_END, dndEnd) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setDisconnectNotification(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("Function not implemented"); + return this; + } + + @Override + protected Huami2021Support setDistanceUnit(final TransactionBuilder builder) { + final MiBandConst.DistanceUnit unit = HuamiCoordinator.getDistanceUnit(); + LOG.info("Setting distance unit to {}", unit); + + final byte unitByte; + switch (unit) { + case IMPERIAL: + unitByte = 0x01; + break; + case METRIC: + default: + unitByte = 0x00; + break; + } + + new ConfigSetter(ConfigType.SYSTEM) + .setByte(TEMPERATURE_UNIT, unitByte) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setBandScreenUnlock(final TransactionBuilder builder) { + // Supported by the Mi Band 7 through the band, but not configurable through the app + LOG.warn("Function not implemented"); + return this; + } + + @Override + protected Huami2021Support setScreenOnOnNotification(final TransactionBuilder builder) { + final boolean enable = HuamiCoordinator.getScreenOnOnNotification(gbDevice.getAddress()); + LOG.info("Set Screen On on notification = {}", enable); + + new ConfigSetter(ConfigType.DISPLAY) + .setBoolean(SCREEN_ON_ON_NOTIFICATIONS, enable) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setScreenBrightness(final TransactionBuilder builder) { + final int brightness = HuamiCoordinator.getScreenBrightness(gbDevice.getAddress()); + LOG.info("Setting band screen brightness to {}", brightness); + + new ConfigSetter(ConfigType.DISPLAY) + .setShort(SCREEN_BRIGHTNESS, (byte) brightness) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setScreenTimeout(final TransactionBuilder builder) { + final int timeout = HuamiCoordinator.getScreenTimeout(gbDevice.getAddress()); + LOG.info("Setting band screen timeout to {}", timeout); + + new ConfigSetter(ConfigType.DISPLAY) + .setByte(SCREEN_TIMEOUT, (byte) timeout) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setLanguage(final TransactionBuilder builder) { + final String localeString = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()) + .getString("language", "auto"); + + LOG.info("Setting device language to {}", localeString); + + new ConfigSetter(ConfigType.LANGUAGE) + .setByte(LANGUAGE, getLanguageId()) + .setBoolean(LANGUAGE_FOLLOW_PHONE, localeString.equals("auto")) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setExposeHRThirdParty(final TransactionBuilder builder) { + final boolean enable = HuamiCoordinator.getExposeHRThirdParty(gbDevice.getAddress()); + LOG.info("Setting exposure of HR to third party apps to {}", enable); + + new ConfigSetter(ConfigType.HEALTH) + .setBoolean(THIRD_PARTY_HR_SHARING, enable) + .write(this, builder); + + return this; + } + + @Override + protected Huami2021Support setBtConnectedAdvertising(final TransactionBuilder builder) { + final boolean enable = HuamiCoordinator.getBtConnectedAdvertising(gbDevice.getAddress()); + LOG.info("Setting connected advertisement to: {}", enable); + + new ConfigSetter(ConfigType.BLUETOOTH) + .setBoolean(BLUETOOTH_CONNECTED_ADVERTISING, enable) + .write(this, builder); + + return this; + } + + @Override + protected void writeToChunked(final TransactionBuilder builder, + final int type, + final byte[] data) { + LOG.warn("writeToChunked is not supported"); + } + + @Override + protected void writeToChunkedOld(final TransactionBuilder builder, final int type, final byte[] data) { + LOG.warn("writeToChunkedOld is not supported"); + } + + @Override + public void writeToConfiguration(final TransactionBuilder builder, final byte[] data) { + LOG.warn("writeToConfiguration is not supported"); + } + + @Override + protected Huami2021Support requestGPSVersion(final TransactionBuilder builder) { + // Not supported by the Mi Band 7 at least + LOG.warn("Request GPS version not implemented"); + return this; + } + + @Override + protected Huami2021Support requestAlarms(final TransactionBuilder builder) { + LOG.info("Requesting alarms"); + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_ALARMS, new byte[]{ALARMS_CMD_REQUEST}, false); + + return this; + } + + private void requestAlarms() { + try { + final TransactionBuilder builder = performInitialized("request alarms"); + requestAlarms(builder); + builder.queue(getQueue()); + } catch (final Exception e) { + LOG.error("Failed to request alarms", e); + } + } + + @Override + protected Huami2021Support requestDisplayItems(final TransactionBuilder builder) { + LOG.info("Requesting display items"); + + writeToChunked2021( + builder, + CHUNKED2021_ENDPOINT_DISPLAY_ITEMS, + new byte[]{DISPLAY_ITEMS_CMD_REQUEST, DISPLAY_ITEMS_MENU}, + true + ); + + return this; + } + + @Override + protected Huami2021Support requestShortcuts(final TransactionBuilder builder) { + LOG.info("Requesting shortcuts"); + + writeToChunked2021( + builder, + CHUNKED2021_ENDPOINT_DISPLAY_ITEMS, + new byte[]{DISPLAY_ITEMS_CMD_REQUEST, DISPLAY_ITEMS_SHORTCUTS}, + true + ); + + return this; + } + + @Override + public void phase2Initialize(final TransactionBuilder builder) { + LOG.info("2021 phase2Initialize..."); + requestBatteryInfo(builder); + } + + @Override + public void phase3Initialize(final TransactionBuilder builder) { + LOG.info("2021 phase3Initialize..."); + setUserInfo(builder); + + for (final ConfigType configType : ConfigType.values()) { + // FIXME: Request only supported args? + requestConfig(builder, configType); + } + + for (final HuamiVibrationPatternNotificationType type : HuamiVibrationPatternNotificationType.values()) { + // FIXME: Can we read these from the band? + final String typeKey = type.name().toLowerCase(Locale.ROOT); + setVibrationPattern(builder, HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX + typeKey); + } + + requestCannedMessages(builder); + requestDisplayItems(builder); + requestShortcuts(builder); + requestAlarms(builder); + //requestReminders(builder); + } + + @Override + public UpdateFirmwareOperation createUpdateFirmwareOperation(final Uri uri) { + return new UpdateFirmwareOperation2020(uri, this); + } + + @Override + public int getActivitySampleSize() { + return 8; + } + + @Override + public boolean force2021Protocol() { + return true; + } + + @Override + public void handle2021Payload(final int type, final byte[] payload) { + if (payload == null || payload.length == 0) { + LOG.warn("Empty or null payload for {}", String.format("0x%04x", type)); + return; + } + + LOG.debug("Got 2021 payload for {}: {}", String.format("0x%04x", type), GB.hexdump(payload)); + + switch (type) { + case CHUNKED2021_ENDPOINT_ALARMS: + handle2021Alarms(payload); + return; + case CHUNKED2021_ENDPOINT_AUTH: + LOG.warn("Unexpected auth payload {}", GB.hexdump(payload)); + return; + case CHUNKED2021_ENDPOINT_CALENDAR: + handle2021Calendar(payload); + return; + case CHUNKED2021_ENDPOINT_COMPAT: + LOG.warn("Unexpected compat payload {}", GB.hexdump(payload)); + return; + case CHUNKED2021_ENDPOINT_CONFIG: + handle2021Config(payload); + return; + case CHUNKED2021_ENDPOINT_ICONS: + handle2021Icons(payload); + return; + case CHUNKED2021_ENDPOINT_WORKOUT: + handle2021Workout(payload); + return; + case CHUNKED2021_ENDPOINT_DISPLAY_ITEMS: + handle2021DisplayItems(payload); + return; + case CHUNKED2021_ENDPOINT_FIND_DEVICE: + handle2021FindDevice(payload); + return; + case CHUNKED2021_ENDPOINT_HTTP: + handle2021Http(payload); + return; + case CHUNKED2021_ENDPOINT_HEARTRATE: + handle2021HeartRate(payload); + return; + case CHUNKED2021_ENDPOINT_NOTIFICATIONS: + handle2021Notifications(payload); + return; + case CHUNKED2021_ENDPOINT_REMINDERS: + handle2021Reminders(payload); + return; + case CHUNKED2021_ENDPOINT_CANNED_MESSAGES: + handle2021CannedMessages(payload); + return; + case CHUNKED2021_ENDPOINT_USER_INFO: + handle2021UserInfo(payload); + return; + case CHUNKED2021_ENDPOINT_STEPS: + handle2021Steps(payload); + return; + case CHUNKED2021_ENDPOINT_VIBRATION_PATTERNS: + handle2021VibrationPatterns(payload); + return; + case CHUNKED2021_ENDPOINT_BATTERY: + handle2021Battery(payload); + return; + case CHUNKED2021_ENDPOINT_SILENT_MODE: + handle2021SilentMode(payload); + return; + case CHUNKED2021_ENDPOINT_MUSIC: + handle2021Music(payload); + return; + default: + LOG.warn("Unhandled 2021 payload {}", String.format("0x%04x", type)); + } + } + + protected void handle2021Alarms(final byte[] payload) { + switch (payload[0]) { + case ALARMS_CMD_CREATE_ACK: + LOG.info("Alarm create ACK, status = {}", payload[1]); + return; + case ALARMS_CMD_DELETE_ACK: + LOG.info("Alarm delete ACK, status = {}", payload[1]); + return; + case ALARMS_CMD_UPDATE_ACK: + LOG.info("Alarm update ACK, status = {}", payload[1]); + return; + case ALARMS_CMD_NOTIFY_CHANGE: + LOG.info("Alarms changed on band"); + requestAlarms(); + return; + case ALARMS_CMD_RESPONSE: + LOG.info("Got alarms from band"); + decodeAndUpdateAlarms(payload); + return; + default: + LOG.warn("Unexpected alarms payload byte {}", String.format("0x%02x", payload[0])); + } + } + + private void decodeAndUpdateAlarms(final byte[] payload) { + final int numAlarms = payload[1]; + + if (payload.length != 2 + numAlarms * 10) { + LOG.warn("Unexpected payload length of {} for {} alarms", payload.length, numAlarms); + return; + } + + // Map of alarm position to Alarm, as returned by the band + final Map payloadAlarms = new HashMap<>(); + for (int i = 0; i < numAlarms; i++) { + final Alarm alarm = parseAlarm(payload, 2 + i * 10); + payloadAlarms.put(alarm.getPosition(), alarm); + } + + final List dbAlarms = DBHelper.getAlarms(gbDevice); + int numUpdatedAlarms = 0; + + for (nodomain.freeyourgadget.gadgetbridge.entities.Alarm alarm : dbAlarms) { + final int pos = alarm.getPosition(); + final Alarm updatedAlarm = payloadAlarms.get(pos); + final boolean alarmNeedsUpdate = updatedAlarm == null || + alarm.getUnused() != updatedAlarm.getUnused() || + alarm.getEnabled() != updatedAlarm.getEnabled() || + alarm.getSmartWakeup() != updatedAlarm.getSmartWakeup() || + alarm.getHour() != updatedAlarm.getHour() || + alarm.getMinute() != updatedAlarm.getMinute() || + alarm.getRepetition() != updatedAlarm.getRepetition(); + + if (alarmNeedsUpdate) { + numUpdatedAlarms++; + LOG.info("Updating alarm index={}, unused={}", pos, updatedAlarm == null); + alarm.setUnused(updatedAlarm == null); + if (updatedAlarm != null) { + alarm.setEnabled(updatedAlarm.getEnabled()); + alarm.setSmartWakeup(updatedAlarm.getSmartWakeup()); + alarm.setHour(updatedAlarm.getHour()); + alarm.setMinute(updatedAlarm.getMinute()); + alarm.setRepetition(updatedAlarm.getRepetition()); + } + DBHelper.store(alarm); + } + } + + if (numUpdatedAlarms > 0) { + final Intent intent = new Intent(DeviceService.ACTION_SAVE_ALARMS); + LocalBroadcastManager.getInstance(getContext()).sendBroadcast(intent); + } + } + + private Alarm parseAlarm(final byte[] payload, final int offset) { + final nodomain.freeyourgadget.gadgetbridge.entities.Alarm alarm = new nodomain.freeyourgadget.gadgetbridge.entities.Alarm(); + + alarm.setUnused(false); // If the band sent it, it's not unused + alarm.setPosition(payload[offset + ALARM_IDX_POSITION]); + alarm.setEnabled((payload[offset + ALARM_IDX_FLAGS] & ALARM_FLAG_ENABLED) > 0); + alarm.setSmartWakeup((payload[offset + ALARM_IDX_FLAGS] & ALARM_FLAG_SMART) > 0); + alarm.setHour(payload[offset + ALARM_IDX_HOUR]); + alarm.setMinute(payload[offset + ALARM_IDX_MINUTE]); + alarm.setRepetition(payload[offset + ALARM_IDX_REPETITION]); + + return alarm; + } + + protected void handle2021Calendar(final byte[] payload) { + switch (payload[0]) { + case CALENDAR_CMD_EVENTS_RESPONSE: + LOG.info("Got calendar events from band"); + decodeAndUpdateCalendarEvents(payload); + return; + case CALENDAR_CMD_CREATE_EVENT_ACK: + LOG.info("Calendar create event ACK, status = {}", payload[1]); + return; + case CALENDAR_CMD_DELETE_EVENT_ACK: + LOG.info("Calendar delete event ACK, status = {}", payload[1]); + return; + default: + LOG.warn("Unexpected calendar payload byte {}", String.format("0x%02x", payload[0])); + } + } + + private void decodeAndUpdateCalendarEvents(final byte[] payload) { + final int numEvents = payload[1]; + // FIXME there's a 0 after this, is it actually a 2-byte short? + + if (payload.length < 1 + numEvents * 34) { + LOG.warn("Unexpected payload length of {} for {} calendar events", payload.length, numEvents); + return; + } + + int i = 3; + while (i < payload.length) { + if (payload.length - i < 34) { + LOG.error("Not enough bytes remaining to parse a calendar event ({})", payload.length - i); + return; + } + + final int eventId = BLETypeConversions.toUint32(payload, i); + i += 4; + + final String title = StringUtils.untilNullTerminator(payload, i); + if (title == null) { + LOG.error("Failed to decode title"); + return; + } + i += title.length() + 1; + + final String description = StringUtils.untilNullTerminator(payload, i); + if (description == null) { + LOG.error("Failed to decode description"); + return; + } + i += description.length() + 1; + + final int startTime = BLETypeConversions.toUint32(payload, i); + i += 4; + + final int endTime = BLETypeConversions.toUint32(payload, i); + i += 4; + + // ? 00 00 00 00 00 00 00 00 ff ff ff ff + i += 12; + + boolean allDay = (payload[i] == 0x01); + i++; + + // ? 00 82 00 00 00 00 + i += 6; + + LOG.info("Calendar Event {}: {}", eventId, title); + } + + if (i != payload.length) { + LOG.error("Unexpected calendar events payload trailer, {} bytes were not consumed", payload.length - i); + return; + } + + // TODO update database? + } + + private void requestConfig(final TransactionBuilder builder, + final ConfigType config, + final List args) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + baos.write(CONFIG_CMD_REQUEST); + baos.write(args.isEmpty() ? CONFIG_REQUEST_TYPE_ALL : CONFIG_REQUEST_TYPE_SPECIFIC); + baos.write(config.getValue()); + baos.write(args.size()); + for (final Huami2021Config.ConfigArg arg : args) { + baos.write(arg.getCode()); + } + + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_CONFIG, baos.toByteArray(), true); + } + + private void requestConfig(final TransactionBuilder builder, final ConfigType config) { + requestConfig(builder, config, Huami2021Config.ConfigArg.getAllArgsForConfigType(config)); + } + + protected void handle2021Config(final byte[] payload) { + switch (payload[0]) { + case CONFIG_CMD_ACK: + LOG.info("Configuration ACK, status = {}", payload[1]); + return; + + case CONFIG_CMD_RESPONSE: + if (payload[1] != 1) { + LOG.warn("Configuration response not success: {}", payload[1]); + return; + } + + handle2021ConfigResponse(payload); + return; + default: + LOG.warn("Unexpected configuration payload byte {}", String.format("0x%02x", payload[0])); + } + } + + private void handle2021ConfigResponse(final byte[] payload) { + final ConfigType configType = ConfigType.fromValue(payload[2]); + if (configType == null) { + LOG.warn("Unknown config type {}", String.format("0x%02x", payload[2])); + return; + } + + int numConfigs = payload[5] & 0xff; + + LOG.info("Got {} configs for {}", numConfigs, configType); + + final Map prefs = new Huami2021Config.ConfigParser(configType) + .parse(numConfigs, subarray(payload, 6, payload.length)); + + if (prefs == null) { + return; + } + + final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences(prefs); + evaluateGBDeviceEvent(eventUpdatePreferences); + + if (isInitialized()) { + final TransactionBuilder builder; + boolean hasAutoConfigsToSend = false; + + try { + builder = performInitialized("set auto band configs"); + } catch (final Exception e) { + LOG.error("Failed to set auto band configs", e); + return; + } + + if (prefs.containsKey(PREF_LANGUAGE) && prefs.get(PREF_LANGUAGE).equals(PREF_LANGUAGE_AUTO)) { + // Band is reporting automatic language, we need to send the actual language + setLanguage(builder); + hasAutoConfigsToSend = true; + } + if (prefs.containsKey(PREF_TIMEFORMAT) && prefs.get(PREF_TIMEFORMAT).equals(PREF_TIMEFORMAT_AUTO)) { + // Band is reporting automatic time format, we need to send the actual time format + setTimeFormat(builder); + hasAutoConfigsToSend = true; + } + + if (hasAutoConfigsToSend) { + builder.queue(getQueue()); + } + } + } + + protected void handle2021Workout(final byte[] payload) { + switch (payload[0]) { + case WORKOUT_CMD_APP_OPEN: + final Huami2021WorkoutTrackActivityType activityType = Huami2021WorkoutTrackActivityType.fromCode(payload[3]); + final boolean workoutNeedsGps = (payload[2] == 1); + + if (activityType == null) { + LOG.warn("Unknown workout activity type {}", String.format("0x%x", payload[3])); + } + + LOG.info("Workout starting on band: {}, needs gps = {}", activityType, workoutNeedsGps); + + onWorkoutOpen(workoutNeedsGps); + return; + case WORKOUT_CMD_STATUS: + switch (payload[1]) { + case WORKOUT_STATUS_START: + LOG.info("Workout Start"); + onWorkoutStart(); + break; + case WORKOUT_STATUS_END: + LOG.info("Workout End"); + onWorkoutEnd(); + break; + default: + LOG.warn("Unexpected workout status {}", String.format("0x%02x", payload[1])); + break; + } + return; + default: + LOG.warn("Unexpected workout byte {}", String.format("0x%02x", payload[0])); + } + } + + protected void handle2021DisplayItems(final byte[] payload) { + switch (payload[0]) { + case DISPLAY_ITEMS_CMD_RESPONSE: + LOG.info("Got display items from band"); + decodeAndUpdateDisplayItems(payload); + break; + case DISPLAY_ITEMS_CMD_CREATE_ACK: + LOG.info("Display items set ACK, type = {}, status = {}", payload[1], payload[2]); + break; + default: + LOG.warn("Unexpected display items payload byte {}", String.format("0x%02x", payload[0])); + } + } + + private void decodeAndUpdateDisplayItems(final byte[] payload) { + final int numberScreens = payload[2]; + final int expectedLength = 4 + numberScreens * 12; + if (payload.length != 4 + numberScreens * 12) { + LOG.error("Unexpected display items payload length {}, expected {}", payload.length, expectedLength); + return; + } + + final Map idMap; + final String prefKey; + switch (payload[1]) { + case DISPLAY_ITEMS_MENU: + LOG.info("Got {} display items", numberScreens); + idMap = MapUtils.reverse(Huami2021MenuType.displayItemIdLookup); + prefKey = HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE; + break; + case DISPLAY_ITEMS_SHORTCUTS: + LOG.info("Got {} shortcuts", numberScreens); + idMap = MapUtils.reverse(Huami2021MenuType.shortcutsIdLookup); + prefKey = HuamiConst.PREF_SHORTCUTS_SORTABLE; + break; + default: + LOG.error("Unknown display items type {}", String.format("0x%x", payload[1])); + return; + } + + final String[] mainScreensArr = new String[numberScreens]; + final String[] moreScreensArr = new String[numberScreens]; + + for (int i = 0; i < numberScreens; i++) { + // Screen IDs are sent as literal hex strings + final Integer screenId = Integer.parseInt(new String(subarray(payload, 4 + i * 12, 4 + i * 12 + 8)), 16); + final String screenKey = idMap.get(screenId); + if (screenKey == null) { + LOG.warn("Unknown screen {}, ignoring", String.format("0x%08X", screenId)); + continue; + } + + final int screenSectionVal = payload[4 + i * 12 + 9]; + final int screenPosition = payload[4 + i * 12 + 10]; + + if (screenPosition >= numberScreens) { + LOG.warn("Invalid screen position {}, ignoring", screenPosition); + continue; + } + + switch (screenSectionVal) { + case DISPLAY_ITEMS_SECTION_MAIN: + if (mainScreensArr[screenPosition] != null) { + LOG.warn("Duplicate position {} for main section", screenPosition); + } + //LOG.debug("mainScreensArr[{}] = {}", screenPosition, screenKey); + mainScreensArr[screenPosition] = screenKey; + break; + case DISPLAY_ITEMS_SECTION_MORE: + if (moreScreensArr[screenPosition] != null) { + LOG.warn("Duplicate position {} for more section", screenPosition); + } + //LOG.debug("moreScreensArr[{}] = {}", screenPosition, screenKey); + moreScreensArr[screenPosition] = screenKey; + break; + case DISPLAY_ITEMS_SECTION_DISABLED: + // Ignore disabled screens + //LOG.debug("Ignoring disabled screen {} {}", screenPosition, screenKey); + break; + default: + LOG.warn("Unknown screen section {}, ignoring", String.format("0x%02x", screenSectionVal)); + } + } + + final List screens = new ArrayList<>(Arrays.asList(mainScreensArr)); + if (payload[1] == DISPLAY_ITEMS_MENU) { + screens.add("more"); + screens.addAll(Arrays.asList(moreScreensArr)); + } + screens.removeAll(Collections.singleton(null)); + + final String prefValue = StringUtils.join(",", screens.toArray(new String[0])).toString(); + final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences(prefKey, prefValue); + + evaluateGBDeviceEvent(eventUpdatePreferences); + } + + protected void handle2021FindDevice(final byte[] payload) { + final GBDeviceEventFindPhone findPhoneEvent = new GBDeviceEventFindPhone(); + + switch (payload[0]) { + case FIND_BAND_ACK: + LOG.info("Band acknowledged find band command"); + return; + case FIND_PHONE_START: + LOG.info("Find Phone Start"); + acknowledgeFindPhone(); // FIXME: premature + findPhoneEvent.event = GBDeviceEventFindPhone.Event.START; + evaluateGBDeviceEvent(findPhoneEvent); + break; + case FIND_PHONE_STOP_FROM_BAND: + LOG.info("Find Phone Stop"); + findPhoneEvent.event = GBDeviceEventFindPhone.Event.STOP; + evaluateGBDeviceEvent(findPhoneEvent); + break; + default: + LOG.warn("Unexpected find phone byte {}", String.format("0x%02x", payload[0])); + } + } + + protected void handle2021Http(final byte[] payload) { + switch (payload[0]) { + case HTTP_CMD_REQUEST: + int pos = 1; + final byte requestId = payload[pos++]; + final String method = StringUtils.untilNullTerminator(payload, pos); + if (method == null) { + LOG.error("Failed to decode method from payload"); + return; + } + pos += method.length() + 1; + final String url = StringUtils.untilNullTerminator(payload, pos); + if (url == null) { + LOG.error("Failed to decode method from payload"); + return; + } + // headers after pos += url.length() + 1; + + LOG.info("Got HTTP {} request: {}", method, url); + + handleUrlRequest(requestId, method, url); + return; + default: + LOG.warn("Unexpected HTTP payload byte {}", String.format("0x%02x", payload[0])); + } + } + + private void handleUrlRequest(final byte requestId, final String method, final String urlString) { + if (!"GET".equals(method)) { + LOG.error("Unable to handle HTTP method {}", method); + // TODO: There's probably a "BAD REQUEST" response or similar + replyHttpNoInternet(requestId); + return; + } + + final URL url; + try { + url = new URL(urlString); + } catch (final MalformedURLException e) { + LOG.error("Failed to parse url", e); + replyHttpNoInternet(requestId); + return; + } + + final String path = url.getPath(); + final Map query = urlQueryParameters(url); + + if (path.startsWith("/weather/")) { + final Huami2021Weather.Response response = Huami2021Weather.handleHttpRequest(path, query); + + if (response != null) { + replyHttpSuccess(requestId, response.toJson()); + return; + } + } + + LOG.error("Unhandled URL {}", url); + replyHttpNoInternet(requestId); + } + + private Map urlQueryParameters(final URL url) { + final Map queryParameters = new HashMap<>(); + final String[] pairs = url.getQuery().split("&"); + for (final String pair : pairs) { + final String[] parts = pair.split("=", 2); + try { + final String key = URLDecoder.decode(parts[0], "UTF-8"); + if (parts.length == 2) { + queryParameters.put(key, URLDecoder.decode(parts[1], "UTF-8")); + } else { + queryParameters.put(key, ""); + } + } catch (final Exception e) { + LOG.error("Failed to decode query", e); + } + } + return queryParameters; + } + + private void replyHttpNoInternet(final byte requestId) { + LOG.info("Replying with no internet to http request {}", requestId); + + final byte[] cmd = new byte[]{HTTP_CMD_RESPONSE, requestId, HTTP_RESPONSE_NO_INTERNET, 0x00, 0x00, 0x00, 0x00}; + + writeToChunked2021("http reply no internet", Huami2021Service.CHUNKED2021_ENDPOINT_HTTP, cmd, true); + } + + private void replyHttpSuccess(final byte requestId, final String content) { + LOG.debug("Replying with success to http request {} with {}", requestId, content); + + final byte[] contentBytes = content.getBytes(StandardCharsets.UTF_8); + final ByteBuffer buf = ByteBuffer.allocate(8 + contentBytes.length); + buf.order(ByteOrder.LITTLE_ENDIAN); + + buf.put((byte) 0x02); + buf.put(requestId); + buf.put(HTTP_RESPONSE_SUCCESS); + buf.put((byte) 0xc8); // ? + buf.putInt(contentBytes.length); + buf.put(contentBytes); + + writeToChunked2021("http reply success", Huami2021Service.CHUNKED2021_ENDPOINT_HTTP, buf.array(), true); + } + + protected void handle2021HeartRate(final byte[] payload) { + switch (payload[0]) { + case HEART_RATE_CMD_REALTIME_ACK: + // what does the status mean? Seems to be 0 on success + LOG.info("Band acknowledged heart rate command, status = {}", payload[1]); + return; + case HEART_RATE_CMD_SLEEP: + switch (payload[1]) { + case HEART_RATE_FALL_ASLEEP: + LOG.info("Fell asleep"); + processDeviceEvent(HuamiDeviceEvent.FELL_ASLEEP); + break; + case HEART_RATE_WAKE_UP: + LOG.info("Woke up"); + processDeviceEvent(HuamiDeviceEvent.WOKE_UP); + break; + default: + LOG.warn("Unexpected sleep byte {}", String.format("0x%02x", payload[1])); + break; + } + return; + default: + LOG.warn("Unexpected heart rate byte {}", String.format("0x%02x", payload[0])); + } + } + + protected void handle2021Notifications(final byte[] payload) { + final GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl(); + final GBDeviceEventCallControl deviceEvtCallControl = new GBDeviceEventCallControl(); + + switch (payload[0]) { + case NOTIFICATION_CMD_REPLY: + // TODO make this configurable? + final int notificationId = BLETypeConversions.toUint32(subarray(payload, 1, 5)); + final Long replyHandle = (Long) mNotificationReplyAction.lookup(notificationId); + if (replyHandle == null) { + LOG.warn("Failed to find reply handle for notification ID {}", notificationId); + return; + } + final String replyMessage = StringUtils.untilNullTerminator(payload, 5); + if (replyMessage == null) { + LOG.warn("Failed to parse reply message for notification ID {}", notificationId); + return; + } + + LOG.info("Got reply to notification {} with '{}'", notificationId, replyMessage); + + deviceEvtNotificationControl.handle = replyHandle; + deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY; + deviceEvtNotificationControl.reply = replyMessage; + evaluateGBDeviceEvent(deviceEvtNotificationControl); + + ackNotificationReply(notificationId); // FIXME: premature? + onDeleteNotification(notificationId); // FIXME: premature? + return; + case NOTIFICATION_CMD_DISMISS: + switch (payload[1]) { + case NOTIFICATION_DISMISS_NOTIFICATION: + // TODO make this configurable? + final int dismissNotificationId = BLETypeConversions.toUint32(subarray(payload, 2, 6)); + LOG.info("Dismiss notification {}", dismissNotificationId); + deviceEvtNotificationControl.handle = dismissNotificationId; + deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.DISMISS; + evaluateGBDeviceEvent(deviceEvtNotificationControl); + return; + case NOTIFICATION_DISMISS_MUTE_CALL: + LOG.info("Mute call"); + deviceEvtCallControl.event = GBDeviceEventCallControl.Event.IGNORE; + evaluateGBDeviceEvent(deviceEvtCallControl); + return; + case NOTIFICATION_DISMISS_REJECT_CALL: + LOG.info("Reject call"); + deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT; + evaluateGBDeviceEvent(deviceEvtCallControl); + return; + default: + LOG.warn("Unexpected notification dismiss byte {}", String.format("0x%02x", payload[1])); + return; + } + case NOTIFICATION_CMD_ICON_REQUEST: + final String packageName = StringUtils.untilNullTerminator(payload, 1); + if (packageName == null) { + LOG.error("Failed to decode package name from payload"); + return; + } + LOG.info("Got notification icon request for {}", packageName); + + final int expectedLength = packageName.length() + 7; + if (payload.length != expectedLength) { + LOG.error("Unexpected icon request payload length {}, expected {}", payload.length, expectedLength); + return; + } + int pos = 1 + packageName.length() + 1; + // payload[pos] = 0x08? + pos++; + int width = BLETypeConversions.toUint16(subarray(payload, pos, pos + 2)); + pos += 2; + int height = BLETypeConversions.toUint16(subarray(payload, pos, pos + 2)); + sendIconForPackage(packageName, width, height); + return; + default: + LOG.warn("Unexpected notification byte {}", String.format("0x%02x", payload[0])); + } + } + + private void ackNotificationReply(final int notificationId) { + final ByteBuffer buf = ByteBuffer.allocate(9); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(NOTIFICATION_CMD_REPLY_ACK); + buf.putInt(notificationId); + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + + writeToChunked2021("ack notification reply", CHUNKED2021_ENDPOINT_NOTIFICATIONS, buf.array(), true); + } + + // Queue of package names for which icons are being sent + // FIXME: This probably breaks if there's more than 1 + private final Queue queuedIcons = new LinkedList<>(); + // Map of package name to encoded TGA565 bytes + private final Map queuedIconBytes = new HashMap<>(); + + protected void handle2021Icons(final byte[] payload) { + switch (payload[0]) { + case ICONS_CMD_SEND_RESPONSE: + LOG.info("Band acknowledged icon send request: {}", GB.hexdump(payload)); + // FIXME: The bytes probably mean something.. + sendNextQueuedIconData(); + return; + case ICONS_CMD_DATA_ACK: + LOG.info("Band acknowledged icon icon data: {}", GB.hexdump(payload)); + // After the icon is sent to the band, we need to ACK it on the notifications + // FIXME: The bytes probably mean something.. + ackNotificationAfterIconSent(); + return; + default: + LOG.warn("Unexpected icons byte {}", String.format("0x%02x", payload[0])); + } + } + + private void sendNextQueuedIconData() { + final String packageName = queuedIcons.peek(); + if (packageName == null) { + LOG.error("No queued icon to send"); + return; + } + + final byte[] bytes = queuedIconBytes.get(packageName); + if (bytes == null) { + LOG.error("No icon bytes for {}", packageName); + return; + } + + LOG.info("Sending icon data for {}", packageName); + + // The band always sends a full 8192 chunk, with zeroes at the end if bytes < 8192 + final ByteBuffer buf = ByteBuffer.allocate(10 + bytes.length); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(ICONS_CMD_DATA_SEND); + buf.put((byte) 0x03); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x08); // ? + buf.put((byte) 0x17); // ? + buf.put(bytes); + + writeToChunked2021("send icon data", CHUNKED2021_ENDPOINT_ICONS, buf.array(), false); + } + + private void ackNotificationAfterIconSent() { + final String packageName = queuedIcons.poll(); + if (packageName == null) { + LOG.error("No queued icon to ack"); + return; + } + + LOG.info("Acknowledging icon send for {}", packageName); + + queuedIconBytes.remove(packageName); + + final ByteBuffer buf = ByteBuffer.allocate(1 + packageName.length() + 1 + 1); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(NOTIFICATION_CMD_ICON_REQUEST_ACK); + buf.put(packageName.getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0x00); + buf.put((byte) 0x01); + + writeToChunked2021("ack icon send", CHUNKED2021_ENDPOINT_NOTIFICATIONS, buf.array(), true); + } + + private void sendIconForPackage(final String packageName, final int width, final int height) { + final Drawable icon; + try { + icon = getContext().getPackageManager().getApplicationIcon(packageName); + } catch (final PackageManager.NameNotFoundException e) { + LOG.error("Failed to get icon for {}", packageName, e); + return; + } + + final Bitmap bmp = BitmapUtil.toBitmap(icon); + + // The TGA needs to have this ID, or the band does not accept it + final byte[] tgaId = new byte[46]; + System.arraycopy("SOMH6".getBytes(StandardCharsets.UTF_8), 0, tgaId, 0, 5); + + final byte[] tga565 = BitmapUtil.convertToTgaRGB565(bmp, width, height, tgaId); + + if (tga565.length > 8192) { + // FIXME: Pretty sure we can't send more than 8KB in a single request, + // but don't know how it's supposed to be encoded + LOG.error("TGA output is too large: {}", tga565.length); + return; + } + + final String format = "TGA_RGB565_DAVE2D"; + final String url = String.format( + Locale.ROOT, + "notification://logo?app_id=%s&width=%d&height=%d&format=%s", + packageName, + width, + height, + format + ); + final String filename = String.format("logo_%s.tga", packageName.replace(".", "_")); + + final ByteBuffer buf = ByteBuffer.allocate(2 + url.length() + 1 + filename.length() + 1 + 4 + 4); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put((byte) ICONS_CMD_SEND_REQUEST); + buf.put((byte) 0x00); + buf.put(url.getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0x00); + buf.put(filename.getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0x00); + buf.putInt(tga565.length); + buf.putInt(CheckSums.getCRC32(tga565)); + + LOG.info("Queueing icon for {}", packageName); + queuedIcons.add(packageName); + queuedIconBytes.put(packageName, tga565); + + writeToChunked2021("send icon send request", CHUNKED2021_ENDPOINT_ICONS, buf.array(), false); + } + + protected void handle2021Reminders(final byte[] payload) { + switch (payload[0]) { + case REMINDERS_CMD_CREATE_ACK: + LOG.info("Reminder create ACK, status = {}", payload[1]); + return; + case REMINDERS_CMD_DELETE_ACK: + LOG.info("Reminder delete ACK, status = {}", payload[1]); + // status 1 = success + // status 2 = reminder not found + return; + case REMINDERS_CMD_UPDATE_ACK: + LOG.info("Reminder update ACK, status = {}", payload[1]); + return; + case REMINDERS_CMD_RESPONSE: + LOG.info("Got reminders from band"); + decodeAndUpdateReminders(payload); + return; + default: + LOG.warn("Unexpected reminders payload byte {}", String.format("0x%02x", payload[0])); + return; + } + } + + private void decodeAndUpdateReminders(final byte[] payload) { + final int numReminders = payload[1]; + + if (payload.length < 3 + numReminders * 11) { + LOG.warn("Unexpected payload length of {} for {} reminders", payload.length, numReminders); + return; + } + + // Map of alarm position to Reminder, as returned by the band + final Map payloadReminders = new HashMap<>(); + + int i = 3; + while (i < payload.length) { + if (payload.length - i < 11) { + LOG.error("Not enough bytes remaining to parse a reminder ({})", payload.length - i); + return; + } + + final int reminderPosition = payload[i++] & 0xff; + final int reminderFlags = BLETypeConversions.toUint32(payload, i); + i += 4; + final int reminderTimestamp = BLETypeConversions.toUint32(payload, i); + i += 4; + i++; // 0 ? + final Date reminderDate = new Date(reminderTimestamp * 1000L); + final String reminderText = StringUtils.untilNullTerminator(payload, i); + if (reminderText == null) { + LOG.error("Failed to parse reminder text at pos {}", i); + return; + } + + i += reminderText.length() + 1; + + LOG.info("Reminder {}, {}, {}, {}", reminderPosition, String.format("0x%04x", reminderFlags), reminderDate, reminderText); + } + if (i != payload.length) { + LOG.error("Unexpected reminders payload trailer, {} bytes were not consumed", payload.length - i); + return; + } + + // TODO persist in database. Probably not trivial, because reminderPosition != reminderId + } + + protected void handle2021CannedMessages(final byte[] payload) { + switch (payload[0]) { + case CANNED_MESSAGES_CMD_RESPONSE: + LOG.info("Canned Messages response"); + decodeAndUpdateCannedMessagesResponse(payload); + return; + case CANNED_MESSAGES_CMD_SET_ACK: + LOG.info("Canned Message set ACK, status = {}", payload[1]); + return; + case CANNED_MESSAGES_CMD_DELETE_ACK: + LOG.info("Canned Message delete ACK, status = {}", payload[1]); + return; + case CANNED_MESSAGES_CMD_REPLY_SMS: + LOG.info("Canned Message SMS reply"); + handleCannedSmsReply(payload); + return; + case CANNED_MESSAGES_CMD_REPLY_SMS_CHECK: + LOG.info("Canned Message reply SMS check"); + final boolean canSendSms; + // TODO place this behind a setting as well? + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + canSendSms = getContext().checkSelfPermission(Manifest.permission.SEND_SMS) == PackageManager.PERMISSION_GRANTED; + } else { + canSendSms = true; + } + sendCannedSmsReplyAllow(canSendSms); + return; + default: + LOG.warn("Unexpected canned messages payload byte {}", String.format("0x%02x", payload[0])); + } + } + + private void sendCannedSmsReplyAllow(final boolean allowed) { + LOG.info("Sending SMS reply allowed = {}", allowed); + + writeToChunked2021( + "allow sms reply", + CHUNKED2021_ENDPOINT_CANNED_MESSAGES, + new byte[]{CANNED_MESSAGES_CMD_REPLY_SMS_ALLOW, bool(allowed)}, + false + ); + } + + private void handleCannedSmsReply(final byte[] payload) { + final String phoneNumber = StringUtils.untilNullTerminator(payload, 1); + if (phoneNumber == null || phoneNumber.isEmpty()) { + LOG.warn("No phone number for SMS reply"); + ackCannedSmsReply(false); + return; + } + + final int messageLength = payload[phoneNumber.length() + 6] & 0xff; + if (phoneNumber.length() + 8 + messageLength != payload.length) { + LOG.warn("Unexpected message or payload lengths ({} / {})", messageLength, payload.length); + ackCannedSmsReply(false); + return; + } + + final String message = new String(payload, phoneNumber.length() + 8, messageLength); + if (StringUtils.isNullOrEmpty(message)) { + LOG.warn("No message for SMS reply"); + ackCannedSmsReply(false); + return; + } + + LOG.debug("Sending SMS message '{}' to number '{}' and rejecting call", message, phoneNumber); + final GBDeviceEventNotificationControl devEvtNotificationControl = new GBDeviceEventNotificationControl(); + devEvtNotificationControl.handle = -1; + devEvtNotificationControl.phoneNumber = phoneNumber; + devEvtNotificationControl.reply = message; + devEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY; + evaluateGBDeviceEvent(devEvtNotificationControl); + + final GBDeviceEventCallControl rejectCallCmd = new GBDeviceEventCallControl(GBDeviceEventCallControl.Event.REJECT); + evaluateGBDeviceEvent(rejectCallCmd); + + ackCannedSmsReply(true); // FIXME probably premature + } + + private void ackCannedSmsReply(final boolean success) { + LOG.info("Acknowledging SMS reply, success = {}", success); + + writeToChunked2021( + "ack sms reply", + CHUNKED2021_ENDPOINT_CANNED_MESSAGES, + new byte[]{CANNED_MESSAGES_CMD_REPLY_SMS_ACK, bool(success)}, + false + ); + } + + private void decodeAndUpdateCannedMessagesResponse(final byte[] payload) { + final int numberMessages = payload[1] & 0xff; + + LOG.info("Got {} canned messages", numberMessages); + + final GBDeviceEventUpdatePreferences gbDeviceEventUpdatePreferences = new GBDeviceEventUpdatePreferences(); + final Map cannedMessages = new HashMap<>(); + + int pos = 3; + for (int i = 0; i < numberMessages; i++) { + if (pos + 4 >= payload.length) { + LOG.warn("Unexpected end of payload while parsing message {} at pos {}", i, pos); + return; + } + + final int messageId = BLETypeConversions.toUint32(subarray(payload, pos, pos + 4)); + final int messageLength = payload[pos + 4] & 0xff; + + if (pos + 6 + messageLength > payload.length) { + LOG.warn("Unexpected end of payload for message of length {} while parsing message {} at pos {}", messageLength, i, pos); + return; + } + + final String messageText = new String(subarray(payload, pos + 6, pos + 6 + messageLength)); + + LOG.debug("Canned message {}: {}", String.format("0x%x", messageId), messageText); + + final int cannedMessagePrefId = i + 1; + if (cannedMessagePrefId > 16) { + LOG.warn("Canned message ID {} is out of range", cannedMessagePrefId); + } else { + cannedMessages.put(cannedMessagePrefId, messageText); + } + + pos += messageLength + 6; + } + + for (int i = 1; i <= 16; i++) { + String message = cannedMessages.get(i); + if (StringUtils.isEmpty(message)) { + message = null; + } + + gbDeviceEventUpdatePreferences.withPreference("canned_reply_" + i, message); + } + + evaluateGBDeviceEvent(gbDeviceEventUpdatePreferences); + } + + protected void handle2021UserInfo(final byte[] payload) { + // TODO handle2021UserInfo + LOG.warn("Unexpected user info payload byte {}", String.format("0x%02x", payload[0])); + } + + protected void handle2021Steps(final byte[] payload) { + switch (payload[0]) { + case STEPS_CMD_REPLY: + LOG.info("Got steps reply, status = {}", payload[1]); + if (payload.length != 15) { + LOG.error("Unexpected steps reply payload length {}", payload.length); + return; + } + handleRealtimeSteps(subarray(payload, 2, 15)); + return; + case STEPS_CMD_ENABLE_REALTIME_ACK: + LOG.info("Band acknowledged realtime steps, status = {}, enabled = {}", payload[1], payload[2]); + return; + case STEPS_CMD_REALTIME_NOTIFICATION: + LOG.info("Got steps notification"); + if (payload.length != 14) { + LOG.error("Unexpected steps reply payload length {}", payload.length); + return; + } + handleRealtimeSteps(subarray(payload, 1, 14)); + return; + default: + LOG.warn("Unexpected steps payload byte {}", String.format("0x%02x", payload[0])); + } + } + + protected void handle2021VibrationPatterns(final byte[] payload) { + switch (payload[0]) { + case VIBRATION_PATTERN_ACK: + LOG.info("Vibration Patterns ACK, status = {}", payload[1]); + return; + default: + LOG.warn("Unexpected Vibration Patterns payload byte {}", String.format("0x%02x", payload[0])); + } + } + + protected void handle2021Battery(final byte[] payload) { + if (payload[0] != BATTERY_REPLY) { + LOG.warn("Unexpected battery payload byte {}", String.format("0x%02x", payload[0])); + return; + } + + if (payload.length != 21) { + LOG.warn("Unexpected battery payload length: {}", payload.length); + } + + final HuamiBatteryInfo batteryInfo = new HuamiBatteryInfo(subarray(payload, 1, payload.length)); + handleGBDeviceEvent(batteryInfo.toDeviceEvent()); + } + + protected void handle2021SilentMode(final byte[] payload) { + switch (payload[0]) { + case SILENT_MODE_CMD_NOTIFY_BAND_ACK: + LOG.info("Band acknowledged current phone silent mode, status = {}", payload[1]); + return; + case SILENT_MODE_CMD_QUERY: + LOG.info("Got silent mode query from band"); + // TODO sendCurrentSilentMode(); + return; + case SILENT_MODE_CMD_SET: + LOG.info("Band setting silent mode = {}", payload[1]); + // TODO ackSilentModeSet(); + // TODO setSilentMode(payload[1] == 0x01); + // TODO sendCurrentSilentMode(); + return; + default: + LOG.warn("Unexpected silent mode payload byte {}", String.format("0x%02x", payload[0])); + } + } + + protected void handle2021Music(final byte[] payload) { + switch (payload[0]) { + case MUSIC_CMD_APP_STATE: + switch (payload[1]) { + case MUSIC_APP_OPEN: + onMusicAppOpen(); + break; + case MUSIC_APP_CLOSE: + onMusicAppClosed(); + break; + default: + LOG.warn("Unexpected music app state {}", String.format("0x%02x", payload[1])); + break; + } + return; + + case MUSIC_CMD_BUTTON_PRESS: + LOG.info("Got music button press"); + final GBDeviceEventMusicControl deviceEventMusicControl = new GBDeviceEventMusicControl(); + switch (payload[1]) { + case MUSIC_BUTTON_PLAY: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PLAY; + break; + case MUSIC_BUTTON_PAUSE: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PAUSE; + break; + case MUSIC_BUTTON_NEXT: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.NEXT; + break; + case MUSIC_BUTTON_PREVIOUS: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.PREVIOUS; + break; + case MUSIC_BUTTON_VOLUME_UP: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEUP; + break; + case MUSIC_BUTTON_VOLUME_DOWN: + deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEDOWN; + break; + default: + LOG.warn("Unexpected music button {}", String.format("0x%02x", payload[1])); + return; + } + evaluateGBDeviceEvent(deviceEventMusicControl); + return; + default: + LOG.warn("Unexpected music byte {}", String.format("0x%02x", payload[0])); + } + } + + private byte bool(final boolean b) { + return (byte) (b ? 1 : 0); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Weather.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Weather.java new file mode 100644 index 000000000..54dabfe0e --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021Weather.java @@ -0,0 +1,326 @@ +/* Copyright (C) 2022 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.huami; + +import android.location.Location; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; + +import net.e175.klaus.solarpositioning.DeltaT; +import net.e175.klaus.solarpositioning.SPA; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.threeten.bp.LocalDate; +import org.threeten.bp.format.DateTimeFormatter; + +import java.lang.reflect.Type; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiWeatherConditions; +import nodomain.freeyourgadget.gadgetbridge.model.Weather; +import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.CurrentPosition; + +/** + * The weather models that the bands expect as an http response to weather requests. Base URL usually + * is https://api-mifit.huami.com. + */ +public class Huami2021Weather { + private static final Logger LOG = LoggerFactory.getLogger(Huami2021Weather.class); + + private static final Gson GSON = new GsonBuilder() + .serializeNulls() + .setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") // for pubTimes + .registerTypeAdapter(LocalDate.class, new LocalDateSerializer()) + .create(); + + public static Response handleHttpRequest(final String path, final Map query) { + final WeatherSpec weatherSpec = Weather.getInstance().getWeatherSpec(); + + if (weatherSpec == null) { + LOG.error("No weather in weather instance"); + return null; + } + + switch (path) { + case "/weather/v2/forecast": + final String daysStr = query.get("days"); + final int days; + if (daysStr != null) { + days = Integer.parseInt(daysStr); + } else { + days = 10; + } + return new ForecastResponse(weatherSpec, days); + case "/weather/index": + return new IndexResponse(weatherSpec); + case "/weather/current": + return new CurrentResponse(weatherSpec); + case "/weather/forecast/hourly": + return new HourlyResponse(); + case "/weather/alerts": + return new AlertsResponse(); + default: + LOG.error("Unknown weather path {}", path); + } + + return null; + } + + private static class RawJsonStringResponse extends Response { + private final String content; + + public RawJsonStringResponse(final String content) { + this.content = content; + } + + public String toJson() { + return content; + } + } + + public static abstract class Response { + public String toJson() { + return GSON.toJson(this); + } + } + + // /weather/v2/forecast + // + // locale=zh_CN + // deviceSource=11 + // days=10 + // isGlobal=true + // locationKey=00.000,-0.000,xiaomi_accu:000000 + public static class ForecastResponse extends Response { + public Date pubTime; + public List humidity = new ArrayList<>(); + public List temperature = new ArrayList<>(); + public List weather = new ArrayList<>(); + public List windDirection = new ArrayList<>(); + public List sunRiseSet = new ArrayList<>(); + public List windSpeed = new ArrayList<>(); + public List moonRiseSet = new ArrayList<>(); + public List airQualities = new ArrayList<>(); + + public ForecastResponse(final WeatherSpec weatherSpec, final int days) { + pubTime = new Date(weatherSpec.timestamp * 1000L); + + final Calendar calendar = GregorianCalendar.getInstance(); + calendar.setTime(new Date(weatherSpec.timestamp * 1000L)); + + // TODO: We should send sunrise on the same location as the weather + final SimpleDateFormat sunRiseSetSdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT); + final Location lastKnownLocation = new CurrentPosition().getLastKnownLocation(); + final GregorianCalendar sunriseDate = new GregorianCalendar(); + sunriseDate.setTime(calendar.getTime()); + + for (int i = 0; i < Math.min(weatherSpec.forecasts.size(), days); i++) { + final WeatherSpec.Forecast forecast = weatherSpec.forecasts.get(i); + temperature.add(new Range(forecast.minTemp - 273, forecast.maxTemp - 273)); + final String weatherCode = String.valueOf(HuamiWeatherConditions.mapToAmazfitBipWeatherCode(forecast.conditionCode) & 0xff); // is it? + weather.add(new Range(weatherCode, weatherCode)); + + final GregorianCalendar[] sunriseTransitSet = SPA.calculateSunriseTransitSet( + sunriseDate, + lastKnownLocation.getLatitude(), + lastKnownLocation.getLongitude(), + DeltaT.estimate(sunriseDate) + ); + + final String from = sunRiseSetSdf.format(sunriseTransitSet[0].getTime()); + final String to = sunRiseSetSdf.format(sunriseTransitSet[2].getTime()); + sunRiseSet.add(new Range(from, to)); + + sunriseDate.add(Calendar.DAY_OF_MONTH, 1); + + windDirection.add(new Range(0, 0)); + windSpeed.add(new Range(0, 0)); + } + } + } + + private static class Range { + public String from; + public String to; + + public Range(final String from, final String to) { + this.from = from; + this.to = to; + } + + public Range(final int from, final int to) { + this.from = String.valueOf(from); + this.to = String.valueOf(to); + } + } + + // /weather/index + // + // locale=zh_CN + // deviceSource=11 + // days=3 + // isGlobal=true + // locationKey=00.000,-0.000,xiaomi_accu:000000 + public static class IndexResponse extends Response { + public Date pubTime; + public List dataList = new ArrayList<>(); + + public IndexResponse(final WeatherSpec weatherSpec) { + pubTime = new Date(weatherSpec.timestamp * 1000L); + } + } + + private static class IndexEntry { + public LocalDate date; + public String osi; + public String uvi; + public Object pai; + public String cwi; + public String fi; + } + + // /weather/current + // + // locale=zh_CN + // deviceSource=11 + // isGlobal=true + // locationKey=00.000,-0.000,xiaomi_accu:000000 + public static class CurrentResponse extends Response { + public CurrentWeatherModel currentWeatherModel; + + public CurrentResponse(final WeatherSpec weatherSpec) { + this.currentWeatherModel = new CurrentWeatherModel(weatherSpec); + } + } + + private static class CurrentWeatherModel { + public UnitValue humidity; + public UnitValue pressure; + public Date pubTime; + public UnitValue temperature; + public String uvIndex; + public UnitValue visibility; + public String weather; + public Wind wind; + + public CurrentWeatherModel(final WeatherSpec weatherSpec) { + humidity = new UnitValue(Unit.PERCENTAGE, weatherSpec.currentHumidity); + pressure = new UnitValue(Unit.PRESSURE_MB, "1015"); // ? + pubTime = new Date(weatherSpec.timestamp * 1000L); + temperature = new UnitValue(Unit.TEMPERATURE_C, weatherSpec.currentTemp - 273); + uvIndex = "0"; + visibility = new UnitValue(Unit.KM, ""); + weather = String.valueOf(HuamiWeatherConditions.mapToAmazfitBipWeatherCode(weatherSpec.currentConditionCode) & 0xff); // is it? + wind = new Wind(weatherSpec.windDirection, Math.round(weatherSpec.windSpeed)); + } + } + + private enum Unit { + PRESSURE_MB("mb"), + PERCENTAGE("%"), + TEMPERATURE_C("℃"), // e2 84 83 in UTF-8 + WIND_DEGREES("°"), // c2 b0 in UTF-8 + KM("km"), + KPH("km/h"), + ; + + private final String value; + + Unit(final String value) { + this.value = value; + } + + public String getValue() { + return value; + } + } + + private static class UnitValue { + public String unit; + public String value; + + public UnitValue(final Unit unit, final String value) { + this.unit = unit.getValue(); + this.value = value; + } + + public UnitValue(final Unit unit, final int value) { + this.unit = unit.getValue(); + this.value = String.valueOf(value); + } + } + + private static class Wind { + public UnitValue direction; + public UnitValue speed; + + public Wind(final int direction, final int speed) { + this.direction = new UnitValue(Unit.WIND_DEGREES, direction); + this.speed = new UnitValue(Unit.KPH, Math.round(speed)); + } + } + + // /weather/forecast/hourly + // + // locale=zh_CN + // deviceSource=11 + // hourly=72 + // isGlobal=true + // locationKey=00.000,-0.000,xiaomi_accu:000000 + public static class HourlyResponse extends Response { + public Object pubTime; + public Object weather; + public Object temperature; + public Object humidity; + public Object fxTime; + public Object windDirection; + public Object windSpeed; + public Object windScale; + } + + // /weather/alerts + // + // locale=zh_CN + // deviceSource=11 + // days=3 + // isGlobal=true + // locationKey=00.000,-0.000,xiaomi_accu:000000 + public static class AlertsResponse extends Response { + } + + private static class LocalDateSerializer implements JsonSerializer { + @Override + public JsonElement serialize(final LocalDate src, final Type typeOfSrc, final JsonSerializationContext context) { + // Serialize as "yyyy-MM-dd" string + return new JsonPrimitive(src.format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021WorkoutTrackActivityType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021WorkoutTrackActivityType.java new file mode 100644 index 000000000..ce1e085fc --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/Huami2021WorkoutTrackActivityType.java @@ -0,0 +1,60 @@ +/* Copyright (C) 2022 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.huami; + +/** + * The workout types, used to start / when workout tracking starts on the band. + */ +public enum Huami2021WorkoutTrackActivityType { + // TODO 150 workouts :/ + Badminton(0x5c), + Dance(0x4c), + Elliptical(0x09), + Freestyle(0x05), + IndoorCycling(0x08), + IndoorFitness(0x18), + JumpRope(0x15), + OutdoorCycling(0x04), + OutdoorRunning(0x01), + PoolSwimming(0x06), + Rowing(0x17), + Soccer(0xbf), + Treadmill(0x02), + Walking(0x03), + Yoga(0x3c), + ; + + private final byte code; + + Huami2021WorkoutTrackActivityType(final int code) { + this.code = (byte) code; + } + + public byte getCode() { + return code; + } + + public static Huami2021WorkoutTrackActivityType fromCode(final byte code) { + for (final Huami2021WorkoutTrackActivityType type : values()) { + if (type.getCode() == code) { + return type; + } + } + + return null; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiBatteryInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiBatteryInfo.java index e8a30174f..9e658debf 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiBatteryInfo.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiBatteryInfo.java @@ -18,6 +18,7 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami; import java.util.GregorianCalendar; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandDateConverter; import nodomain.freeyourgadget.gadgetbridge.model.BatteryState; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; @@ -108,4 +109,13 @@ public class HuamiBatteryInfo extends AbstractInfo { // } return -1; } + + public GBDeviceEventBatteryInfo toDeviceEvent() { + final GBDeviceEventBatteryInfo deviceEventBatteryInfo = new GBDeviceEventBatteryInfo(); + deviceEventBatteryInfo.level = ((short) getLevelInPercent()); + deviceEventBatteryInfo.state = getState(); + deviceEventBatteryInfo.lastChargeTime = getLastChargeTime(); + deviceEventBatteryInfo.numCharges = getNumCharges(); + return deviceEventBatteryInfo; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiChunked2021Decoder.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiChunked2021Decoder.java deleted file mode 100644 index 002943eaa..000000000 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiChunked2021Decoder.java +++ /dev/null @@ -1,147 +0,0 @@ -/* Copyright (C) 2022 Andreas Shimokawa - - 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.huami; - -import org.apache.commons.lang3.ArrayUtils; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; -import java.nio.ByteBuffer; - -import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl; -import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; -import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; -import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils; -import nodomain.freeyourgadget.gadgetbridge.util.GB; - -public class HuamiChunked2021Decoder { - private static final Logger LOG = LoggerFactory.getLogger(HuamiChunked2021Decoder.class); - private Byte currentHandle; - private int currentType; - private int currentLength; - ByteBuffer reassemblyBuffer; - private final HuamiSupport huamiSupport; - - public HuamiChunked2021Decoder(HuamiSupport huamiSupport) { - this.huamiSupport = huamiSupport; - } - - - public byte[] decode(byte[] data) { - int i = 0; - if (data[i++] != 0x03) { - return null; - } - boolean encrypted = false; - byte flags = data[i++]; - if ((flags & 0x08) == 0x08) { - encrypted = true; - } - if (huamiSupport.force2021Protocol) { - i++; // skip extended header - } - byte handle = data[i++]; - if (currentHandle != null && currentHandle != handle) { - LOG.warn("ignoring handle " + handle + ", expected " + currentHandle); - return null; - } - byte count = data[i++]; - if ((flags & 0x01) == 0x01) { // beginning - int full_length = (data[i++] & 0xff) | ((data[i++] & 0xff) << 8) | ((data[i++] & 0xff) << 16) | ((data[i++] & 0xff) << 24); - currentLength = full_length; - if (encrypted) { - int encrypted_length = full_length + 8; - int overflow = encrypted_length % 16; - if (overflow > 0) { - encrypted_length += (16 - overflow); - } - full_length = encrypted_length; - } - reassemblyBuffer = ByteBuffer.allocate(full_length); - currentType = (data[i++] & 0xff) | ((data[i++] & 0xff) << 8); - currentHandle = handle; - } - reassemblyBuffer.put(data, i, data.length - i); - if ((flags & 0x02) == 0x02) { // end - byte[] buf = reassemblyBuffer.array(); - if (encrypted) { - byte[] messagekey = new byte[16]; - for (int j = 0; j < 16; j++) { - messagekey[j] = (byte) (huamiSupport.sharedSessionKey[j] ^ handle); - } - try { - buf = CryptoUtils.decryptAES(buf, messagekey); - buf = ArrayUtils.subarray(buf, 0, currentLength); - LOG.info("decrypted data: " + GB.hexdump(buf)); - } catch (Exception e) { - LOG.warn("error decrypting " + e); - return null; - } - } - if (currentType == HuamiService.CHUNKED2021_ENDPOINT_COMPAT) { - LOG.info("got configuration data"); - currentHandle = null; - currentType = 0; - return ArrayUtils.remove(buf, 0); - } - if (currentType == HuamiService.CHUNKED2021_ENDPOINT_SMSREPLY && false) { // unsafe for now, disabled, also we shoud return somehing and then parse in HuamiSupport instead of firing stuff here - LOG.debug("got command for SMS reply"); - if (buf[0] == 0x0d) { - try { - TransactionBuilder builder = huamiSupport.performInitialized("allow sms reply"); - huamiSupport.writeToChunked2021(builder, (short) 0x0013, huamiSupport.getNextHandle(), new byte[]{(byte) 0x0e, 0x01}, huamiSupport.force2021Protocol, false); - builder.queue(huamiSupport.getQueue()); - } catch (IOException e) { - LOG.error("Unable to allow sms reply"); - } - } else if (buf[0] == 0x0b) { - String phoneNumber = null; - String smsReply = null; - for (i = 1; i < buf.length; i++) { - if (buf[i] == 0) { - phoneNumber = new String(buf, 1, i - 1); - // there are four unknown bytes between caller and reply - smsReply = new String(buf, i + 5, buf.length - i - 6); - break; - } - } - if (phoneNumber != null && !phoneNumber.isEmpty()) { - LOG.debug("will send message '" + smsReply + "' to number '" + phoneNumber + "'"); - GBDeviceEventNotificationControl devEvtNotificationControl = new GBDeviceEventNotificationControl(); - devEvtNotificationControl.handle = -1; - devEvtNotificationControl.phoneNumber = phoneNumber; - devEvtNotificationControl.reply = smsReply; - devEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY; - huamiSupport.evaluateGBDeviceEvent(devEvtNotificationControl); - try { - TransactionBuilder builder = huamiSupport.performInitialized("ack sms reply"); - byte[] ackSentCommand = new byte[]{0x0c, 0x01}; - huamiSupport.writeToChunked2021(builder, (short) 0x0013, huamiSupport.getNextHandle(), ackSentCommand, huamiSupport.force2021Protocol, false); - builder.queue(huamiSupport.getQueue()); - } catch (IOException e) { - LOG.error("Unable to ack sms reply"); - } - } - } - } - currentHandle = null; - currentType = 0; - } - return null; - } -} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareInfo.java index 9f06b11fd..db6c8f6ce 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareInfo.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareInfo.java @@ -26,7 +26,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils; import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; -public abstract class HuamiFirmwareInfo { +public abstract class HuamiFirmwareInfo extends AbstractHuamiFirmwareInfo { protected static final byte[] RES_HEADER = new byte[]{ // HMRES resources file (*.res) 0x48, 0x4d, 0x52, 0x45, 0x53 @@ -104,9 +104,9 @@ public abstract class HuamiFirmwareInfo { protected static final int COMPRESSED_RES_HEADER_OFFSET = 0x9; protected static final int COMPRESSED_RES_HEADER_OFFSET_NEW = 0xd; - private HuamiFirmwareType firmwareType; - + @Override public String toVersion(int crc16) { + final byte[] bytes = getBytes(); String version = getCrcMap().get(crc16); if (version == null) { switch (firmwareType) { @@ -165,61 +165,10 @@ public abstract class HuamiFirmwareInfo { return version; } - public int[] getWhitelistedVersions() { - return ArrayUtils.toIntArray(getCrcMap().keySet()); - } - - private final int crc16; - private final int crc32; - - private byte[] bytes; - public HuamiFirmwareInfo(byte[] bytes) { - this.bytes = bytes; - crc16 = CheckSums.getCRC16(bytes); - crc32 = CheckSums.getCRC32(bytes); - firmwareType = determineFirmwareType(bytes); + super(bytes); } - public abstract boolean isGenerallyCompatibleWith(GBDevice device); - - public boolean isHeaderValid() { - return getFirmwareType() != HuamiFirmwareType.INVALID; - } - - public void checkValid() throws IllegalArgumentException { - } - - /** - * @return the size of the firmware in number of bytes. - */ - public int getSize() { - return bytes.length; - } - - public byte[] getBytes() { - return bytes; - } - - public int getCrc16() { - return crc16; - } - public int getCrc32() { - return crc32; - } - - public int getFirmwareVersion() { - return getCrc16(); // HACK until we know how to determine the version from the fw bytes - } - - public HuamiFirmwareType getFirmwareType() { - return firmwareType; - } - - protected abstract Map getCrcMap(); - - protected abstract HuamiFirmwareType determineFirmwareType(byte[] bytes); - protected String searchFirmwareVersion(byte[] fwbytes) { ByteBuffer buf = ByteBuffer.wrap(fwbytes); buf.order(ByteOrder.BIG_ENDIAN); @@ -242,26 +191,4 @@ public abstract class HuamiFirmwareInfo { } return null; } - - protected boolean searchString32BitAligned(byte[] fwbytes, String findString) { - ByteBuffer stringBuf = ByteBuffer.wrap((findString + "\0").getBytes()); - stringBuf.order(ByteOrder.BIG_ENDIAN); - int[] findArray = new int[stringBuf.remaining() / 4]; - for (int i = 0; i < findArray.length; i++) { - findArray[i] = stringBuf.getInt(); - } - - ByteBuffer buf = ByteBuffer.wrap(fwbytes); - buf.order(ByteOrder.BIG_ENDIAN); - while (buf.remaining() > 3) { - int arrayPos = 0; - while (arrayPos < findArray.length && buf.remaining() > 3 && (buf.getInt() == findArray[arrayPos])) { - arrayPos++; - } - if (arrayPos == findArray.length) { - return true; - } - } - return false; - } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareType.java index 2fd948add..ba41eeb05 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiFirmwareType.java @@ -18,6 +18,9 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami; public enum HuamiFirmwareType { FIRMWARE((byte) 0), + CHANGELOG_TXT((byte) 16), + // MB7 firmwares are sent as UIHH packing FIRMWARE (zip) + CHANGELOG_TXT, type 0xfd + FIRMWARE_UIHH_2021_ZIP_WITH_CHANGELOG((byte) -3), FONT((byte) 1), RES((byte) 2), RES_COMPRESSED((byte) 130), diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiLanguageType.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiLanguageType.java index 3953d8080..e6690a52a 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiLanguageType.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiLanguageType.java @@ -16,11 +16,12 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami; -import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; public class HuamiLanguageType { - public static final Map idLookup = new HashMap() {{ + // Use a LinkedHashMap (sorted), so that when we reverse it we get the first value as key, deterministically + public static final Map idLookup = new LinkedHashMap() {{ put("zh_CN", 0x00); put("zh_TW", 0x01); put("zh_HK", 0x01); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java index f1e71ed2b..557430b60 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java @@ -71,6 +71,7 @@ import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.Logging; import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.SettingsActivity; +import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabilityImpl; import nodomain.freeyourgadget.gadgetbridge.database.DBHandler; import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; @@ -78,19 +79,20 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInf import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; import nodomain.freeyourgadget.gadgetbridge.devices.huami.ActivateDisplayOnLift; import nodomain.freeyourgadget.gadgetbridge.devices.huami.ActivateDisplayOnLiftSensitivity; import nodomain.freeyourgadget.gadgetbridge.devices.huami.DisconnectNotificationSetting; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiWeatherConditions; import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipService; -import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband2.MiBand2FWHelper; import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband3.MiBand3Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband3.MiBand3Service; import nodomain.freeyourgadget.gadgetbridge.devices.miband.DateTimeDisplay; @@ -151,8 +153,6 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.NotificationS import nodomain.freeyourgadget.gadgetbridge.service.devices.miband.RealtimeSamplesSupport; import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol; import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; -import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; -import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils; import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; @@ -163,6 +163,9 @@ import nodomain.freeyourgadget.gadgetbridge.util.Version; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ACTIVATE_DISPLAY_ON_LIFT; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_END; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_MODE; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALWAYS_ON_DISPLAY_START; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_BT_CONNECTED_ADVERTISEMENT; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DATEFORMAT; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DISPLAY_ON_LIFT_SENSITIVITY; @@ -177,8 +180,11 @@ import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.Dev import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_DO_NOT_DISTURB_LIFT_WRIST; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ACTIVITY_MONITORING; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_ENABLED; -import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_ALERT_THRESHOLD; +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; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HEARTRATE_STRESS_RELAXATION_REMINDER; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HOURLY_CHIME_ENABLE; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HOURLY_CHIME_END; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_HOURLY_CHIME_START; @@ -192,11 +198,22 @@ import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.Dev import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_LANGUAGE; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_RESERVER_ALARMS_CALENDAR; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_RESERVER_REMINDERS_CALENDAR; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SCREEN_BRIGHTNESS; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SCREEN_ON_ON_NOTIFICATIONS; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SCREEN_TIMEOUT; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SOUNDS; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SPO2_ALL_DAY_MONITORING; +import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SPO2_LOW_ALERT_THRESHOLD; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SYNC_CALENDAR; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_TIMEFORMAT; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_USER_FITNESS_GOAL_NOTIFICATION; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_WEARLOCATION; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.CANNED_MESSAGES_CMD_REPLY_SMS; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.CANNED_MESSAGES_CMD_REPLY_SMS_ACK; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.CANNED_MESSAGES_CMD_REPLY_SMS_ALLOW; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.CHUNKED2021_ENDPOINT_CANNED_MESSAGES; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.WORKOUT_GPS_FLAG_POSITION; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.WORKOUT_GPS_FLAG_STATUS; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_BUTTON_ACTION_SELECTION_BROADCAST; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_BUTTON_ACTION_SELECTION_FITNESS_APP_START; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_BUTTON_ACTION_SELECTION_FITNESS_APP_STOP; @@ -243,6 +260,12 @@ import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.CO import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.DISPLAY_ITEM_BIT_CLOCK; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.ENDPOINT_DISPLAY; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.ENDPOINT_DISPLAY_ITEMS; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.MUSIC_FLAG_ALBUM; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.MUSIC_FLAG_ARTIST; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.MUSIC_FLAG_DURATION; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.MUSIC_FLAG_STATE; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.MUSIC_FLAG_TRACK; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.MUSIC_FLAG_VOLUME; import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.DEFAULT_VALUE_VIBRATION_COUNT; import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.DEFAULT_VALUE_VIBRATION_PROFILE; import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.VIBRATION_COUNT; @@ -256,7 +279,7 @@ import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_ import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_YEAR_OF_BIRTH; import static nodomain.freeyourgadget.gadgetbridge.service.btle.GattCharacteristic.UUID_CHARACTERISTIC_ALERT_LEVEL; -public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { +public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements Huami2021Handler { // We introduce key press counter for notification purposes private static int currentButtonActionId = 0; @@ -264,14 +287,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { private static long currentButtonPressTime = 0; private static long currentButtonTimerActivationTime = 0; - public byte[] sharedSessionKey; - public int encryptedSequenceNr; - public byte handle; - public byte getNextHandle() { - return handle++; - } - - private Timer buttonActionTimer = null; private static final Logger LOG = LoggerFactory.getLogger(HuamiSupport.class); @@ -308,8 +323,9 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { private boolean heartRateNotifyEnabled; private int mMTU = 23; protected int mActivitySampleSize = 4; - protected boolean force2021Protocol = false; - private HuamiChunked2021Decoder huamiChunked2021Decoder; + + protected Huami2021ChunkedEncoder huami2021ChunkedEncoder; + protected Huami2021ChunkedDecoder huami2021ChunkedDecoder; public HuamiSupport() { this(LOG); @@ -342,13 +358,15 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { boolean authenticate = needsAuth && (cryptFlags == 0x00); needsAuth = false; characteristicChunked2021Read = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ); - if (characteristicChunked2021Read != null) { - huamiChunked2021Decoder = new HuamiChunked2021Decoder(this); + if (characteristicChunked2021Read != null && huami2021ChunkedDecoder == null) { + huami2021ChunkedDecoder = new Huami2021ChunkedDecoder(this, force2021Protocol()); } characteristicChunked2021Write = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_WRITE); - if (characteristicChunked2021Write != null && GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()).getBoolean("force_new_protocol", false)) { - force2021Protocol = true; - new InitOperation2021(authenticate, authFlags, cryptFlags, this, builder).perform(); + if (characteristicChunked2021Write != null && huami2021ChunkedEncoder == null) { + huami2021ChunkedEncoder = new Huami2021ChunkedEncoder(characteristicChunked2021Write, force2021Protocol(), mMTU); + } + if (characteristicChunked2021Write != null && force2021Protocol()) { + new InitOperation2021(authenticate, authFlags, cryptFlags, this, builder, huami2021ChunkedEncoder, huami2021ChunkedDecoder).perform(); } else { new InitOperation(authenticate, authFlags, cryptFlags, this, builder).perform(); } @@ -423,16 +441,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - public HuamiSupport setLowLatency(TransactionBuilder builder) { - // TODO: low latency? - return this; - } - - public HuamiSupport setHighLatency(TransactionBuilder builder) { - // TODO: high latency? - return this; - } - /** * Last action of initialization sequence. Sets the device to initialized. * It is only invoked if all other actions were successfully run, so the device @@ -511,7 +519,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { private static final byte[] startHeartMeasurementContinuous = new byte[]{0x15, MiBandService.COMMAND_SET__HR_CONTINUOUS, 1}; private static final byte[] stopHeartMeasurementContinuous = new byte[]{0x15, MiBandService.COMMAND_SET__HR_CONTINUOUS, 0}; - private HuamiSupport requestBatteryInfo(TransactionBuilder builder) { + protected HuamiSupport requestBatteryInfo(TransactionBuilder builder) { LOG.debug("Requesting Battery Info!"); BluetoothGattCharacteristic characteristic = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_6_BATTERY_INFO); builder.read(characteristic); @@ -554,8 +562,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { * @param transaction * @return */ - - private HuamiSupport setUserInfo(TransactionBuilder transaction) { + protected HuamiSupport setUserInfo(TransactionBuilder transaction) { BluetoothGattCharacteristic characteristic = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_8_USER_SETTINGS); if (characteristic == null) { return this; @@ -616,7 +623,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { * @param builder * @return */ - private HuamiSupport setWearLocation(TransactionBuilder builder) { + protected HuamiSupport setWearLocation(TransactionBuilder builder) { LOG.info("Attempting to set wear location..."); BluetoothGattCharacteristic characteristic = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_8_USER_SETTINGS); if (characteristic != null) { @@ -670,7 +677,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { // not supported } - private HuamiSupport setPassword(final TransactionBuilder builder) { + protected HuamiSupport setPassword(final TransactionBuilder builder) { final boolean passwordEnabled = HuamiCoordinator.getPasswordEnabled(gbDevice.getAddress()); final String password = HuamiCoordinator.getPassword(gbDevice.getAddress()); @@ -721,16 +728,16 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setHeartrateActivityMonitoring(TransactionBuilder builder) { + protected HuamiSupport setHeartrateActivityMonitoring(TransactionBuilder builder) { final boolean enableHrActivityMonitoring = HuamiCoordinator.getHeartrateActivityMonitoring(gbDevice.getAddress()); final byte[] cmd = {ENDPOINT_DISPLAY, 0x22, 0x00, (byte) (enableHrActivityMonitoring ? 0x01 : 0x00)}; writeToConfiguration(builder, cmd); return this; } - private HuamiSupport setHeartrateAlert(TransactionBuilder builder) { + protected HuamiSupport setHeartrateAlert(TransactionBuilder builder) { final boolean enableHrAlert = HuamiCoordinator.getHeartrateAlert(gbDevice.getAddress()); - final int hrAlertThreshold = HuamiCoordinator.getHeartrateAlertThreshold(gbDevice.getAddress()); + final int hrAlertThreshold = HuamiCoordinator.getHeartrateAlertHighThreshold(gbDevice.getAddress()); final byte[] cmd = { ENDPOINT_DISPLAY, @@ -745,14 +752,35 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setHeartrateStressMonitoring(TransactionBuilder builder) { + protected HuamiSupport setHeartrateSleepBreathingQualityMonitoring(TransactionBuilder builder) { + LOG.warn("setHeartrateSleepBreathingQualityMonitoring not implemented"); + return this; + } + + protected HuamiSupport setSPO2AllDayMonitoring(TransactionBuilder builder) { + LOG.warn("setSPO2AllDayMonitoring not implemented"); + return this; + } + + protected HuamiSupport setSPO2AlertThreshold(TransactionBuilder builder) { + LOG.warn("setSPO2AlertThreshold not implemented"); + return this; + } + + protected HuamiSupport setHeartrateStressMonitoring(TransactionBuilder builder) { final boolean enableHrStressMonitoring = HuamiCoordinator.getHeartrateStressMonitoring(gbDevice.getAddress()); + LOG.info("Setting heart rate stress monitoring to {}", enableHrStressMonitoring); final byte[] cmd = new byte[] {(byte) 0xfe, 0x06, 0x00, (byte) (enableHrStressMonitoring ? 0x01 : 0x00)}; writeToConfiguration(builder, cmd); return this; } - private HuamiSupport setHeartrateMeasurementInterval(TransactionBuilder builder, int minutes) { + protected HuamiSupport setHeartrateStressRelaxationReminder(TransactionBuilder builder) { + LOG.warn("setHeartrateStressRelaxationReminder not implemented"); + return this; + } + + protected HuamiSupport setHeartrateMeasurementInterval(TransactionBuilder builder, int minutes) { if (characteristicHRControlPoint != null) { builder.notify(characteristicHRControlPoint, true); LOG.info("Setting heart rate measurement interval to " + minutes + " minutes"); @@ -799,10 +827,20 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { @Override public void onSetAlarms(ArrayList alarms) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); + int maxAlarms = coordinator.getAlarmSlotCount(); + try { TransactionBuilder builder = performInitialized("Set alarm"); boolean anyAlarmEnabled = false; for (Alarm alarm : alarms) { + if (alarm.getPosition() >= maxAlarms) { + if (alarm.getEnabled()) { + GB.toast(getContext(), "Only " + maxAlarms + " alarms are currently supported.", Toast.LENGTH_LONG, GB.WARN); + } + break; + } + anyAlarmEnabled |= alarm.getEnabled(); queueAlarm(alarm, builder); } @@ -944,11 +982,11 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { sendReminders(builder, reminders); } - private void sendReminders(final TransactionBuilder builder, final List reminders) { + protected void sendReminders(final TransactionBuilder builder, final List reminders) { final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); - int reservedSlots = prefs.getInt(PREF_RESERVER_REMINDERS_CALENDAR, 9); + int reservedSlots = prefs.getInt(PREF_RESERVER_REMINDERS_CALENDAR, coordinator.supportsCalendarEvents() ? 0 : 9); LOG.info("On Set Reminders. Reminders: {}, Reserved slots: {}", reminders.size(), reservedSlots); // Send the reminders, skipping the reserved slots for calendar events @@ -966,7 +1004,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private void sendReminderToDevice(final TransactionBuilder builder, int position, final Reminder reminder) { + protected void sendReminderToDevice(final TransactionBuilder builder, int position, final Reminder reminder) { if (characteristicChunked == null) { LOG.warn("characteristicChunked is null, not sending reminder"); return; @@ -1057,7 +1095,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { sendWorldClocks(builder, clocks); } - private void sendWorldClocks(final TransactionBuilder builder, final List clocks) { + protected void sendWorldClocks(final TransactionBuilder builder, final List clocks) { final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); if (coordinator.getWorldClocksSlotCount() == 0) { return; @@ -1082,10 +1120,10 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return; } - writeToChunked2021(builder, (short) 0x0008, getNextHandle(), baos.toByteArray(), force2021Protocol, false); + writeToChunked2021(builder, (short) 0x0008, baos.toByteArray(), false); } - public byte[] encodeWorldClock(final WorldClock clock) { + private byte[] encodeWorldClock(final WorldClock clock) { final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); try { @@ -1163,12 +1201,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { setCurrentTimeWithService(builder); //TODO: once we have a common strategy for sending events (e.g. EventHandler), remove this call from here. Meanwhile it does no harm. // = we should genaralize the pebble calender code - if (characteristicChunked == null) { // all except Mi Band 2 - sendCalendarEvents(builder); - } - else { - sendCalendarEventsAsReminder(builder); - } + sendCalendarEvents(builder); builder.queue(getQueue()); } catch (IOException ex) { LOG.error("Unable to set time on Huami device", ex); @@ -1240,7 +1273,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { for (int i = 0; i < 16; i++) { byte[] delete_command = new byte[]{0x07, (byte) (handle & 0xff), (byte) ((handle & 0xff00) >> 8), (byte) ((handle & 0xff0000) >> 16), (byte) ((handle & 0xff000000) >> 24)}; - writeToChunked2021(builder, (short) 0x0013, getNextHandle(), delete_command, force2021Protocol, false); + writeToChunked2021(builder, (short) 0x0013, delete_command, false); handle++; } handle = 0x12345678; @@ -1252,11 +1285,11 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { buf.putInt(handle++); buf.put(cannedMessage.getBytes()); buf.put((byte) 0x00); - writeToChunked2021(builder, (short) 0x0013, getNextHandle(), buf.array(), force2021Protocol, false); + writeToChunked2021(builder, (short) 0x0013, buf.array(), false); } builder.queue(getQueue()); } catch (IOException ex) { - LOG.error("Unable to set time on Huami device", ex); + LOG.error("Unable to set canned messages on Huami device", ex); } } } @@ -1300,6 +1333,18 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } + protected void onMusicAppOpen() { + LOG.info("Music app started"); + isMusicAppStarted = true; + sendMusicStateToDevice(); + sendVolumeStateToDevice(); + } + + protected void onMusicAppClosed() { + LOG.info("Music app terminated"); + isMusicAppStarted = false; + } + private void sendMusicStateToDevice() { sendMusicStateToDevice(bufferMusicSpec, bufferMusicStateSpec); } @@ -1310,7 +1355,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return; } - final byte[] volumeCommand = new byte[]{0x40, (byte) Math.round(volume)}; + final byte[] volumeCommand = new byte[]{MUSIC_FLAG_VOLUME, (byte) Math.round(volume)}; try { final TransactionBuilder builder = performInitialized("send volume"); @@ -1325,16 +1370,20 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } private void sendVolumeStateToDevice() { + onSetPhoneVolume(getPhoneVolume()); + } + + protected int getPhoneVolume() { 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 int volumePercentage = (byte) Math.round(100 * (volumeLevel / (float) volumeMax)); - onSetPhoneVolume(volumePercentage); + return volumePercentage; } - protected void sendMusicStateToDevice(MusicSpec musicSpec, MusicStateSpec musicStateSpec) { + protected void sendMusicStateToDevice(final MusicSpec musicSpec, final MusicStateSpec musicStateSpec) { if (characteristicChunked == null) { return; } @@ -1343,13 +1392,34 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return; } + try { + TransactionBuilder builder = performInitialized("send playback info"); + writeToChunked(builder, 3, encodeMusicState(musicSpec, musicStateSpec, false)); + builder.queue(getQueue()); + } catch (IOException e) { + LOG.error("Unable to send playback state"); + } + LOG.info("sendMusicStateToDevice: {}, {}", musicSpec, musicStateSpec); + } + + protected byte[] encodeMusicState(final MusicSpec musicSpec, final MusicStateSpec musicStateSpec, final boolean includeVolume) { String artist = ""; String album = ""; String track = ""; byte flags = 0x00; - flags |= 0x01; - int length = 5; + int length = 1; + + if (musicStateSpec != null) { + length += 4; + flags |= MUSIC_FLAG_STATE; + } + + if (includeVolume) { + length += 1; + flags |= MUSIC_FLAG_VOLUME; + } + if (musicSpec != null) { artist = StringUtils.truncate(musicSpec.artist, 80); album = StringUtils.truncate(musicSpec.album, 80); @@ -1357,26 +1427,27 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { if (artist.getBytes().length > 0) { length += artist.getBytes().length + 1; - flags |= 0x02; + flags |= MUSIC_FLAG_ARTIST; } if (album.getBytes().length > 0) { length += album.getBytes().length + 1; - flags |= 0x04; + flags |= MUSIC_FLAG_ALBUM; } if (track.getBytes().length > 0) { length += track.getBytes().length + 1; - flags |= 0x08; + flags |= MUSIC_FLAG_TRACK; } if (musicSpec.duration != 0) { length += 2; - flags |= 0x10; + flags |= MUSIC_FLAG_DURATION; } } - try { - ByteBuffer buf = ByteBuffer.allocate(length); - buf.order(ByteOrder.LITTLE_ENDIAN); - buf.put(flags); + ByteBuffer buf = ByteBuffer.allocate(length); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(flags); + + if (musicStateSpec != null) { byte state; switch (musicStateSpec.state) { case MusicStateSpec.STATE_PLAYING: @@ -1389,33 +1460,31 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { buf.put(state); buf.put((byte) 0); buf.putShort((short) musicStateSpec.position); - - if (musicSpec != null) { - if (artist.getBytes().length > 0) { - buf.put(artist.getBytes()); - buf.put((byte) 0); - } - if (album.getBytes().length > 0) { - buf.put(album.getBytes()); - buf.put((byte) 0); - } - if (track.getBytes().length > 0) { - buf.put(track.getBytes()); - buf.put((byte) 0); - } - if (musicSpec.duration != 0) { - buf.putShort((short) musicSpec.duration); - } - } - - TransactionBuilder builder = performInitialized("send playback info"); - writeToChunked(builder, 3, buf.array()); - - builder.queue(getQueue()); - } catch (IOException e) { - LOG.error("Unable to send playback state"); } - LOG.info("sendMusicStateToDevice: " + musicSpec + " " + musicStateSpec); + + if (musicSpec != null) { + if (artist.getBytes().length > 0) { + buf.put(artist.getBytes()); + buf.put((byte) 0); + } + if (album.getBytes().length > 0) { + buf.put(album.getBytes()); + buf.put((byte) 0); + } + if (track.getBytes().length > 0) { + buf.put(track.getBytes()); + buf.put((byte) 0); + } + if (musicSpec.duration != 0) { + buf.putShort((short) musicSpec.duration); + } + } + + if (includeVolume) { + buf.put((byte) getPhoneVolume()); + } + + return buf.array(); } @Override @@ -1481,7 +1550,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private void enableNotifyHeartRateMeasurements(boolean enable, TransactionBuilder builder) { + protected void enableNotifyHeartRateMeasurements(boolean enable, TransactionBuilder builder) { if (heartRateNotifyEnabled != enable) { BluetoothGattCharacteristic heartrateCharacteristic = getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_MEASUREMENT); if (heartrateCharacteristic != null) { @@ -1536,44 +1605,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private byte[] getHighLatency() { - int minConnectionInterval = 460; - int maxConnectionInterval = 500; - int latency = 0; - int timeout = 500; - int advertisementInterval = 0; - - return getLatency(minConnectionInterval, maxConnectionInterval, latency, timeout, advertisementInterval); - } - - private byte[] getLatency(int minConnectionInterval, int maxConnectionInterval, int latency, int timeout, int advertisementInterval) { - byte[] result = new byte[12]; - result[0] = (byte) (minConnectionInterval & 0xff); - result[1] = (byte) (0xff & minConnectionInterval >> 8); - result[2] = (byte) (maxConnectionInterval & 0xff); - result[3] = (byte) (0xff & maxConnectionInterval >> 8); - result[4] = (byte) (latency & 0xff); - result[5] = (byte) (0xff & latency >> 8); - result[6] = (byte) (timeout & 0xff); - result[7] = (byte) (0xff & timeout >> 8); - result[8] = 0; - result[9] = 0; - result[10] = (byte) (advertisementInterval & 0xff); - result[11] = (byte) (0xff & advertisementInterval >> 8); - - return result; - } - - private byte[] getLowLatency() { - int minConnectionInterval = 39; - int maxConnectionInterval = 49; - int latency = 0; - int timeout = 500; - int advertisementInterval = 0; - - return getLatency(minConnectionInterval, maxConnectionInterval, latency, timeout, advertisementInterval); - } - @Override public void onInstallApp(Uri uri) { try { @@ -1680,7 +1711,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private void handleDeviceAction(String deviceAction, String message) { + protected void handleDeviceAction(String deviceAction, String message) { if (deviceAction.equals(PREF_DEVICE_ACTION_SELECTION_OFF)) { return; } @@ -1816,14 +1847,10 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { deviceEventMusicControl.event = GBDeviceEventMusicControl.Event.VOLUMEDOWN; break; case (byte) 224: - LOG.info("Music app started"); - isMusicAppStarted = true; - sendMusicStateToDevice(); - sendVolumeStateToDevice(); + onMusicAppOpen(); break; case (byte) 225: - LOG.info("Music app terminated"); - isMusicAppStarted = false; + onMusicAppClosed(); break; default: LOG.info("unhandled music control event " + value[1]); @@ -1843,6 +1870,9 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { break; } mMTU = mtu; + if (huami2021ChunkedEncoder != null) { + huami2021ChunkedEncoder.setMTU(mtu); + } /* * not really sure if this would make sense, is this event already a proof of a successful MTU * negotiation initiated by the Huami device, and acknowledged by the phone? do we really have to @@ -1855,29 +1885,20 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { break; case HuamiDeviceEvent.WORKOUT_STARTING: final HuamiWorkoutTrackActivityType activityType = HuamiWorkoutTrackActivityType.fromCode(value[3]); - this.workoutNeedsGps = (value[2] == 1); if (activityType == null) { - LOG.warn("Unknown workout activity type {}", String.format("0x%x", value[3])); + LOG.warn("Unknown workout activity type {}", String.format("0x%02x", value[3])); } - LOG.info("Workout starting on band: {}, needs gps = {}", activityType, workoutNeedsGps); + final boolean needsGps = value[2] == 1; - final boolean sendGpsToBand = HuamiCoordinator.getWorkoutSendGpsToBand(getDevice().getAddress()); + LOG.info("Workout starting on band: {}, needs gps = {}", activityType, needsGps); - if (workoutNeedsGps) { - if (sendGpsToBand) { - lastPhoneGpsSent = 0; - sendPhoneGpsStatus(HuamiPhoneGpsStatus.SEARCHING); - GBLocationManager.start(getContext(), this); - } else { - sendPhoneGpsStatus(HuamiPhoneGpsStatus.DISABLED); - } - } + onWorkoutOpen(needsGps); break; default: - LOG.warn("unhandled event {}", String.format("0x%x", value[0])); + LOG.warn("unhandled event {}", String.format("0x%02x", value[0])); } } @@ -1892,6 +1913,43 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { */ private long lastPhoneGpsSent = 0; + protected void onWorkoutOpen(final boolean needsGps) { + this.workoutNeedsGps = needsGps; + + final boolean sendGpsToBand = HuamiCoordinator.getWorkoutSendGpsToBand(getDevice().getAddress()); + + if (workoutNeedsGps) { + if (sendGpsToBand) { + lastPhoneGpsSent = 0; + sendPhoneGps(HuamiPhoneGpsStatus.SEARCHING, null); + GBLocationManager.start(getContext(), this); + } else { + sendPhoneGps(HuamiPhoneGpsStatus.DISABLED, null); + } + } + } + + protected void onWorkoutStart() { + final boolean startOnPhone = HuamiCoordinator.getWorkoutStartOnPhone(getDevice().getAddress()); + + if (workoutNeedsGps && startOnPhone) { + LOG.info("Starting OpenTracks recording"); + + OpenTracksController.startRecording(getContext()); + } + } + + protected void onWorkoutEnd() { + final boolean startOnPhone = HuamiCoordinator.getWorkoutStartOnPhone(getDevice().getAddress()); + + GBLocationManager.stop(getContext(), this); + + if (startOnPhone) { + LOG.info("Stopping OpenTracks recording"); + OpenTracksController.stopRecording(getContext()); + } + } + private void handleDeviceWorkoutEvent(byte[] value) { if (value == null || value.length == 0) { return; @@ -1900,91 +1958,54 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { switch (value[0]) { case 0x11: final HuamiWorkoutStatus status = HuamiWorkoutStatus.fromCode(value[1]); - if (status == null) { - LOG.warn("Unknown workout status {}", String.format("0x%x", value[1])); - return; - } LOG.info("Got workout status {}", status); - final boolean sendGpsToBand = HuamiCoordinator.getWorkoutSendGpsToBand(getDevice().getAddress()); - final boolean startOnPhone = HuamiCoordinator.getWorkoutStartOnPhone(getDevice().getAddress()); - switch (status) { case Start: - if (workoutNeedsGps && startOnPhone) { - LOG.info("Starting OpenTracks recording"); - - OpenTracksController.startRecording(getContext()); - } - + onWorkoutStart(); break; case End: - GBLocationManager.stop(getContext(), this); - - if (startOnPhone) { - LOG.info("Stopping OpenTracks recording"); - OpenTracksController.stopRecording(getContext()); - } - + onWorkoutEnd(); + break; + default: + LOG.warn("Unknown workout status {}", String.format("0x%02x", value[1])); break; } break; default: - LOG.warn("Unhandled workout event {}", String.format("0x%x", value[0])); + LOG.warn("Unhandled workout event {}", String.format("0x%02x", value[0])); } } @Override public void onSetGpsLocation(final Location location) { - if (characteristicChunked == null || location == null) { - return; - } - final boolean sendGpsToBand = HuamiCoordinator.getWorkoutSendGpsToBand(getDevice().getAddress()); - if (!sendGpsToBand) { LOG.warn("Sending GPS to band is disabled, ignoring location update"); return; } - int flags = 0x40000; - int length = 1 + 4 + 31; - boolean newGpsLock = System.currentTimeMillis() - lastPhoneGpsSent > 5000; lastPhoneGpsSent = System.currentTimeMillis(); - if (newGpsLock) { - flags |= 0x01; - length += 1; + final HuamiPhoneGpsStatus status = newGpsLock ? HuamiPhoneGpsStatus.ACQUIRED : null; + + sendPhoneGps(status, location); + } + + protected void sendPhoneGps(final HuamiPhoneGpsStatus status, final Location location) { + if (characteristicChunked == null || location == null) { + return; } - final ByteBuffer buf = ByteBuffer.allocate(length); + final byte[] locationBytes = encodePhoneGpsPayload(status, location); + + final ByteBuffer buf = ByteBuffer.allocate(1 + locationBytes.length); buf.order(ByteOrder.LITTLE_ENDIAN); buf.put((byte) 0x06); - buf.putInt(flags); - - if (newGpsLock) { - buf.put((byte) 0x01); - } - - buf.putInt((int) (location.getLongitude() * 3000000.0)); - buf.putInt((int) (location.getLatitude() * 3000000.0)); - buf.putInt((int) location.getSpeed() * 10); - - buf.putInt((int) (location.getAltitude() * 100)); - buf.putLong(location.getTime()); - - // Seems to always be ff ? - buf.putInt(0xffffffff); - - // Not sure what this is, maybe bearing? It changes while moving, but - // doesn't seem to be needed on the Mi Band 5 - buf.putShort((short) 0x00); - - // Seems to always be 0 ? - buf.put((byte) 0x00); + buf.put(locationBytes); try { final TransactionBuilder builder = performInitialized("send phone gps location"); @@ -1993,32 +2014,54 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } catch (final IOException e) { LOG.error("Unable to send location", e); } - - LOG.info("sendLocationToBand: {}", location); } - private void sendPhoneGpsStatus(final HuamiPhoneGpsStatus status) { - int flags = 0x01; - final ByteBuffer buf = ByteBuffer.allocate(1 + 4 + 1); + protected byte[] encodePhoneGpsPayload(final HuamiPhoneGpsStatus status, final Location location) { + int flags = 0; + int length = 4; // Start with just the flag bytes - buf.order(ByteOrder.LITTLE_ENDIAN); - buf.put((byte) 0x06); - buf.putInt(flags); - - buf.put(status.getCode()); - - try { - final TransactionBuilder builder = performInitialized("send phone gps status"); - writeToChunked(builder, 6, buf.array()); - builder.queue(getQueue()); - } catch (final IOException e) { - LOG.error("Unable to send location", e); + if (status != null) { + flags |= WORKOUT_GPS_FLAG_STATUS; + length += 1; } - LOG.info("sendPhoneGpsStatus: {}", status); + if (location != null) { + flags |= WORKOUT_GPS_FLAG_POSITION; + length += 31; + } + + final ByteBuffer buf = ByteBuffer.allocate(length); + buf.order(ByteOrder.LITTLE_ENDIAN); + + buf.putInt(flags); + + if (status != null) { + buf.put(status.getCode()); + } + + if (location != null) { + buf.putInt((int) (location.getLongitude() * 3000000.0)); + buf.putInt((int) (location.getLatitude() * 3000000.0)); + buf.putInt((int) location.getSpeed() * 10); + + buf.putInt((int) (location.getAltitude() * 100)); + buf.putLong(location.getTime()); + + // Seems to always be ff ? + buf.putInt(0xffffffff); + + // Not sure what this is, maybe bearing? It changes while moving, but + // doesn't seem to be needed on the Mi Band 5 + buf.putShort((short) 0x00); + + // Seems to always be 0 ? + buf.put((byte) 0x00); + } + + return buf.array(); } - private void requestMTU(int mtu) { + protected void requestMTU(int mtu) { if (!GBApplication.isRunningLollipopOrLater()) { LOG.warn("Requesting MTU is only supported in Lollipop or later"); return; @@ -2044,7 +2087,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { this.mMTU = mtu; } - private void acknowledgeFindPhone() { + protected void acknowledgeFindPhone() { try { TransactionBuilder builder = performInitialized("acknowledge find phone"); @@ -2055,7 +2098,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private void processDeviceEvent(int event){ + protected void processDeviceEvent(int event){ LOG.debug("Handling device event: " + event); Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); String deviceActionBroadcastMessage=null; @@ -2083,7 +2126,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { handleDeviceAction(nonwearAction, deviceActionBroadcastMessage); break; } - } private void handleLongButtonEvent(){ @@ -2175,11 +2217,8 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } else if (HuamiService.UUID_CHARACTERISTIC_3_CONFIGURATION.equals(characteristicUUID)) { handleConfigurationInfo(characteristic.getValue()); return true; - } else if (HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ.equals(characteristicUUID) && huamiChunked2021Decoder != null) { - byte[] decoded_data = huamiChunked2021Decoder.decode(characteristic.getValue()); - if (decoded_data != null) { - handleConfigurationInfo(decoded_data); - } + } else if (HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ.equals(characteristicUUID) && huami2021ChunkedDecoder != null) { + huami2021ChunkedDecoder.decode(characteristic.getValue()); return true; } else { LOG.info("Unhandled characteristic changed: " + characteristicUUID); @@ -2260,7 +2299,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private void handleRealtimeSteps(byte[] value) { + protected void handleRealtimeSteps(byte[] value) { if (value == null) { LOG.error("realtime steps: value is null"); return; @@ -2314,7 +2353,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } if (!done) { - LOG.info("got chunk of configuration data for {}", String.format("0x%x", reassemblyType)); + LOG.info("got chunk of configuration data for {}", String.format("0x%02x", reassemblyType)); } else { LOG.info("got full/reassembled configuration data"); switch (reassemblyType) { @@ -2325,7 +2364,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { LOG.warn("got workout activity types, not handled"); break; default: - LOG.warn("got unknown chunked configuration response for {}, not handled", String.format("0x%x", reassemblyType)); + LOG.warn("got unknown chunked configuration response for {}, not handled", String.format("0x%02x", reassemblyType)); break; } @@ -2400,7 +2439,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private void enableRealtimeSamplesTimer(boolean enable) { + protected void enableRealtimeSamplesTimer(boolean enable) { if (enable) { getRealtimeSamplesSupport().start(); } else { @@ -2410,7 +2449,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - public MiBandActivitySample createActivitySample(Device device, User user, int timestampInSeconds, SampleProvider provider) { + private MiBandActivitySample createActivitySample(Device device, User user, int timestampInSeconds, SampleProvider provider) { MiBandActivitySample sample = new MiBandActivitySample(); sample.setDevice(device); sample.setUser(user); @@ -2478,18 +2517,10 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { * @param alarm * @param builder */ - private void queueAlarm(Alarm alarm, TransactionBuilder builder) { - Calendar calendar = AlarmUtils.toCalendar(alarm); - + protected void queueAlarm(Alarm alarm, TransactionBuilder builder) { DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); - int maxAlarms = coordinator.getAlarmSlotCount(); - if (alarm.getPosition() >= maxAlarms) { - if (alarm.getEnabled()) { - GB.toast(getContext(), "Only " + maxAlarms + " alarms are currently supported.", Toast.LENGTH_LONG, GB.WARN); - } - return; - } + Calendar calendar = AlarmUtils.toCalendar(alarm); int actionMask = 0; int daysMask = 0; @@ -2542,54 +2573,66 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { private void handleBatteryInfo(byte[] value, int status) { if (status == BluetoothGatt.GATT_SUCCESS) { HuamiBatteryInfo info = new HuamiBatteryInfo(value); - batteryCmd.level = ((short) info.getLevelInPercent()); - batteryCmd.state = info.getState(); - batteryCmd.lastChargeTime = info.getLastChargeTime(); - batteryCmd.numCharges = info.getNumCharges(); - handleGBDeviceEvent(batteryCmd); + handleGBDeviceEvent(info.toDeviceEvent()); } } + protected HuamiSupport sendCalendarEvents(TransactionBuilder builder) { + if (characteristicChunked == null) { // all except Mi Band 2 + sendCalendarEventsAsAlarms(builder); + } else { + sendCalendarEventsAsReminders(builder); + } + + return this; + } + /** * Fetch the events from the android device calendars and set the alarms on the miband. * @param builder */ - private HuamiSupport sendCalendarEvents(TransactionBuilder builder) { + private HuamiSupport sendCalendarEventsAsAlarms(TransactionBuilder builder) { + DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); - int availableSlots = prefs.getInt(PREF_RESERVER_ALARMS_CALENDAR, 0); + int maxAlarms = coordinator.getAlarmSlotCount(); + int availableSlots = Math.min(prefs.getInt(PREF_RESERVER_ALARMS_CALENDAR, 0), maxAlarms); - if (availableSlots > 0) { - CalendarManager upcomingEvents = new CalendarManager(getContext(), getDevice().getAddress()); - List mEvents = upcomingEvents.getCalendarEventList(); - - int iteration = 0; - - for (CalendarEvent mEvt : mEvents) { - if (mEvt.isAllDay()) { - continue; - } - if (iteration >= availableSlots || iteration > 2) { - break; - } - int slotToUse = 2 - iteration; - Calendar calendar = Calendar.getInstance(); - calendar.setTimeInMillis(mEvt.getBegin()); - Alarm alarm = AlarmUtils.createSingleShot(slotToUse, false, true, calendar); - queueAlarm(alarm, builder); - iteration++; - } + if (availableSlots <= 0) { + return this; } + + CalendarManager upcomingEvents = new CalendarManager(getContext(), getDevice().getAddress()); + List mEvents = upcomingEvents.getCalendarEventList(); + + int iteration = 0; + + for (CalendarEvent mEvt : mEvents) { + if (mEvt.isAllDay()) { + continue; + } + if (iteration >= availableSlots) { + break; + } + int slotToUse = 2 - iteration; + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(mEvt.getBegin()); + Alarm alarm = AlarmUtils.createSingleShot(slotToUse, false, true, calendar); + queueAlarm(alarm, builder); + iteration++; + } + return this; } - private HuamiSupport sendCalendarEventsAsReminder(TransactionBuilder builder) { + private HuamiSupport sendCalendarEventsAsReminders(TransactionBuilder builder) { boolean syncCalendar = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()).getBoolean(PREF_SYNC_CALENDAR, false); if (!syncCalendar) { return this; } + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); - int availableSlots = prefs.getInt(PREF_RESERVER_REMINDERS_CALENDAR, 9); + int availableSlots = prefs.getInt(PREF_RESERVER_REMINDERS_CALENDAR, coordinator.supportsCalendarEvents() ? 0 : 9); CalendarManager upcomingEvents = new CalendarManager(getContext(), getDevice().getAddress()); List calendarEvents = upcomingEvents.getCalendarEventList(); @@ -2692,6 +2735,25 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { case ActivityUser.PREF_USER_STEPS_GOAL: setFitnessGoal(builder); break; + case PREF_SCREEN_ON_ON_NOTIFICATIONS: + setScreenOnOnNotification(builder); + break; + case PREF_SCREEN_BRIGHTNESS: + setScreenBrightness(builder); + break; + case PREF_SCREEN_TIMEOUT: + setScreenTimeout(builder); + break; + case PREF_ALWAYS_ON_DISPLAY_MODE: + case PREF_ALWAYS_ON_DISPLAY_START: + case PREF_ALWAYS_ON_DISPLAY_END: + setAlwaysOnDisplay(builder); + break; + case MiBandConst.PREF_NIGHT_MODE: + case MiBandConst.PREF_NIGHT_MODE_START: + case MiBandConst.PREF_NIGHT_MODE_END: + setNightMode(builder); + break; case PREF_DO_NOT_DISTURB: case PREF_DO_NOT_DISTURB_START: case PREF_DO_NOT_DISTURB_END: @@ -2728,7 +2790,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { setLanguage(builder); break; case HuamiConst.PREF_EXPOSE_HR_THIRDPARTY: - setExposeHRThridParty(builder); + setExposeHRThirdParty(builder); break; case PREF_BT_CONNECTED_ADVERTISEMENT: setBtConnectedAdvertising(builder); @@ -2776,12 +2838,25 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { setHeartrateActivityMonitoring(builder); break; case PREF_HEARTRATE_ALERT_ENABLED: - case PREF_HEARTRATE_ALERT_THRESHOLD: + case PREF_HEARTRATE_ALERT_HIGH_THRESHOLD: + case PREF_HEARTRATE_ALERT_LOW_THRESHOLD: setHeartrateAlert(builder); break; + case PREF_HEARTRATE_SLEEP_BREATHING_QUALITY_MONITORING: + setHeartrateSleepBreathingQualityMonitoring(builder); + break; + case PREF_SPO2_ALL_DAY_MONITORING: + setSPO2AllDayMonitoring(builder); + break; + case PREF_SPO2_LOW_ALERT_THRESHOLD: + setSPO2AlertThreshold(builder); + break; case PREF_HEARTRATE_STRESS_MONITORING: setHeartrateStressMonitoring(builder); break; + case PREF_HEARTRATE_STRESS_RELAXATION_REMINDER: + setHeartrateStressRelaxationReminder(builder); + break; case PasswordCapabilityImpl.PREF_PASSWORD: case PasswordCapabilityImpl.PREF_PASSWORD_ENABLED: setPassword(builder); @@ -2810,7 +2885,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private void setVibrationPattern(final TransactionBuilder builder, final String preferenceKey) { + protected HuamiSupport setVibrationPattern(final TransactionBuilder builder, final String preferenceKey) { // The preference key has one of the 3 prefixes final String notificationTypeName = preferenceKey.replace(PREF_HUAMI_VIBRATION_COUNT_PREFIX, "") .replace(PREF_HUAMI_VIBRATION_PROFILE_PREFIX, "") @@ -2822,6 +2897,8 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { final VibrationProfile vibrationProfile = HuamiCoordinator.getVibrationProfile(getDevice().getAddress(), notificationType); setVibrationPattern(builder, notificationType, isTry, vibrationProfile); + + return this; } /** @@ -2832,31 +2909,14 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { * @param test test the pattern (only vibrate the band, do not set it) * @param profile the {@link VibrationProfile} */ - private void setVibrationPattern(final TransactionBuilder builder, + protected void setVibrationPattern(final TransactionBuilder builder, final HuamiVibrationPatternNotificationType notificationType, final boolean test, final VibrationProfile profile) { final int MAX_TOTAL_LENGTH_MS = 10_000; // 10 seconds, about as long as Mi Fit allows - int totalLengthMs = 0; // The on-off sequence, until the max total length is reached - final List onOff = new ArrayList<>(profile.getOnOffSequence().length); - - for (int c = 0; c < profile.getRepeat(); c++) { - for (int i = 0; i < profile.getOnOffSequence().length; i += 2) { - final short on = (short) profile.getOnOffSequence()[i]; - final short off = (short) profile.getOnOffSequence()[i + 1]; - - if (totalLengthMs + on + off > MAX_TOTAL_LENGTH_MS) { - LOG.warn("VibrationProfile {} too long, truncating to {} ms", profile.getId(), MAX_TOTAL_LENGTH_MS); - break; - } - - onOff.add(on); - onOff.add(off); - totalLengthMs += on + off; - } - } + final List onOff = truncateVibrationsOnOff(profile, MAX_TOTAL_LENGTH_MS); final ByteBuffer buf = ByteBuffer.allocate(3 + 2 * onOff.size()); buf.order(ByteOrder.LITTLE_ENDIAN); @@ -2877,6 +2937,31 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { writeToChunked(builder, 2, buf.array()); } + protected List truncateVibrationsOnOff(final VibrationProfile profile, final int limitMillis) { + int totalLengthMs = 0; + + // The on-off sequence, until the max total length is reached + final List onOff = new ArrayList<>(profile.getOnOffSequence().length); + + for (int c = 0; c < profile.getRepeat(); c++) { + for (int i = 0; i < profile.getOnOffSequence().length; i += 2) { + final short on = (short) profile.getOnOffSequence()[i]; + final short off = (short) profile.getOnOffSequence()[i + 1]; + + if (totalLengthMs + on + off > limitMillis) { + LOG.warn("VibrationProfile {} too long, truncating to {} ms", profile.getId(), limitMillis); + break; + } + + onOff.add(on); + onOff.add(off); + totalLengthMs += on + off; + } + } + + return onOff; + } + @Override public void onSendWeather(WeatherSpec weatherSpec) { final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); @@ -3122,7 +3207,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - private HuamiSupport setDateDisplay(TransactionBuilder builder) { + protected HuamiSupport setDateDisplay(TransactionBuilder builder) { DateTimeDisplay dateTimeDisplay = HuamiCoordinator.getDateDisplay(getContext(), gbDevice.getAddress()); LOG.info("Setting date display to " + dateTimeDisplay); switch (dateTimeDisplay) { @@ -3139,7 +3224,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { protected HuamiSupport setDateFormat(TransactionBuilder builder) { String dateFormat = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()).getString("dateformat", "MM/dd/yyyy"); if (dateFormat == null) { - return null; + return this; } switch (dateFormat) { case "MM/dd/yyyy": @@ -3156,12 +3241,12 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setTimeFormat(TransactionBuilder builder) { + protected HuamiSupport setTimeFormat(TransactionBuilder builder) { GBPrefs gbPrefs = new GBPrefs(new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()))); String timeFormat = gbPrefs.getTimeFormat(); LOG.info("Setting time format to " + timeFormat); - if (timeFormat.equals("24h")) { + if (timeFormat.equals(DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_24H)) { writeToConfiguration(builder,HuamiService.DATEFORMAT_TIME_24_HOURS); } else { writeToConfiguration(builder,HuamiService.DATEFORMAT_TIME_12_HOURS); @@ -3180,7 +3265,12 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setActivateDisplayOnLiftWrist(TransactionBuilder builder) { + protected HuamiSupport setAlwaysOnDisplay(TransactionBuilder builder) { + LOG.warn("Always on display not implemented"); + return this; + } + + protected HuamiSupport setActivateDisplayOnLiftWrist(TransactionBuilder builder) { ActivateDisplayOnLift displayOnLift = HuamiCoordinator.getActivateDisplayOnLiftWrist(getContext(), gbDevice.getAddress()); LOG.info("Setting activate display on lift wrist to " + displayOnLift); @@ -3420,7 +3510,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } protected HuamiSupport setBeepSounds(TransactionBuilder builder) { - SharedPreferences prefs = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()); Set sounds = prefs.getStringSet(PREF_SOUNDS, new HashSet<>(Arrays.asList(getContext().getResources().getStringArray(R.array.pref_amazfitneo_sounds_default)))); @@ -3443,7 +3532,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setRotateWristToSwitchInfo(TransactionBuilder builder) { + protected HuamiSupport setRotateWristToSwitchInfo(TransactionBuilder builder) { boolean enable = HuamiCoordinator.getRotateWristToSwitchInfo(gbDevice.getAddress()); LOG.info("Setting rotate wrist to cycle info to " + enable); if (enable) { @@ -3454,12 +3543,12 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setDisplayCaller(TransactionBuilder builder) { + protected HuamiSupport setDisplayCaller(TransactionBuilder builder) { writeToConfiguration(builder, HuamiService.COMMAND_ENABLE_DISPLAY_CALLER); return this; } - private HuamiSupport setDoNotDisturb(TransactionBuilder builder) { + protected HuamiSupport setDoNotDisturb(TransactionBuilder builder) { DoNotDisturb doNotDisturb = HuamiCoordinator.getDoNotDisturb(gbDevice.getAddress()); boolean doNotDisturbLiftWrist = HuamiCoordinator.getDoNotDisturbLiftWrist(gbDevice.getAddress()); LOG.info("Setting do not disturb to {}, wake on lift wrist {}", doNotDisturb, doNotDisturbLiftWrist); @@ -3501,7 +3590,43 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setInactivityWarnings(TransactionBuilder builder) { + protected HuamiSupport setNightMode(TransactionBuilder builder) { + String nightMode = MiBand3Coordinator.getNightMode(gbDevice.getAddress()); + LOG.info("Setting night mode to " + nightMode); + + switch (nightMode) { + case MiBandConst.PREF_NIGHT_MODE_SUNSET: + writeToConfiguration(builder, MiBand3Service.COMMAND_NIGHT_MODE_SUNSET); + break; + case MiBandConst.PREF_NIGHT_MODE_OFF: + writeToConfiguration(builder, MiBand3Service.COMMAND_NIGHT_MODE_OFF); + break; + case MiBandConst.PREF_NIGHT_MODE_SCHEDULED: + byte[] cmd = MiBand3Service.COMMAND_NIGHT_MODE_SCHEDULED.clone(); + + Calendar calendar = GregorianCalendar.getInstance(); + + Date start = MiBand3Coordinator.getNightModeStart(gbDevice.getAddress()); + calendar.setTime(start); + cmd[2] = (byte) calendar.get(Calendar.HOUR_OF_DAY); + cmd[3] = (byte) calendar.get(Calendar.MINUTE); + + Date end = MiBand3Coordinator.getNightModeEnd(gbDevice.getAddress()); + calendar.setTime(end); + cmd[4] = (byte) calendar.get(Calendar.HOUR_OF_DAY); + cmd[5] = (byte) calendar.get(Calendar.MINUTE); + + writeToConfiguration(builder, cmd); + break; + default: + LOG.error("Invalid night mode: " + nightMode); + break; + } + + return this; + } + + protected HuamiSupport setInactivityWarnings(TransactionBuilder builder) { boolean enable = HuamiCoordinator.getInactivityWarnings(gbDevice.getAddress()); LOG.info("Setting inactivity warnings to " + enable); @@ -3586,9 +3711,11 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - public boolean supportsHourlyChime() { return false; } + public boolean supportsHourlyChime() { + return false; + } - private HuamiSupport setDisconnectNotification(TransactionBuilder builder) { + protected HuamiSupport setDisconnectNotification(TransactionBuilder builder) { DisconnectNotificationSetting disconnectNotificationSetting = HuamiCoordinator.getDisconnectNotificationSetting(getContext(), gbDevice.getAddress()); LOG.info("Setting disconnect notification to " + disconnectNotificationSetting); @@ -3619,7 +3746,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setDistanceUnit(TransactionBuilder builder) { + protected HuamiSupport setDistanceUnit(TransactionBuilder builder) { MiBandConst.DistanceUnit unit = HuamiCoordinator.getDistanceUnit(); LOG.info("Setting distance unit to " + unit); if (unit == MiBandConst.DistanceUnit.METRIC) { @@ -3643,6 +3770,23 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } + protected HuamiSupport setScreenOnOnNotification(TransactionBuilder builder) { + LOG.warn("Function not implemented"); + + return this; + } + + protected HuamiSupport setScreenBrightness(TransactionBuilder builder) { + LOG.warn("Function not implemented"); + + return this; + } + + protected HuamiSupport setScreenTimeout(TransactionBuilder builder) { + LOG.warn("Function not implemented"); + + return this; + } protected HuamiSupport setLanguage(TransactionBuilder builder) { String localeString = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()).getString("language", "auto"); @@ -3676,7 +3820,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { default: command_old = AmazfitBipService.COMMAND_SET_LANGUAGE_ENGLISH; } - if (force2021Protocol) { + if (force2021Protocol()) { writeToConfiguration(builder,command_new); } else { final byte[] finalCommand_old = command_old; @@ -3700,6 +3844,12 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { Amazfit Bip U and GTS 2 mini tested so far */ protected HuamiSupport setLanguageByIdNew(TransactionBuilder builder) { + final byte[] command = new byte[]{0x06, 0x3b, 0x00, getLanguageId(), 0x03}; + writeToConfiguration(builder, command); + return this; + } + + protected byte getLanguageId() { byte language_code = 0x02; // english default String localeString = GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()).getString("language", "auto"); @@ -3715,12 +3865,10 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { language_code = id.byteValue(); } - final byte[] command = new byte[]{0x06, 0x3b, 0x00, language_code, 0x03}; - writeToConfiguration(builder, command); - return this; + return language_code; } - private HuamiSupport setExposeHRThridParty(TransactionBuilder builder) { + protected HuamiSupport setExposeHRThirdParty(TransactionBuilder builder) { boolean enable = HuamiCoordinator.getExposeHRThirdParty(gbDevice.getAddress()); LOG.info("Setting exposure of HR to third party apps to: " + enable); @@ -3733,7 +3881,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } - private HuamiSupport setBtConnectedAdvertising(TransactionBuilder builder) { + protected HuamiSupport setBtConnectedAdvertising(TransactionBuilder builder) { boolean enable = HuamiCoordinator.getBtConnectedAdvertising(gbDevice.getAddress()); LOG.info("Setting connected advertisement to: " + enable); @@ -3747,14 +3895,14 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } protected void writeToChunked(TransactionBuilder builder, int type, byte[] data) { - if (force2021Protocol && type > 0) { + if (force2021Protocol() && type > 0) { boolean encrypt = true; if (type == 1 && (data[1] == 2)) { // don't encypt current weather encrypt = false; } byte[] command = ArrayUtils.addAll(new byte[]{0x00, 0x00, (byte) (0xc0 | type), 0x00}, data); - writeToChunked2021(builder, HuamiService.CHUNKED2021_ENDPOINT_COMPAT, getNextHandle(), command, true, encrypt); + writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_COMPAT, command, encrypt); } else { writeToChunkedOld(builder, type, data); } @@ -3788,102 +3936,24 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { } } - public void writeToChunked2021(TransactionBuilder builder, short type, byte handle, byte[] data, boolean extended_flags, boolean encrypt) { - int remaining = data.length; - int length = data.length; - byte count = 0; - int header_size = 10; + protected void writeToChunked2021(TransactionBuilder builder, short type, byte[] data, boolean encrypt) { + huami2021ChunkedEncoder.write(builder, type, data, force2021Protocol(), encrypt); + } - if (extended_flags) { - header_size++; - } - - if (extended_flags && encrypt) { - byte[] messagekey = new byte[16]; - for (int i = 0; i < 16; i++) { - messagekey[i] = (byte) (sharedSessionKey[i] ^ handle); - } - int encrypted_length = length + 8; - int overflow = encrypted_length % 16; - if (overflow > 0) { - encrypted_length += (16 - overflow); - } - - byte[] encryptable_payload = new byte[encrypted_length]; - System.arraycopy(data, 0, encryptable_payload, 0, length); - encryptable_payload[length] = (byte) (encryptedSequenceNr & 0xff); - encryptable_payload[length + 1] = (byte) ((encryptedSequenceNr >> 8) & 0xff); - encryptable_payload[length + 2] = (byte) ((encryptedSequenceNr >> 16) & 0xff); - encryptable_payload[length + 3] = (byte) ((encryptedSequenceNr >> 24) & 0xff); - encryptedSequenceNr++; - int checksum = CheckSums.getCRC32(encryptable_payload, 0, length + 4); - encryptable_payload[length + 4] = (byte) (checksum & 0xff); - encryptable_payload[length + 5] = (byte) ((checksum >> 8) & 0xff); - encryptable_payload[length + 6] = (byte) ((checksum >> 16) & 0xff); - encryptable_payload[length + 7] = (byte) ((checksum >> 24) & 0xff); - remaining = encrypted_length; - try { - data = CryptoUtils.encryptAES(encryptable_payload, messagekey); - } catch (Exception e) { - LOG.error("error while encrypting", e); - return; - } - - } - - while (remaining > 0) { - int MAX_CHUNKLENGTH = mMTU - 3 - header_size; - int copybytes = Math.min(remaining, MAX_CHUNKLENGTH); - byte[] chunk = new byte[copybytes + header_size]; - - byte flags = 0; - if (encrypt) { - flags |= 0x08; - } - if (count == 0) { - flags |= 0x01; - int i = 4; - if (extended_flags) { - i++; - } - chunk[i++] = (byte) (length & 0xff); - chunk[i++] = (byte) ((length >> 8) & 0xff); - chunk[i++] = (byte) ((length >> 16) & 0xff); - chunk[i++] = (byte) ((length >> 24) & 0xff); - chunk[i++] = (byte) (type & 0xff); - chunk[i] = (byte) ((type >> 8) & 0xff); - } - if (remaining <= MAX_CHUNKLENGTH) { - flags |= 0x06; // last chunk? - } - chunk[0] = 0x03; - chunk[1] = flags; - if (extended_flags) { - chunk[2] = 0; - chunk[3] = handle; - chunk[4] = count; - } else { - chunk[2] = handle; - chunk[3] = count; - } - - System.arraycopy(data, data.length - remaining, chunk, header_size, copybytes); - builder.write(characteristicChunked2021Write, chunk); - remaining -= copybytes; - header_size = 4; - - if (extended_flags) { - header_size++; - } - - count++; + protected void writeToChunked2021(final String taskName, short type, byte[] data, boolean encrypt) { + try { + final TransactionBuilder builder = performInitialized(taskName); + writeToChunked2021(builder, type, data, encrypt); + builder.queue(getQueue()); + } catch (final Exception e) { + LOG.error("Failed to " + taskName, e); } } public void writeToConfiguration(TransactionBuilder builder, byte[] data) { - if (force2021Protocol) { + if (force2021Protocol()) { data = ArrayUtils.insert(0, data, (byte) 1); - writeToChunked2021(builder, HuamiService.CHUNKED2021_ENDPOINT_COMPAT, getNextHandle(), data, true, true); + writeToChunked2021(builder, Huami2021Service.CHUNKED2021_ENDPOINT_COMPAT, data, true); } else { builder.write(getCharacteristic(HuamiService.UUID_CHARACTERISTIC_3_CONFIGURATION), data); } @@ -3903,6 +3973,16 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return this; } + protected HuamiSupport requestDisplayItems(TransactionBuilder builder) { + LOG.warn("Function not implemented"); + return this; + } + + protected HuamiSupport requestShortcuts(TransactionBuilder builder) { + LOG.warn("Function not implemented"); + return this; + } + @Override public String customStringFilter(String inputString) { if (HuamiCoordinator.getUseCustomFont(gbDevice.getAddress())) { @@ -3911,7 +3991,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { return inputString; } - private String convertEmojiToCustomFont(String str) { StringBuilder sb = new StringBuilder(); int i = 0; @@ -3967,10 +4046,14 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { setHeartrateAlert(builder); setHeartrateStressMonitoring(builder); setDisconnectNotification(builder); - setExposeHRThridParty(builder); + setExposeHRThirdParty(builder); setHeartrateMeasurementInterval(builder, HuamiCoordinator.getHeartRateMeasurementInterval(getDevice().getAddress())); sendReminders(builder); setWorldClocks(builder); + for (final HuamiVibrationPatternNotificationType type : HuamiVibrationPatternNotificationType.values()) { + final String typeKey = type.name().toLowerCase(Locale.ROOT); + setVibrationPattern(builder, HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX + typeKey); + } if (!PasswordCapabilityImpl.Mode.NONE.equals(coordinator.getPasswordCapability())) { setPassword(builder); } @@ -3990,4 +4073,59 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport { public int getActivitySampleSize() { return mActivitySampleSize; } + + public boolean force2021Protocol() { + return GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()).getBoolean("force_new_protocol", false); + } + + @Override + public void handle2021Payload(int type, byte[] payload) { + if (type == Huami2021Service.CHUNKED2021_ENDPOINT_COMPAT) { + LOG.info("got configuration data"); + type = 0; + handleConfigurationInfo(ArrayUtils.remove(payload, 0)); + return; + } + + if (type == CHUNKED2021_ENDPOINT_CANNED_MESSAGES && false) { // unsafe for now, disabled + LOG.debug("got command for SMS reply"); + if (payload[0] == 0x0d) { + try { + TransactionBuilder builder = performInitialized("allow sms reply"); + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_CANNED_MESSAGES, new byte[]{(byte) CANNED_MESSAGES_CMD_REPLY_SMS_ALLOW, 0x01}, false); + builder.queue(getQueue()); + } catch (IOException e) { + LOG.error("Unable to allow sms reply"); + } + } else if (payload[0] == CANNED_MESSAGES_CMD_REPLY_SMS) { + String phoneNumber = null; + String smsReply = null; + for (int i = 1; i < payload.length; i++) { + if (payload[i] == 0) { + phoneNumber = new String(payload, 1, i - 1); + // there are four unknown bytes between caller and reply + smsReply = new String(payload, i + 5, payload.length - i - 6); + break; + } + } + if (phoneNumber != null && !phoneNumber.isEmpty()) { + LOG.debug("will send message '" + smsReply + "' to number '" + phoneNumber + "'"); + GBDeviceEventNotificationControl devEvtNotificationControl = new GBDeviceEventNotificationControl(); + devEvtNotificationControl.handle = -1; + devEvtNotificationControl.phoneNumber = phoneNumber; + devEvtNotificationControl.reply = smsReply; + devEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY; + evaluateGBDeviceEvent(devEvtNotificationControl); + try { + TransactionBuilder builder = performInitialized("ack sms reply"); + byte[] ackSentCommand = new byte[]{CANNED_MESSAGES_CMD_REPLY_SMS_ACK, 0x01}; + writeToChunked2021(builder, CHUNKED2021_ENDPOINT_CANNED_MESSAGES, ackSentCommand, false); + builder.queue(getQueue()); + } catch (IOException e) { + LOG.error("Unable to ack sms reply"); + } + } + } + } + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/UIHHContainer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/UIHHContainer.java new file mode 100644 index 000000000..25c4f0564 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/UIHHContainer.java @@ -0,0 +1,231 @@ +/* Copyright (C) 2022 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.huami; + +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.util.ArrayList; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.util.CheckSums; + +public class UIHHContainer { + private static final Logger LOG = LoggerFactory.getLogger(UIHHContainer.class); + + public static final byte[] UIHH_HEADER = new byte[]{ + 'U', 'I', 'H', 'H' + }; + + public List files = new ArrayList<>(); + + public List getFiles() { + return files; + } + + public byte[] getFile(final FileType fileType) { + for (final FileEntry file : files) { + if (file.getType() == fileType) { + return file.getContent(); + } + } + + return null; + } + + public void addFile(final FileType type, final byte[] bytes) { + files.add(new FileEntry(type, bytes)); + } + + @Nullable + public byte[] toRawBytes() { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + for (final FileEntry file : files) { + try { + baos.write(file.buildHeader()); + baos.write(file.getContent()); + } catch (final IOException e) { + LOG.error("Failed to generate UIHH bytes", e); + return null; + } + } + + final byte[] contentBytes = baos.toByteArray(); + final byte[] headerBytes = buildHeader(contentBytes); + + return ArrayUtils.addAll(headerBytes, contentBytes); + } + + @Nullable + public static UIHHContainer fromRawBytes(final byte[] bytes) { + if (bytes.length < 32) { + LOG.error("bytes array too small {}", bytes.length); + return null; + } + + if (!nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils.startsWith(bytes, UIHH_HEADER)) { + LOG.error("UIHH header not found"); + return null; + } + + final int crc32 = BLETypeConversions.toUint32(ArrayUtils.subarray(bytes, 12, 12 + 4)); + final int length = BLETypeConversions.toUint32(ArrayUtils.subarray(bytes, 22, 22 + 4)); + + if (length + 32 != bytes.length) { + LOG.error("Length mismatch between header and bytes: {}/{}", length, bytes.length); + return null; + } + + if (crc32 != CheckSums.getCRC32(ArrayUtils.subarray(bytes, 32, bytes.length))) { + LOG.error("CRC mismatch for content"); + return null; + } + + int i = 32; + + final UIHHContainer ret = new UIHHContainer(); + + while (i < bytes.length) { + if (i + 10 >= bytes.length) { + LOG.error("Not enough bytes remaining"); + return null; + } + + if (bytes[i] != 1) { + LOG.error("Expected 1 at position {}", i); + return null; + } + i++; + + final FileType type = FileType.fromValue(bytes[i]); + if (type == null) { + LOG.error("Unknown type byte {} at position {}", String.format("0x%x", bytes[i], i)); + return null; + } + i++; + + final int fileLength = BLETypeConversions.toUint32(ArrayUtils.subarray(bytes, i, i + 4)); + i += 4; + final int fileCrc32 = BLETypeConversions.toUint32(ArrayUtils.subarray(bytes, i, i + 4)); + i += 4; + + if (i + fileLength > bytes.length) { + LOG.error("Not enough bytes remaining to read a {} byte file", fileLength); + return null; + } + + final byte[] fileContent = ArrayUtils.subarray(bytes, i, i + fileLength); + if (fileCrc32 != CheckSums.getCRC32(fileContent)) { + LOG.error("CRC mismatch for {}", type); + return null; + } + i += fileLength; + + ret.getFiles().add(new FileEntry(type, fileContent)); + } + + return ret; + } + + private static byte[] buildHeader(final byte[] content) { + final ByteBuffer buf = ByteBuffer.allocate(32); + buf.order(ByteOrder.LITTLE_ENDIAN); + + buf.put(UIHH_HEADER); + buf.put(new byte[]{0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}); + buf.putInt(CheckSums.getCRC32(content)); + buf.put(new byte[]{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + buf.putInt(content.length); + buf.put(new byte[]{0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + + return buf.array(); + } + + public enum FileType { + FIRMWARE_ZIP(HuamiFirmwareType.FIRMWARE.getValue(), "firmware.zip"), + FIRMWARE_CHANGELOG(0x10, "changelog.txt"), + GPS_ALM_BIN(HuamiFirmwareType.GPS_ALMANAC.getValue(), "gps_alm.bin"), + GLN_ALM_BIN(0x0f, "gln_alm.bin"), + LLE_BDS_LLE(0x86, "lle_bds.lle"), + LLE_GPS_LLE(0x87, "lle_gps.lle"), + LLE_GLO_LLE(0x88, "lle_glo.lle"), + LLE_GAL_LLE(0x89, "lle_gal.lle"), + LLE_QZSS_LLE(0x8a, "lle_qzss.lle"), + ; + + private final byte value; + private final String name; + + FileType(final int value, final String name) { + this.value = (byte) value; + this.name = name; + } + + public byte getValue() { + return value; + } + + public static FileType fromValue(final byte value) { + for (final FileType fileType : values()) { + if (fileType.getValue() == value) { + return fileType; + } + } + + return null; + } + } + + public static class FileEntry { + private final FileType type; + private final byte[] content; + + public FileEntry(final FileType type, final byte[] content) { + this.type = type; + this.content = content; + } + + public FileType getType() { + return type; + } + + public byte[] getContent() { + return content; + } + + public byte[] buildHeader() { + final ByteBuffer buf = ByteBuffer.allocate(10); + buf.order(ByteOrder.LITTLE_ENDIAN); + + buf.put((byte) 0x01); + buf.put(type.getValue()); + buf.putInt(content.length); + buf.putInt(CheckSums.getCRC32(content)); + + return buf.array(); + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband3/MiBand3Support.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband3/MiBand3Support.java index 74705d0f9..f1cd8257c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband3/MiBand3Support.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband3/MiBand3Support.java @@ -66,63 +66,6 @@ public class MiBand3Support extends AmazfitBipSupport { return this; } - @Override - public void onSendConfiguration(String config) { - TransactionBuilder builder; - try { - builder = performInitialized("Sending configuration for option: " + config); - switch (config) { - case MiBandConst.PREF_NIGHT_MODE: - case MiBandConst.PREF_NIGHT_MODE_START: - case MiBandConst.PREF_NIGHT_MODE_END: - setNightMode(builder); - break; - default: - super.onSendConfiguration(config); - return; - } - builder.queue(getQueue()); - } catch (IOException e) { - GB.toast("Error setting configuration", Toast.LENGTH_LONG, GB.ERROR, e); - } - } - - private MiBand3Support setNightMode(TransactionBuilder builder) { - String nightMode = MiBand3Coordinator.getNightMode(gbDevice.getAddress()); - LOG.info("Setting night mode to " + nightMode); - - switch (nightMode) { - case MiBandConst.PREF_NIGHT_MODE_SUNSET: - writeToConfiguration(builder, MiBand3Service.COMMAND_NIGHT_MODE_SUNSET); - break; - case MiBandConst.PREF_NIGHT_MODE_OFF: - writeToConfiguration(builder, MiBand3Service.COMMAND_NIGHT_MODE_OFF); - break; - case MiBandConst.PREF_NIGHT_MODE_SCHEDULED: - byte[] cmd = MiBand3Service.COMMAND_NIGHT_MODE_SCHEDULED.clone(); - - Calendar calendar = GregorianCalendar.getInstance(); - - Date start = MiBand3Coordinator.getNightModeStart(gbDevice.getAddress()); - calendar.setTime(start); - cmd[2] = (byte) calendar.get(Calendar.HOUR_OF_DAY); - cmd[3] = (byte) calendar.get(Calendar.MINUTE); - - Date end = MiBand3Coordinator.getNightModeEnd(gbDevice.getAddress()); - calendar.setTime(end); - cmd[4] = (byte) calendar.get(Calendar.HOUR_OF_DAY); - cmd[5] = (byte) calendar.get(Calendar.MINUTE); - - writeToConfiguration(builder, cmd); - break; - default: - LOG.error("Invalid night mode: " + nightMode); - break; - } - - return this; - } - @Override public void phase2Initialize(TransactionBuilder builder) { super.phase2Initialize(builder); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband7/MiBand7FirmwareInfo.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband7/MiBand7FirmwareInfo.java new file mode 100644 index 000000000..6f351e8c5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband7/MiBand7FirmwareInfo.java @@ -0,0 +1,55 @@ +/* Copyright (C) 2022 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.huami.miband7; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021FirmwareInfo; + +public class MiBand7FirmwareInfo extends Huami2021FirmwareInfo { + private static final Logger LOG = LoggerFactory.getLogger(MiBand7FirmwareInfo.class); + + private static final Map crcToVersion = new HashMap() {{ + // firmware + }}; + + public MiBand7FirmwareInfo(final byte[] bytes) { + super(bytes); + } + + @Override + public String deviceName() { + return HuamiConst.XIAOMI_SMART_BAND7_NAME; + } + + @Override + public boolean isGenerallyCompatibleWith(final GBDevice device) { + return isHeaderValid() && device.getType() == DeviceType.MIBAND7; + } + + @Override + protected Map getCrcMap() { + return crcToVersion; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband7/MiBand7Support.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband7/MiBand7Support.java new file mode 100644 index 000000000..b18547da2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/miband7/MiBand7Support.java @@ -0,0 +1,59 @@ +/* Copyright (C) 2022 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.huami.miband7; + +import android.content.Context; +import android.net.Uri; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband7.MiBand7FWHelper; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Support; + +public class MiBand7Support extends Huami2021Support { + private static final Logger LOG = LoggerFactory.getLogger(MiBand7Support.class); + + @Override + public HuamiFWHelper createFWHelper(Uri uri, Context context) throws IOException { + return new MiBand7FWHelper(uri, context); + } + + @Override + protected int getAllDisplayItems() { + return R.array.pref_miband7_display_items_values; + } + + @Override + protected int getDefaultDisplayItems() { + return R.array.pref_miband7_display_items_default; + } + + @Override + protected int getAllShortcutItems() { + return R.array.pref_miband7_shortcuts_values; + } + + @Override + protected int getDefaultShortcutItems() { + return R.array.pref_miband7_shortcuts_default; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java index 4980362c8..1713e8604 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/AbstractFetchOperation.java @@ -85,7 +85,6 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation { lastPacketCounter = -1; TransactionBuilder builder = performInitialized(getName()); - getSupport().setLowLatency(builder); if (fetchCount == 0) { builder.add(new SetDeviceBusyAction(getDevice(), getContext().getString(R.string.busy_task_fetch_activity_data), getContext())); } @@ -176,6 +175,7 @@ public abstract class AbstractFetchOperation extends AbstractHuamiOperation { } private void handleActivityMetadata(byte[] value) { + // TODO it's 16 on the MB7 if (value.length == 15) { // first two bytes are whether our request was accepted if (ArrayUtils.equals(value, HuamiService.RESPONSE_ACTIVITY_DATA_START_DATE_SUCCESS, 0)) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java index 344daf8a9..a17b3c840 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/HuamiFetchDebugLogsOperation.java @@ -35,7 +35,7 @@ import androidx.annotation.NonNull; import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitbip.AmazfitBipService; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; -import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.amazfitbip.AmazfitBipSupport; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; @@ -44,7 +44,7 @@ public class HuamiFetchDebugLogsOperation extends AbstractFetchOperation { private FileOutputStream logOutputStream; - public HuamiFetchDebugLogsOperation(AmazfitBipSupport support) { + public HuamiFetchDebugLogsOperation(HuamiSupport support) { super(support); setName("fetch debug logs"); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/InitOperation2021.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/InitOperation2021.java index 8764bb5cf..9190e672e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/InitOperation2021.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/InitOperation2021.java @@ -25,15 +25,23 @@ import org.slf4j.LoggerFactory; import java.util.Random; import java.util.UUID; +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Handler; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021ChunkedDecoder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021ChunkedEncoder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; -public class InitOperation2021 extends InitOperation { +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.SUCCESS; +import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.RESPONSE; + +public class InitOperation2021 extends InitOperation implements Huami2021Handler { private byte[] privateEC = new byte[24]; private byte[] publicEC; private byte[] remotePublicEC = new byte[48]; @@ -41,10 +49,8 @@ public class InitOperation2021 extends InitOperation { private byte[] sharedEC; private final byte[] finalSharedSessionAES = new byte[16]; - private final byte[] reassembleBuffer = new byte[512]; - private int lastSequenceNumber = 0; - private int reassembleBuffer_pointer = 0; - private int reassembleBuffer_expectedBytes = 0; + private final Huami2021ChunkedEncoder huami2021ChunkedEncoder; + private final Huami2021ChunkedDecoder huami2021ChunkedDecoder; static { System.loadLibrary("tiny-edhc"); @@ -53,8 +59,17 @@ public class InitOperation2021 extends InitOperation { private static final Logger LOG = LoggerFactory.getLogger(InitOperation2021.class); - public InitOperation2021(boolean needsAuth, byte authFlags, byte cryptFlags, HuamiSupport support, TransactionBuilder builder) { + public InitOperation2021(final boolean needsAuth, + final byte authFlags, + final byte cryptFlags, + final HuamiSupport support, + final TransactionBuilder builder, + final Huami2021ChunkedEncoder huami2021ChunkedEncoder, + final Huami2021ChunkedDecoder huami2021ChunkedDecoder) { super(needsAuth, authFlags, cryptFlags, support, builder); + this.huami2021ChunkedEncoder = huami2021ChunkedEncoder; + this.huami2021ChunkedDecoder = huami2021ChunkedDecoder; + this.huami2021ChunkedDecoder.setHuami2021Handler(this); } private void testAuth() { @@ -87,7 +102,7 @@ public class InitOperation2021 extends InitOperation { sendPubkeyCommand[3] = 0x02; System.arraycopy(publicEC, 0, sendPubkeyCommand, 4, 48); //testAuth(); - huamiSupport.writeToChunked2021(builder, HuamiService.CHUNKED2021_ENDPOINT_AUTH, huamiSupport.getNextHandle(), sendPubkeyCommand, true, false); + huami2021ChunkedEncoder.write(builder, Huami2021Service.CHUNKED2021_ENDPOINT_AUTH, sendPubkeyCommand, true, false); } private native byte[] ecdh_generate_public(byte[] privateEC); @@ -110,83 +125,83 @@ public class InitOperation2021 extends InitOperation { public boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { UUID characteristicUUID = characteristic.getUuid(); - if (HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ.equals(characteristicUUID)) { - byte[] value = characteristic.getValue(); - if (value.length > 1 && value[0] == 0x03) { - int sequenceNumber = value[4]; - int headerSize; - if (sequenceNumber == 0 && value[9] == (byte) HuamiService.CHUNKED2021_ENDPOINT_AUTH && value[10] == 0x00 && value[11] == 0x10 && value[12] == 0x04 && value[13] == 0x01) { - reassembleBuffer_pointer = 0; - headerSize = 14; - reassembleBuffer_expectedBytes = value[5] - 3; - - } else if (sequenceNumber > 0) { - if (sequenceNumber != lastSequenceNumber + 1) { - LOG.warn("unexpected sequence number"); - return false; - } - headerSize = 5; - } else if (value[9] == (byte) HuamiService.CHUNKED2021_ENDPOINT_AUTH && value[10] == 0x00 && value[11] == 0x10 && value[12] == 0x05 && value[13] == 0x01) { - try { - TransactionBuilder builder = createTransactionBuilder("Authenticated, now initialize phase 2"); - builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); - huamiSupport.enableFurtherNotifications(builder, true); - huamiSupport.setCurrentTimeWithService(builder); - huamiSupport.requestDeviceInfo(builder); - huamiSupport.phase2Initialize(builder); - huamiSupport.phase3Initialize(builder); - huamiSupport.setInitialized(builder); - huamiSupport.performImmediately(builder); - } catch (Exception e) { - LOG.error("failed initializing device", e); - } - return true; - } else { - LOG.info("Unhandled characteristic changed: " + characteristicUUID); - return super.onCharacteristicChanged(gatt, characteristic); - } - - int bytesToCopy = value.length - headerSize; - System.arraycopy(value, headerSize, reassembleBuffer, reassembleBuffer_pointer, bytesToCopy); - reassembleBuffer_pointer += bytesToCopy; - - lastSequenceNumber = sequenceNumber; - if (reassembleBuffer_pointer == reassembleBuffer_expectedBytes) { - System.arraycopy(reassembleBuffer, 0, remoteRandom, 0, 16); - System.arraycopy(reassembleBuffer, 16, remotePublicEC, 0, 48); - sharedEC = ecdh_generate_shared(privateEC, remotePublicEC); - huamiSupport.encryptedSequenceNr = ((sharedEC[0] & 0xff) | ((sharedEC[1] & 0xff) << 8) | ((sharedEC[2] & 0xff) << 16) | ((sharedEC[3] & 0xff) << 24)); - - byte[] secretKey = getSecretKey(); - for (int i = 0; i < 16; i++) { - finalSharedSessionAES[i] = (byte) (sharedEC[i + 8] ^ secretKey[i]); - } - huamiSupport.sharedSessionKey = finalSharedSessionAES; - try { - byte[] encryptedRandom1 = CryptoUtils.encryptAES(remoteRandom, secretKey); - byte[] encryptedRandom2 = CryptoUtils.encryptAES(remoteRandom, finalSharedSessionAES); - if (encryptedRandom1.length == 16 && encryptedRandom2.length == 16) { - byte[] command = new byte[33]; - command[0] = 0x05; - System.arraycopy(encryptedRandom1, 0, command, 1, 16); - System.arraycopy(encryptedRandom2, 0, command, 17, 16); - TransactionBuilder builder = createTransactionBuilder("Sending double encryted random to device"); - huamiSupport.writeToChunked2021(builder, HuamiService.CHUNKED2021_ENDPOINT_AUTH, huamiSupport.getNextHandle(), command, true, false); - huamiSupport.performImmediately(builder); - } - } catch (Exception e) { - LOG.error("AES encryption failed", e); - } - } - return true; - } - - huamiSupport.logMessageContent(value); - return super.onCharacteristicChanged(gatt, characteristic); - } else { + if (!HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER_2021_READ.equals(characteristicUUID)) { LOG.info("Unhandled characteristic changed: " + characteristicUUID); return super.onCharacteristicChanged(gatt, characteristic); } + byte[] value = characteristic.getValue(); + if (value.length <= 1 || value[0] != 0x03) { + // Not chunked + return super.onCharacteristicChanged(gatt, characteristic); + } + + this.huami2021ChunkedDecoder.decode(value); + return true; + } + + @Override + public void handle2021Payload(final int type, final byte[] payload) { + if (type != Huami2021Service.CHUNKED2021_ENDPOINT_AUTH) { + this.huamiSupport.handle2021Payload(type, payload); + return; + } + + if (payload[0] == RESPONSE && payload[1] == 0x04 && payload[2] == SUCCESS) { + LOG.debug("Got remote random + public key"); + // Received remote random (16 bytes) + public key (48 bytes) + + System.arraycopy(payload, 3, remoteRandom, 0, 16); + System.arraycopy(payload, 19, remotePublicEC, 0, 48); + sharedEC = ecdh_generate_shared(privateEC, remotePublicEC); + int encryptedSequenceNumber = (sharedEC[0] & 0xff) | ((sharedEC[1] & 0xff) << 8) | ((sharedEC[2] & 0xff) << 16) | ((sharedEC[3] & 0xff) << 24); + + byte[] secretKey = getSecretKey(); + for (int i = 0; i < 16; i++) { + finalSharedSessionAES[i] = (byte) (sharedEC[i + 8] ^ secretKey[i]); + } + + if (BuildConfig.DEBUG) { + LOG.debug("Shared Session Key: {}", GB.hexdump(finalSharedSessionAES)); + } + huami2021ChunkedEncoder.setEncryptionParameters(encryptedSequenceNumber, finalSharedSessionAES); + huami2021ChunkedDecoder.setEncryptionParameters(finalSharedSessionAES); + + try { + byte[] encryptedRandom1 = CryptoUtils.encryptAES(remoteRandom, secretKey); + byte[] encryptedRandom2 = CryptoUtils.encryptAES(remoteRandom, finalSharedSessionAES); + if (encryptedRandom1.length == 16 && encryptedRandom2.length == 16) { + byte[] command = new byte[33]; + command[0] = 0x05; + System.arraycopy(encryptedRandom1, 0, command, 1, 16); + System.arraycopy(encryptedRandom2, 0, command, 17, 16); + TransactionBuilder builder = createTransactionBuilder("Sending double encryted random to device"); + huami2021ChunkedEncoder.write(builder, Huami2021Service.CHUNKED2021_ENDPOINT_AUTH, command, true, false); + huamiSupport.performImmediately(builder); + } + } catch (Exception e) { + LOG.error("AES encryption failed", e); + } + } else if (payload[0] == RESPONSE && payload[1] == 0x05 && payload[2] == SUCCESS) { + LOG.debug("Auth Success"); + + try { + TransactionBuilder builder = createTransactionBuilder("Authenticated, now initialize phase 2"); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + huamiSupport.enableFurtherNotifications(builder, true); + huamiSupport.setCurrentTimeWithService(builder); + huamiSupport.requestDeviceInfo(builder); + huamiSupport.phase2Initialize(builder); + huamiSupport.phase3Initialize(builder); + huamiSupport.setInitialized(builder); + huamiSupport.performImmediately(builder); + } catch (Exception e) { + LOG.error("failed initializing device", e); + } + return; + } else { + LOG.info("Unhandled auth payload: {}", GB.hexdump(payload)); + return; + } } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperation.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperation.java index adea8e892..73cf1845d 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperation.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperation.java @@ -39,6 +39,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceBusyAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetProgressAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiFirmwareInfo; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareInfo; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType; @@ -53,7 +54,7 @@ public class UpdateFirmwareOperation extends AbstractHuamiOperation { final BluetoothGattCharacteristic fwCControlChar; final BluetoothGattCharacteristic fwCDataChar; protected final Prefs prefs = GBApplication.getPrefs(); - protected HuamiFirmwareInfo firmwareInfo; + protected AbstractHuamiFirmwareInfo firmwareInfo; public UpdateFirmwareOperation(Uri uri, HuamiSupport support) { super(support); @@ -81,7 +82,7 @@ public class UpdateFirmwareOperation extends AbstractHuamiOperation { //the firmware will be sent by the notification listener if the band confirms that the metadata are ok. } - HuamiFirmwareInfo createFwInfo(Uri uri, Context context) throws IOException { + AbstractHuamiFirmwareInfo createFwInfo(Uri uri, Context context) throws IOException { HuamiFWHelper fwHelper = getSupport().createFWHelper(uri, context); return fwHelper.getFirmwareInfo(); } @@ -229,7 +230,7 @@ public class UpdateFirmwareOperation extends AbstractHuamiOperation { * @return whether the transfer succeeded or not. Only a BT layer exception will cause the transmission to fail. * @see #handleNotificationNotif */ - private boolean sendFirmwareData(HuamiFirmwareInfo info) { + private boolean sendFirmwareData(AbstractHuamiFirmwareInfo info) { byte[] fwbytes = info.getBytes(); int len = fwbytes.length; final int packetLength = getSupport().getMTU() - 3; @@ -272,7 +273,7 @@ public class UpdateFirmwareOperation extends AbstractHuamiOperation { } - protected void sendChecksum(HuamiFirmwareInfo firmwareInfo) throws IOException { + protected void sendChecksum(AbstractHuamiFirmwareInfo firmwareInfo) throws IOException { TransactionBuilder builder = performInitialized("send firmware checksum"); int crc16 = firmwareInfo.getCrc16(); byte[] bytes = BLETypeConversions.fromUint16(crc16); @@ -284,7 +285,7 @@ public class UpdateFirmwareOperation extends AbstractHuamiOperation { builder.queue(getQueue()); } - HuamiFirmwareInfo getFirmwareInfo() { + AbstractHuamiFirmwareInfo getFirmwareInfo() { return firmwareInfo; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperation2020.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperation2020.java index 50e563f01..cfb2fcdce 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperation2020.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperation2020.java @@ -31,6 +31,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceBusyAction; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetProgressAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiFirmwareInfo; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareInfo; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; @@ -47,6 +48,7 @@ public class UpdateFirmwareOperation2020 extends UpdateFirmwareOperation { } private final byte COMMAND_REQUEST_PARAMETERS = (byte) 0xd0; + private final byte COMMAND_UNKNOWN_D1 = (byte) 0xd1; private final byte COMMAND_SEND_FIRMWARE_INFO = (byte) 0xd2; private final byte COMMAND_START_TRANSFER = (byte) 0xd3; private final byte REPLY_UPDATE_PROGRESS = (byte) 0xd4; @@ -71,12 +73,12 @@ public class UpdateFirmwareOperation2020 extends UpdateFirmwareOperation { @Override protected void handleNotificationNotif(byte[] value) { - if (value.length != 3 && value.length != 6 && value.length != 11) { - LOG.error("Notifications should be 3, 6 or 11 bytes long."); + if (value.length != 3 && value.length != 6 && value.length != 7 && value.length != 11) { + LOG.error("Notifications should be 3, 6, 7 or 11 bytes long."); getSupport().logMessageContent(value); return; } - boolean success = (value[2] == HuamiService.SUCCESS) || ((value[1] == REPLY_UPDATE_PROGRESS) && value.length == 6); // ugly + boolean success = (value[2] == HuamiService.SUCCESS) || ((value[1] == REPLY_UPDATE_PROGRESS) && value.length >= 6); // ugly if (value[0] == HuamiService.RESPONSE && success) { try { @@ -214,7 +216,7 @@ public class UpdateFirmwareOperation2020 extends UpdateFirmwareOperation { } - private boolean sendFirmwareDataChunk(HuamiFirmwareInfo info, int offset) { + private boolean sendFirmwareDataChunk(AbstractHuamiFirmwareInfo info, int offset) { byte[] fwbytes = info.getBytes(); int len = fwbytes.length; int remaining = len - offset; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperationNew.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperationNew.java index d511312b3..e187fbaf3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperationNew.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/operations/UpdateFirmwareOperationNew.java @@ -28,6 +28,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceBusyAction; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuamiFirmwareInfo; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareInfo; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiFirmwareType; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiSupport; @@ -87,7 +88,7 @@ public class UpdateFirmwareOperationNew extends UpdateFirmwareOperation { } @Override - protected void sendChecksum(HuamiFirmwareInfo firmwareInfo) throws IOException { + protected void sendChecksum(AbstractHuamiFirmwareInfo firmwareInfo) throws IOException { TransactionBuilder builder = performInitialized("send firmware upload finished"); builder.write(fwCControlChar, new byte[]{HuamiService.COMMAND_FIRMWARE_CHECKSUM}); builder.queue(getQueue()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java index 5e5150b3e..f7134a219 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java @@ -208,6 +208,12 @@ public abstract class AbstractSerialDeviceSupport extends AbstractDeviceSupport sendToDevice(bytes); } + @Override + public void onPhoneFound() { + byte[] bytes = gbDeviceProtocol.encodePhoneFound(); + sendToDevice(bytes); + } + @Override public void onScreenshotReq() { byte[] bytes = gbDeviceProtocol.encodeScreenshotReq(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java index 580d56292..927b97c35 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/GBDeviceProtocol.java @@ -110,6 +110,10 @@ public abstract class GBDeviceProtocol { return null; } + public byte[] encodePhoneFound() { + return null; + } + public byte[] encodeEnableRealtimeSteps(boolean enable) { return null; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BitmapUtil.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BitmapUtil.java index ae9d0f71b..09345c9f5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BitmapUtil.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/BitmapUtil.java @@ -30,6 +30,8 @@ import android.graphics.RectF; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import java.nio.ByteBuffer; + public class BitmapUtil { /** * Downscale a bitmap to a maximum resolution. Doesn't scale if the bitmap is already smaller than the max resolution. @@ -205,4 +207,81 @@ public class BitmapUtil { canvas.drawBitmap(bmp2, new Matrix(), null); return bmOverlay; } + + /** + * Converts a {@link Drawable} to a {@link Bitmap}, in ARGB8888 mode. + * + * @param drawable the {@link Drawable} + * @return the {@link Bitmap} + */ + public static Bitmap toBitmap(final Drawable drawable) { + if (drawable instanceof BitmapDrawable) { + return ((BitmapDrawable) drawable).getBitmap(); + } + + final Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + return bitmap; + } + + /** + * Converts a {@link Bitmap} to an uncompressed TGA image, as raw bytes, in RGB565 encoding. + * @param bmp the {@link Bitmap} to convert. + * @param width the target width + * @param height the target height + * @param id the TGA ID + * @return the raw bytes for the TGA image + */ + public static byte[] convertToTgaRGB565(final Bitmap bmp, final int width, final int height, final byte[] id) { + final Bitmap bmp565; + if (bmp.getConfig().equals(Bitmap.Config.RGB_565) && bmp.getWidth() == width && bmp.getHeight() == height) { + // Right encoding and size + bmp565 = bmp; + } else { + // Convert encoding / scale + bmp565 = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + final Canvas canvas = new Canvas(bmp565); + final Rect rect = new Rect(0, 0, width, height); + canvas.drawBitmap(bmp, null, rect, null); + } + + int size = bmp565.getRowBytes() * bmp565.getHeight(); + final ByteBuffer bmp565buf = ByteBuffer.allocate(size); + bmp565.copyPixelsToBuffer(bmp565buf); + + // As per https://en.wikipedia.org/wiki/Truevision_TGA + // 18 bytes + final byte[] header = { + // ID length + (byte) id.length, + // Color map type - (0 - no color map) + 0x00, + // Image type (2 - uncompressed true-color image) + 0x02, + // Color map specification (5 bytes) + 0x00, 0x00, // first entry index + 0x00, 0x00, /// color map length + 0x00, // color map entry size + // Image dimensions and format (10 bytes) + 0x00, 0x00, // x origin + 0x00, 0x00, // y origin + (byte) (width & 0xff), (byte) ((width >> 8) & 0xff), // width + (byte) (height & 0xff), (byte) ((height >> 8) & 0xff), // height + 16, // bits per pixel (10) + 0x20, // image descriptor (0x20, 00100000) + // bits 3-0 give the alpha channel depth, bits 5-4 give pixel ordering + // Bit 4 of the image descriptor byte indicates right-to-left pixel ordering if set. + // Bit 5 indicates an ordering of top-to-bottom. Otherwise, pixels are stored in bottom-to-top, left-to-right order. + }; + + final ByteBuffer tga565buf = ByteBuffer.allocate(header.length + id.length + size); + + tga565buf.put(header); + tga565buf.put(id); + tga565buf.put(bmp565buf.array()); + + return tga565buf.array(); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java index d7d2fe3f5..e92444400 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/DeviceHelper.java @@ -77,6 +77,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitpoppro.AmazfitP import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfittrexpro.AmazfitTRexProCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitx.AmazfitXCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband6.MiBand6Coordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.miband7.MiBand7Coordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppe.ZeppECoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgtr2.AmazfitGTR2eCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.amazfitgts.AmazfitGTSCoordinator; @@ -283,6 +284,7 @@ public class DeviceHelper { result.add(new MiBand4Coordinator()); result.add(new MiBand5Coordinator()); result.add(new MiBand6Coordinator()); + result.add(new MiBand7Coordinator()); result.add(new MiBand2HRXCoordinator()); result.add(new MiBand2Coordinator()); // Note: MiBand2 and all of the above must come before MiBand because detection is hacky, atm result.add(new MiBandCoordinator()); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java index d848a8de0..0ab53e341 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/GBPrefs.java @@ -105,12 +105,12 @@ public class GBPrefs { } public String getTimeFormat() { - String timeFormat = mPrefs.getString(DeviceSettingsPreferenceConst.PREF_TIMEFORMAT, "auto"); - if ("auto".equals(timeFormat)) { + String timeFormat = mPrefs.getString(DeviceSettingsPreferenceConst.PREF_TIMEFORMAT, DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_AUTO); + if (DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_AUTO.equals(timeFormat)) { if (DateFormat.is24HourFormat(GBApplication.getContext())) { - timeFormat = "24h"; + timeFormat = DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_24H; } else { - timeFormat = "am/pm"; + timeFormat = DeviceSettingsPreferenceConst.PREF_TIMEFORMAT_12H; } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/MapUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/MapUtils.java new file mode 100644 index 000000000..e6a4edc71 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/MapUtils.java @@ -0,0 +1,42 @@ +/* Copyright (C) 2022 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.util; + +import java.util.HashMap; +import java.util.Map; + +public final class MapUtils { + /** + * Reverses a map. If there are multiple values for the same key, the first one will take precedence. + * + * @param map the map to reverse + * @param the type for the values + * @param the type for the keys + * @return the reversed map + */ + public static Map reverse(final Map map) { + final Map reversed = new HashMap<>(); + + for (final Map.Entry entry : map.entrySet()) { + if (!reversed.containsKey(entry.getValue())) { + reversed.put(entry.getValue(), entry.getKey()); + } + } + + return reversed; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java index 8a8243f12..ea9d415ee 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java @@ -23,6 +23,9 @@ import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.ArrayUtils; public class StringUtils { @@ -133,6 +136,17 @@ public class StringUtils { return new String(newArray); } + @Nullable + public static String untilNullTerminator(final byte[] bytes, final int startOffset) { + for (int i = startOffset; i < bytes.length; i++) { + if (bytes[i] == 0) { + return new String(ArrayUtils.subarray(bytes, startOffset, i)); + } + } + + return null; + } + public static String bytesToHex(byte[] array) { return GB.hexdump(array, 0, -1); } diff --git a/app/src/main/res/drawable/ic_always_on_display.xml b/app/src/main/res/drawable/ic_always_on_display.xml new file mode 100644 index 000000000..975e95882 --- /dev/null +++ b/app/src/main/res/drawable/ic_always_on_display.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_hourglass_empty.xml b/app/src/main/res/drawable/ic_hourglass_empty.xml new file mode 100644 index 000000000..465fcb881 --- /dev/null +++ b/app/src/main/res/drawable/ic_hourglass_empty.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_debug.xml b/app/src/main/res/layout/activity_debug.xml index ec93917b2..9de38248b 100644 --- a/app/src/main/res/layout/activity_debug.xml +++ b/app/src/main/res/layout/activity_debug.xml @@ -123,6 +123,14 @@ grid:layout_gravity="fill_horizontal" android:text="Set Activity Fetch Time" /> +