From 9d3c48041408ad5deec80acfd5d94a2473165d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Fri, 16 Jun 2023 20:43:05 +0100 Subject: [PATCH] Zepp OS: Refactor config, display items, reminders and http to standalone services --- .../devices/huami/Huami2021Coordinator.java | 3 +- .../devices/huami/Huami2021Service.java | 46 -- .../devices/huami/Huami2021Support.java | 649 +----------------- .../service/devices/huami/HuamiSupport.java | 9 +- .../huami/zeppos/AbstractZeppOsService.java | 13 + .../zeppos/services/ZeppOsConfigService.java | 30 + .../services/ZeppOsDisplayItemsService.java | 377 ++++++++++ .../zeppos/services/ZeppOsHttpService.java | 162 +++++ .../services/ZeppOsRemindersService.java | 267 +++++++ 9 files changed, 875 insertions(+), 681 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsDisplayItemsService.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsHttpService.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsRemindersService.java 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 index 49ae67868..551c57ef4 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Coordinator.java @@ -52,6 +52,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.AbstractHuami2 import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAlexaService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsContactsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLogsService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsRemindersService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsShortcutCardsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiLanguageType; @@ -241,7 +242,7 @@ public abstract class Huami2021Coordinator extends HuamiCoordinator { @Override public int getReminderSlotCount(final GBDevice device) { - return getPrefs(device).getInt(Huami2021Service.REMINDERS_PREF_CAPABILITY, 0); + return ZeppOsRemindersService.getSlotCount(getPrefs(device)); } @Override 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 index 007016c74..7f5efdbe9 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/Huami2021Service.java @@ -20,7 +20,6 @@ public class Huami2021Service { /** * Endpoints for 2021 chunked protocol */ - public static final short CHUNKED2021_ENDPOINT_HTTP = 0x0001; public static final short CHUNKED2021_ENDPOINT_WEATHER = 0x000e; public static final short CHUNKED2021_ENDPOINT_CONNECTION = 0x0015; public static final short CHUNKED2021_ENDPOINT_USER_INFO = 0x0017; @@ -30,37 +29,11 @@ public class Huami2021Service { 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_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; - - /** - * 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_CONTROL_CENTER = 0x03; - 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}. */ @@ -157,25 +130,6 @@ public class Huami2021Service { public static final byte MUSIC_BUTTON_VOLUME_UP = 0x05; public static final byte MUSIC_BUTTON_VOLUME_DOWN = 0x06; - /** - * Reminders, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_REMINDERS}. - */ - public static final byte REMINDERS_CMD_CAPABILITIES_REQUEST = 0x01; - public static final byte REMINDERS_CMD_CAPABILITIES_RESPONSE = 0x02; - 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; - public static final String REMINDERS_PREF_CAPABILITY = "huami_2021_capability_reminders"; - /** * Weather, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_WEATHER}. */ 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 index 9c82bfade..d4911f660 100644 --- 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 @@ -118,8 +118,11 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAppsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsCalendarService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsCannedMessagesService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsDisplayItemsService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsHttpService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsLogsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsNotificationService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsRemindersService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsServicesService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsShortcutCardsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService; @@ -164,6 +167,9 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil private final ZeppOsAlexaService alexaService = new ZeppOsAlexaService(this); private final ZeppOsAppsService appsService = new ZeppOsAppsService(this); private final ZeppOsLogsService logsService = new ZeppOsLogsService(this); + private final ZeppOsDisplayItemsService displayItemsService = new ZeppOsDisplayItemsService(this); + private final ZeppOsHttpService httpService = new ZeppOsHttpService(this); + private final ZeppOsRemindersService remindersService = new ZeppOsRemindersService(this); private final Map mServiceMap = new LinkedHashMap() {{ put(servicesService.getEndpoint(), servicesService); @@ -184,6 +190,9 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil put(alexaService.getEndpoint(), alexaService); put(appsService.getEndpoint(), appsService); put(logsService.getEndpoint(), logsService); + put(displayItemsService.getEndpoint(), displayItemsService); + put(httpService.getEndpoint(), httpService); + put(remindersService.getEndpoint(), remindersService); }}; public Huami2021Support() { @@ -215,7 +224,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil @Override public void onSendConfiguration(final String config) { - final ZeppOsConfigService.ConfigSetter configSetter = configService.newSetter(); final Prefs prefs = getDevicePrefs(); // Check if any of the services handles this config @@ -225,28 +233,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil } } - // Other preferences - switch (config) { - case HuamiConst.PREF_CONTROL_CENTER_SORTABLE: - setControlCenter(); - return; - } - - // Defer everything else to the configService - try { - if (configService.setConfig(prefs, config, configSetter)) { - // If the ConfigSetter was able to set the config, just write it and return - final TransactionBuilder builder; - builder = performInitialized("Sending configuration for option: " + config); - configSetter.write(builder); - builder.queue(getQueue()); - - return; - } - } catch (final Exception e) { - GB.toast("Error setting configuration", Toast.LENGTH_LONG, GB.ERROR, e); - } - super.onSendConfiguration(config); } @@ -516,83 +502,16 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil notificationService.sendNotification(notificationSpec); } - protected Huami2021Support requestReminders(final TransactionBuilder builder) { - LOG.info("Requesting reminders"); - - writeToChunked2021(builder, CHUNKED2021_ENDPOINT_REMINDERS, 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); - final int reminderSlotCount = coordinator.getReminderSlotCount(getDevice()); - if (position + 1 > reminderSlotCount) { - LOG.error("Reminder for position {} is over the limit of {} reminders", position, reminderSlotCount); - return; + public void onSetReminders(final ArrayList reminders) { + final TransactionBuilder builder; + try { + builder = performInitialized("onSetReminders"); + remindersService.sendReminders(builder, reminders); + builder.queue(getQueue()); + } catch (final IOException e) { + LOG.error("Unable to send reminders to device", e); } - - if (reminder == null) { - // Delete reminder - writeToChunked2021(builder, CHUNKED2021_ENDPOINT_REMINDERS, new byte[]{REMINDERS_CMD_DELETE, (byte) (position & 0xFF)}, false); - - return; - } - - final String message; - if (reminder.getMessage().length() > coordinator.getMaximumReminderMessageLength()) { - LOG.warn("The reminder message length {} is longer than {}, will be truncated", - reminder.getMessage().length(), - coordinator.getMaximumReminderMessageLength() - ); - message = StringUtils.truncate(reminder.getMessage(), coordinator.getMaximumReminderMessageLength()); - } else { - message = reminder.getMessage(); - } - - final ByteBuffer buf = ByteBuffer.allocate(1 + 10 + message.getBytes(StandardCharsets.UTF_8).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 = createCalendar(); - 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); - - buf.put(message.getBytes(StandardCharsets.UTF_8)); - buf.put((byte) 0x00); - - writeToChunked2021(builder, CHUNKED2021_ENDPOINT_REMINDERS, buf.array(), false); } @Override @@ -898,173 +817,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil return this; } - @Override - protected Huami2021Support setDisplayItems(final TransactionBuilder builder) { - final Prefs prefs = getDevicePrefs(); - - setDisplayItems2021( - builder, - DISPLAY_ITEMS_MENU, - new ArrayList<>(prefs.getList(Huami2021Coordinator.getPrefPossibleValuesKey(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE), Collections.emptyList())), - new ArrayList<>(prefs.getList(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE, Collections.emptyList())) - ); - return this; - } - - @Override - protected Huami2021Support setShortcuts(final TransactionBuilder builder) { - final Prefs prefs = getDevicePrefs(); - - setDisplayItems2021( - builder, - DISPLAY_ITEMS_SHORTCUTS, - new ArrayList<>(prefs.getList(Huami2021Coordinator.getPrefPossibleValuesKey(HuamiConst.PREF_SHORTCUTS_SORTABLE), Collections.emptyList())), - new ArrayList<>(prefs.getList(HuamiConst.PREF_SHORTCUTS_SORTABLE, Collections.emptyList())) - ); - return this; - } - - protected void setControlCenter() { - try { - final TransactionBuilder builder = performInitialized("set control center"); - - final Prefs prefs = getDevicePrefs(); - - setDisplayItems2021( - builder, - DISPLAY_ITEMS_CONTROL_CENTER, - new ArrayList<>(prefs.getList(Huami2021Coordinator.getPrefPossibleValuesKey(HuamiConst.PREF_CONTROL_CENTER_SORTABLE), Collections.emptyList())), - new ArrayList<>(prefs.getList(HuamiConst.PREF_CONTROL_CENTER_SORTABLE, Collections.emptyList())) - ); - - builder.queue(getQueue()); - } catch (final Exception e) { - GB.toast("Error setting control center", Toast.LENGTH_LONG, GB.ERROR, e); - } - } - - private void setDisplayItems2021(final TransactionBuilder builder, - final byte menuType, - final List allSettings, - List enabledList) { - final boolean isMainMenu = menuType == DISPLAY_ITEMS_MENU; - final boolean isShortcuts = menuType == DISPLAY_ITEMS_SHORTCUTS; - final boolean hasMoreSection; - final Map idMap; - - switch (menuType) { - case DISPLAY_ITEMS_MENU: - LOG.info("Setting menu items"); - hasMoreSection = getCoordinator().mainMenuHasMoreSection(); - idMap = MapUtils.reverse(Huami2021MenuType.displayItemNameLookup); - break; - case DISPLAY_ITEMS_SHORTCUTS: - LOG.info("Setting shortcuts"); - hasMoreSection = false; - idMap = MapUtils.reverse(Huami2021MenuType.shortcutsNameLookup); - break; - case DISPLAY_ITEMS_CONTROL_CENTER: - LOG.info("Setting control center"); - hasMoreSection = false; - idMap = MapUtils.reverse(Huami2021MenuType.controlCenterNameLookup); - break; - default: - LOG.warn("Unknown menu type {}", menuType); - return; - } - - if (allSettings.isEmpty()) { - LOG.warn("List of all display items is missing"); - return; - } - - if (isMainMenu && !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 = allSettings.size(); - if (hasMoreSection) { - // 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; - - // IDs are 8-char hex strings, in upper case - final Pattern ID_REGEX = Pattern.compile("^[0-9A-F]{8}$"); - - for (final String name : enabledList) { - if (name.equals("more")) { - inMoreSection = true; - pos = 0; - continue; - } - - final String id = idMap.containsKey(name) ? idMap.get(name) : name; - if (!ID_REGEX.matcher(id).find()) { - LOG.error("Screen item id '{}' is not 8-char hex string", id); - continue; - } - - 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(id.getBytes(StandardCharsets.UTF_8)); - buf.put((byte) 0); - buf.put(sectionKey); - buf.put(pos++); - buf.put((byte) (id.equals("00000013") ? 1 : 0)); - } - - // Set all disabled items - pos = 0; - for (final String name : allSettings) { - if (enabledList.contains(name) || name.equals("more")) { - continue; - } - - final String id = idMap.containsKey(name) ? idMap.get(name) : name; - if (!ID_REGEX.matcher(id).find()) { - LOG.error("Screen item id '{}' is not 8-char hex string", id); - continue; - } - - // Screen IDs are sent as literal hex strings - buf.put(id.getBytes(StandardCharsets.UTF_8)); - buf.put((byte) 0); - buf.put(DISPLAY_ITEMS_SECTION_DISABLED); - buf.put(pos++); - buf.put((byte) (id.equals("00000013") ? 1 : 0)); - } - - writeToChunked2021(builder, CHUNKED2021_ENDPOINT_DISPLAY_ITEMS, buf.array(), true); - } - @Override protected Huami2021Support setDistanceUnit(final TransactionBuilder builder) { final MiBandConst.DistanceUnit unit = HuamiCoordinator.getDistanceUnit(); @@ -1126,18 +878,8 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil return this; } - @Override - public 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; + public void requestDisplayItems(final TransactionBuilder builder) { + displayItemsService.requestItems(builder, ZeppOsDisplayItemsService.DISPLAY_ITEMS_MENU); } public void requestApps(final TransactionBuilder builder) { @@ -1149,32 +891,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil watchfaceService.requestCurrentWatchface(builder); } - 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; - } - - protected Huami2021Support requestControlCenter(final TransactionBuilder builder) { - LOG.info("Requesting shortcuts"); - - writeToChunked2021( - builder, - CHUNKED2021_ENDPOINT_DISPLAY_ITEMS, - new byte[]{DISPLAY_ITEMS_CMD_REQUEST, DISPLAY_ITEMS_CONTROL_CENTER}, - true - ); - - return this; - } - protected void requestMTU(final TransactionBuilder builder) { writeToChunked2021( builder, @@ -1184,15 +900,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil ); } - protected void requestCapabilityReminders(final TransactionBuilder builder) { - writeToChunked2021( - builder, - CHUNKED2021_ENDPOINT_REMINDERS, - REMINDERS_CMD_CAPABILITIES_REQUEST, - false - ); - } - @Override public void phase2Initialize(final TransactionBuilder builder) { LOG.info("2021 phase2Initialize..."); @@ -1219,9 +926,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil LOG.info("2021 phase3Initialize..."); setUserInfo(builder); - configService.requestAllConfigs(builder); - requestCapabilityReminders(builder); - for (final HuamiVibrationPatternNotificationType type : coordinator.getVibrationPatternNotificationTypes(gbDevice)) { // FIXME: Can we read these from the band? final String typeKey = type.name().toLowerCase(Locale.ROOT); @@ -1229,13 +933,7 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil } cannedMessagesService.requestCannedMessages(builder); - requestDisplayItems(builder); - requestShortcuts(builder); - if (coordinator.supportsControlCenter()) { - requestControlCenter(builder); - } alarmsService.requestAlarms(builder); - //requestReminders(builder); for (AbstractZeppOsService service : mServiceMap.values()) { service.initialize(builder); @@ -1298,8 +996,9 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil return; } - if (mServiceMap.containsKey(type)) { - mServiceMap.get(type).handlePayload(payload); + final AbstractZeppOsService service = mServiceMap.get(type); + if (service != null) { + service.handlePayload(payload); return; } @@ -1316,21 +1015,12 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil 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_REMINDERS: - handle2021Reminders(payload); - return; case CHUNKED2021_ENDPOINT_CONNECTION: handle2021Connection(payload); return; @@ -1395,122 +1085,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil } } - 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 String prefKey; - final Map idMap; - switch (payload[1]) { - case DISPLAY_ITEMS_MENU: - LOG.info("Got {} display items", numberScreens); - prefKey = HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE; - idMap = Huami2021MenuType.displayItemNameLookup; - break; - case DISPLAY_ITEMS_SHORTCUTS: - LOG.info("Got {} shortcuts", numberScreens); - prefKey = HuamiConst.PREF_SHORTCUTS_SORTABLE; - idMap = Huami2021MenuType.shortcutsNameLookup; - break; - case DISPLAY_ITEMS_CONTROL_CENTER: - LOG.info("Got {} control center", numberScreens); - prefKey = HuamiConst.PREF_CONTROL_CENTER_SORTABLE; - idMap = Huami2021MenuType.controlCenterNameLookup; - break; - default: - LOG.error("Unknown display items type {}", String.format("0x%x", payload[1])); - return; - } - final String allScreensPrefKey = Huami2021Coordinator.getPrefPossibleValuesKey(prefKey); - - final boolean menuHasMoreSection; - - if (payload[1] == DISPLAY_ITEMS_MENU) { - menuHasMoreSection = getCoordinator().mainMenuHasMoreSection(); - } else { - menuHasMoreSection = false; - } - - final String[] mainScreensArr = new String[numberScreens]; - final String[] moreScreensArr = new String[numberScreens]; - final List allScreens = new LinkedList<>(); - if (menuHasMoreSection) { - // The band doesn't report the "more" screen, so we add it - allScreens.add("more"); - } - - for (int i = 0; i < numberScreens; i++) { - // Screen IDs are sent as literal hex strings - final String screenId = new String(subarray(payload, 4 + i * 12, 4 + i * 12 + 8)); - final String screenNameOrId = idMap.containsKey(screenId) ? idMap.get(screenId) : screenId; - allScreens.add(screenNameOrId); - - 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] = screenNameOrId; - 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] = screenNameOrId; - 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 (menuHasMoreSection) { - screens.add("more"); - screens.addAll(Arrays.asList(moreScreensArr)); - } - screens.removeAll(Collections.singleton(null)); - - final String allScreensPrefValue = StringUtils.join(",", allScreens.toArray(new String[0])).toString(); - final String prefValue = StringUtils.join(",", screens.toArray(new String[0])).toString(); - final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() - .withPreference(allScreensPrefKey, allScreensPrefValue) - .withPreference(prefKey, prefValue); - - evaluateGBDeviceEvent(eventUpdatePreferences); - } - /** * A handler to schedule the find phone event. */ @@ -1561,107 +1135,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil } } - 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); - replyHttpSuccess(requestId, response.getHttpStatusCode(), 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 int status, final String content) { - LOG.debug("Replying with http {} request {} with {}", status, 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) status); - 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: @@ -1698,84 +1171,6 @@ public abstract class Huami2021Support extends HuamiSupport implements ZeppOsFil } } - protected void handle2021Reminders(final byte[] payload) { - switch (payload[0]) { - case REMINDERS_CMD_CAPABILITIES_RESPONSE: - LOG.info("Reminder capability, status = {}", payload[1]); - if (payload[1] != 1) { - LOG.warn("Reminder capability unexpected status"); - return; - } - final int numReminders = payload[2] & 0xff; - final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences( - REMINDERS_PREF_CAPABILITY, - numReminders - ); - evaluateGBDeviceEvent(eventUpdatePreferences); - return; - 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])); - } - } - - 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 handle2021Connection(final byte[] payload) { switch (payload[0]) { case CONNECTION_CMD_MTU_RESPONSE: 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 09de66761..bd83b0363 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 @@ -1009,7 +1009,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements sendReminders(builder, reminders); } - protected void sendReminders(final TransactionBuilder builder, final List reminders) { + private void sendReminders(final TransactionBuilder builder, final List reminders) { final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(gbDevice); final Prefs prefs = new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress())); @@ -1032,7 +1032,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements } } - protected void sendReminderToDevice(final TransactionBuilder builder, int position, final Reminder reminder) { + private void sendReminderToDevice(final TransactionBuilder builder, int position, final Reminder reminder) { if (characteristicChunked == null) { LOG.warn("characteristicChunked is null, not sending reminder"); return; @@ -4085,11 +4085,6 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements return this; } - public HuamiSupport requestDisplayItems(TransactionBuilder builder) { - LOG.warn("Function not implemented"); - return this; - } - @Override public String customStringFilter(String inputString) { if (HuamiCoordinator.getUseCustomFont(gbDevice.getAddress())) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/AbstractZeppOsService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/AbstractZeppOsService.java index 4ab9368f3..114e80ce1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/AbstractZeppOsService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/AbstractZeppOsService.java @@ -18,9 +18,13 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos; import android.content.Context; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Coordinator; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Support; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; public abstract class AbstractZeppOsService { @@ -54,6 +58,15 @@ public abstract class AbstractZeppOsService { return mSupport; } + protected Huami2021Coordinator getCoordinator() { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(getSupport().getDevice()); + return (Huami2021Coordinator) coordinator; + } + + protected Prefs getDevicePrefs() { + return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(getSupport().getDevice().getAddress())); + } + protected void write(final String taskName, final byte b) { this.write(taskName, new byte[]{b}); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsConfigService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsConfigService.java index 32c4f47b2..1ee0cddbf 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsConfigService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsConfigService.java @@ -25,6 +25,8 @@ import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PR 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 android.widget.Toast; + import androidx.annotation.Nullable; import org.apache.commons.lang3.ArrayUtils; @@ -126,6 +128,34 @@ public class ZeppOsConfigService extends AbstractZeppOsService { } } + @Override + public void initialize(TransactionBuilder builder) { + requestAllConfigs(builder); + } + + @Override + public boolean onSendConfiguration(final String prefKey, Prefs prefs) { + if (!PREF_TO_CONFIG.containsKey(prefKey)) { + return false; + } + + final ConfigSetter configSetter = new ConfigSetter(); + if (setConfig(prefs, prefKey, configSetter)) { + try { + // If the ConfigSetter was able to set the config, just write it and return + final TransactionBuilder builder = new TransactionBuilder("Sending configuration for " + prefKey); + configSetter.write(builder); + builder.queue(getSupport().getQueue()); + } catch (final Exception e) { + GB.toast("Error setting configuration", Toast.LENGTH_LONG, GB.ERROR, e); + } + + return true; + } + + return false; + } + private boolean sentFitnessGoal = false; private void handle2021ConfigResponse(final byte[] payload) { diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsDisplayItemsService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsDisplayItemsService.java new file mode 100644 index 000000000..fbd999290 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsDisplayItemsService.java @@ -0,0 +1,377 @@ +/* Copyright (C) 2023 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.zeppos.services; + +import static org.apache.commons.lang3.ArrayUtils.subarray; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Coordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021MenuType; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Support; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; +import nodomain.freeyourgadget.gadgetbridge.util.MapUtils; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class ZeppOsDisplayItemsService extends AbstractZeppOsService { + private static final Logger LOG = LoggerFactory.getLogger(ZeppOsDisplayItemsService.class); + + private static final short ENDPOINT = 0x0026; + + public static final byte CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte CMD_REQUEST = 0x03; + public static final byte CMD_RESPONSE = 0x04; + public static final byte CMD_CREATE = 0x05; + public static final byte 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_CONTROL_CENTER = 0x03; + + 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; + + public ZeppOsDisplayItemsService(final Huami2021Support support) { + super(support); + } + + @Override + public short getEndpoint() { + return ENDPOINT; + } + + @Override + public boolean isEncrypted() { + return true; + } + + @Override + public void handlePayload(final byte[] payload) { + switch (payload[0]) { + case CMD_RESPONSE: + decodeAndUpdateDisplayItems(payload); + break; + case 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])); + } + } + + @Override + public boolean onSendConfiguration(final String config, final Prefs prefs) { + switch (config) { + case HuamiConst.PREF_DISPLAY_ITEMS: + case HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE: + setDisplayItems( + DISPLAY_ITEMS_MENU, + new ArrayList<>(prefs.getList(Huami2021Coordinator.getPrefPossibleValuesKey(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE), Collections.emptyList())), + new ArrayList<>(prefs.getList(HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE, Collections.emptyList())) + ); + return true; + case HuamiConst.PREF_SHORTCUTS: + case HuamiConst.PREF_SHORTCUTS_SORTABLE: + setDisplayItems( + DISPLAY_ITEMS_SHORTCUTS, + new ArrayList<>(prefs.getList(Huami2021Coordinator.getPrefPossibleValuesKey(HuamiConst.PREF_SHORTCUTS_SORTABLE), Collections.emptyList())), + new ArrayList<>(prefs.getList(HuamiConst.PREF_SHORTCUTS_SORTABLE, Collections.emptyList())) + ); + return true; + case HuamiConst.PREF_CONTROL_CENTER_SORTABLE: + setDisplayItems( + DISPLAY_ITEMS_CONTROL_CENTER, + new ArrayList<>(prefs.getList(Huami2021Coordinator.getPrefPossibleValuesKey(HuamiConst.PREF_CONTROL_CENTER_SORTABLE), Collections.emptyList())), + new ArrayList<>(prefs.getList(HuamiConst.PREF_CONTROL_CENTER_SORTABLE, Collections.emptyList())) + ); + return true; + } + + return false; + } + + @Override + public void initialize(final TransactionBuilder builder) { + requestItems(builder, DISPLAY_ITEMS_MENU); + requestItems(builder, DISPLAY_ITEMS_SHORTCUTS); + if (getCoordinator().supportsControlCenter()) { + requestItems(builder, DISPLAY_ITEMS_CONTROL_CENTER); + } + } + + public void requestItems(final TransactionBuilder builder, final byte type) { + LOG.info("Requesting display items type={}", type); + + write(builder, new byte[]{CMD_REQUEST, type}); + } + + private void decodeAndUpdateDisplayItems(final byte[] payload) { + LOG.info("Got display items from band, type={}", payload[1]); + + 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 String prefKey; + final Map idMap; + switch (payload[1]) { + case DISPLAY_ITEMS_MENU: + LOG.info("Got {} display items", numberScreens); + prefKey = HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE; + idMap = Huami2021MenuType.displayItemNameLookup; + break; + case DISPLAY_ITEMS_SHORTCUTS: + LOG.info("Got {} shortcuts", numberScreens); + prefKey = HuamiConst.PREF_SHORTCUTS_SORTABLE; + idMap = Huami2021MenuType.shortcutsNameLookup; + break; + case DISPLAY_ITEMS_CONTROL_CENTER: + LOG.info("Got {} control center", numberScreens); + prefKey = HuamiConst.PREF_CONTROL_CENTER_SORTABLE; + idMap = Huami2021MenuType.controlCenterNameLookup; + break; + default: + LOG.error("Unknown display items type {}", String.format("0x%x", payload[1])); + return; + } + final String allScreensPrefKey = Huami2021Coordinator.getPrefPossibleValuesKey(prefKey); + + final boolean menuHasMoreSection; + + if (payload[1] == DISPLAY_ITEMS_MENU) { + menuHasMoreSection = getCoordinator().mainMenuHasMoreSection(); + } else { + menuHasMoreSection = false; + } + + final String[] mainScreensArr = new String[numberScreens]; + final String[] moreScreensArr = new String[numberScreens]; + final List allScreens = new LinkedList<>(); + if (menuHasMoreSection) { + // The band doesn't report the "more" screen, so we add it + allScreens.add("more"); + } + + for (int i = 0; i < numberScreens; i++) { + // Screen IDs are sent as literal hex strings + final String screenId = new String(subarray(payload, 4 + i * 12, 4 + i * 12 + 8)); + final String screenNameOrId = idMap.containsKey(screenId) ? idMap.get(screenId) : screenId; + allScreens.add(screenNameOrId); + + 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] = screenNameOrId; + 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] = screenNameOrId; + 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 (menuHasMoreSection) { + screens.add("more"); + screens.addAll(Arrays.asList(moreScreensArr)); + } + screens.removeAll(Collections.singleton(null)); + + final String allScreensPrefValue = StringUtils.join(",", allScreens.toArray(new String[0])).toString(); + final String prefValue = StringUtils.join(",", screens.toArray(new String[0])).toString(); + final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences() + .withPreference(allScreensPrefKey, allScreensPrefValue) + .withPreference(prefKey, prefValue); + + evaluateGBDeviceEvent(eventUpdatePreferences); + } + + private void setDisplayItems(final byte menuType, + final List allSettings, + List enabledList) { + try { + final TransactionBuilder builder = new TransactionBuilder("set display items type " + menuType); + setDisplayItems(builder, menuType, allSettings, enabledList); + builder.queue(getSupport().getQueue()); + } catch (final Exception e) { + LOG.error("Failed to set display items", e); + } + } + + private void setDisplayItems(final TransactionBuilder builder, + final byte menuType, + final List allSettings, + List enabledList) { + final boolean isMainMenu = menuType == DISPLAY_ITEMS_MENU; + final boolean isShortcuts = menuType == DISPLAY_ITEMS_SHORTCUTS; + final boolean hasMoreSection; + final Map idMap; + + switch (menuType) { + case DISPLAY_ITEMS_MENU: + LOG.info("Setting menu items"); + hasMoreSection = getCoordinator().mainMenuHasMoreSection(); + idMap = MapUtils.reverse(Huami2021MenuType.displayItemNameLookup); + break; + case DISPLAY_ITEMS_SHORTCUTS: + LOG.info("Setting shortcuts"); + hasMoreSection = false; + idMap = MapUtils.reverse(Huami2021MenuType.shortcutsNameLookup); + break; + case DISPLAY_ITEMS_CONTROL_CENTER: + LOG.info("Setting control center"); + hasMoreSection = false; + idMap = MapUtils.reverse(Huami2021MenuType.controlCenterNameLookup); + break; + default: + LOG.warn("Unknown menu type {}", menuType); + return; + } + + if (allSettings.isEmpty()) { + LOG.warn("List of all display items is missing"); + return; + } + + if (isMainMenu && !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 = allSettings.size(); + if (hasMoreSection) { + // 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(CMD_CREATE); + buf.put(menuType); + buf.put((byte) numItems); + buf.put((byte) 0x00); + + byte pos = 0; + boolean inMoreSection = false; + + // IDs are 8-char hex strings, in upper case + final Pattern ID_REGEX = Pattern.compile("^[0-9A-F]{8}$"); + + for (final String name : enabledList) { + if (name.equals("more")) { + inMoreSection = true; + pos = 0; + continue; + } + + final String id = idMap.containsKey(name) ? idMap.get(name) : name; + if (!ID_REGEX.matcher(id).find()) { + LOG.error("Screen item id '{}' is not 8-char hex string", id); + continue; + } + + 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(id.getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0); + buf.put(sectionKey); + buf.put(pos++); + buf.put((byte) (id.equals("00000013") ? 1 : 0)); + } + + // Set all disabled items + pos = 0; + for (final String name : allSettings) { + if (enabledList.contains(name) || name.equals("more")) { + continue; + } + + final String id = idMap.containsKey(name) ? idMap.get(name) : name; + if (!ID_REGEX.matcher(id).find()) { + LOG.error("Screen item id '{}' is not 8-char hex string", id); + continue; + } + + // Screen IDs are sent as literal hex strings + buf.put(id.getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0); + buf.put(DISPLAY_ITEMS_SECTION_DISABLED); + buf.put(pos++); + buf.put((byte) (id.equals("00000013") ? 1 : 0)); + } + + write(builder, buf.array()); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsHttpService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsHttpService.java new file mode 100644 index 000000000..baa6e2be4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsHttpService.java @@ -0,0 +1,162 @@ +/* Copyright (C) 2023 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.zeppos.services; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.HashMap; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Support; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Weather; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class ZeppOsHttpService extends AbstractZeppOsService { + private static final Logger LOG = LoggerFactory.getLogger(ZeppOsHttpService.class); + + private static final short ENDPOINT = 0x0001; + + public static final byte CMD_REQUEST = 0x01; + public static final byte CMD_RESPONSE = 0x02; + + public static final byte RESPONSE_SUCCESS = 0x01; + public static final byte RESPONSE_NO_INTERNET = 0x02; + + public ZeppOsHttpService(final Huami2021Support support) { + super(support); + } + + @Override + public short getEndpoint() { + return ENDPOINT; + } + + @Override + public boolean isEncrypted() { + return true; + } + + @Override + public void handlePayload(final byte[] payload) { + switch (payload[0]) { + case 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); + replyHttpSuccess(requestId, response.getHttpStatusCode(), 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[]{CMD_RESPONSE, requestId, RESPONSE_NO_INTERNET, 0x00, 0x00, 0x00, 0x00}; + + write("http reply no internet", cmd); + } + + private void replyHttpSuccess(final byte requestId, final int status, final String content) { + LOG.debug("Replying with http {} request {} with {}", status, 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(CMD_RESPONSE); + buf.put(requestId); + buf.put(RESPONSE_SUCCESS); + buf.put((byte) status); + buf.putInt(contentBytes.length); + buf.put(contentBytes); + + write("http reply success", buf.array()); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsRemindersService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsRemindersService.java new file mode 100644 index 000000000..a21a98265 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsRemindersService.java @@ -0,0 +1,267 @@ +/* Copyright (C) 2023 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.zeppos.services; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Calendar; +import java.util.Date; +import java.util.List; + +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.model.Reminder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Support; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class ZeppOsRemindersService extends AbstractZeppOsService { + private static final Logger LOG = LoggerFactory.getLogger(ZeppOsRemindersService.class); + + private static final short ENDPOINT = 0x0038; + + private static final byte CMD_CAPABILITIES_REQUEST = 0x01; + private static final byte CMD_CAPABILITIES_RESPONSE = 0x02; + private static final byte CMD_REQUEST = 0x03; + private static final byte CMD_RESPONSE = 0x04; + private static final byte CMD_CREATE = 0x05; + private static final byte CMD_CREATE_ACK = 0x06; + private static final byte CMD_UPDATE = 0x07; + private static final byte CMD_UPDATE_ACK = 0x08; + private static final byte CMD_DELETE = 0x09; + private static final byte CMD_DELETE_ACK = 0x0a; + + private static final int FLAG_ENABLED = 0x0001; + private static final int FLAG_TEXT = 0x0008; + private static final int FLAG_REPEAT_MONTH = 0x1000; + private static final int FLAG_REPEAT_YEAR = 0x2000; + + private static final String PREF_CAPABILITY = "huami_2021_capability_reminders"; + + public ZeppOsRemindersService(final Huami2021Support support) { + super(support); + } + + @Override + public short getEndpoint() { + return ENDPOINT; + } + + @Override + public boolean isEncrypted() { + return false; + } + + @Override + public void handlePayload(final byte[] payload) { + switch (payload[0]) { + case CMD_CAPABILITIES_RESPONSE: + LOG.info("Reminder capability, version = {}", payload[1]); + if (payload[1] != 1) { + LOG.warn("Reminder unsupported version {}", payload[1]); + return; + } + final int numReminders = payload[2] & 0xff; + final GBDeviceEventUpdatePreferences eventUpdatePreferences = new GBDeviceEventUpdatePreferences( + PREF_CAPABILITY, + numReminders + ); + evaluateGBDeviceEvent(eventUpdatePreferences); + return; + case CMD_CREATE_ACK: + LOG.info("Reminder create ACK, status = {}", payload[1]); + return; + case CMD_DELETE_ACK: + LOG.info("Reminder delete ACK, status = {}", payload[1]); + // status 1 = success + // status 2 = reminder not found + return; + case CMD_UPDATE_ACK: + LOG.info("Reminder update ACK, status = {}", payload[1]); + return; + case CMD_RESPONSE: + LOG.info("Got reminders from band"); + decodeAndUpdateReminders(payload); + return; + default: + LOG.warn("Unexpected reminders payload byte {}", String.format("0x%02x", payload[0])); + } + } + + @Override + public void initialize(final TransactionBuilder builder) { + requestCapabilities(builder); + //requestReminders(builder); + sendReminders(builder); + } + + private void requestCapabilities(final TransactionBuilder builder) { + write(builder, CMD_CAPABILITIES_REQUEST); + } + + private void requestReminders(final TransactionBuilder builder) { + write(builder, CMD_REQUEST); + } + + public void sendReminders(final TransactionBuilder builder) { + final List reminders = DBHelper.getReminders(getSupport().getDevice()); + sendReminders(builder, reminders); + } + + public void sendReminders(final TransactionBuilder builder, final List reminders) { + LOG.info("On Set Reminders: {}", reminders.size()); + + final int reminderSlotCount = getCoordinator().getReminderSlotCount(getSupport().getDevice()); + if (reminderSlotCount <= 0) { + LOG.warn("Reminders not yet initialized"); + return; + } + + // Send the reminders, skipping the reserved slots for calendar events + for (int i = 0; i < reminders.size(); i++) { + LOG.debug("Sending reminder at position {}", i); + } + + // Delete the remaining slots, skipping the sent reminders + for (int i = reminders.size(); i < reminderSlotCount; i++) { + LOG.debug("Deleting reminder at position {}", i); + + sendReminderToDevice(builder, i, null); + } + } + + protected void sendReminderToDevice(final TransactionBuilder builder, int position, final Reminder reminder) { + final DeviceCoordinator coordinator = getCoordinator(); + final int reminderSlotCount = coordinator.getReminderSlotCount(getSupport().getDevice()); + if (position + 1 > reminderSlotCount) { + LOG.error("Reminder for position {} is over the limit of {} reminders", position, reminderSlotCount); + return; + } + + if (reminder == null) { + // Delete reminder + write(builder, new byte[]{CMD_DELETE, (byte) (position & 0xFF)}); + + return; + } + + final String message; + if (reminder.getMessage().length() > coordinator.getMaximumReminderMessageLength()) { + LOG.warn("The reminder message length {} is longer than {}, will be truncated", + reminder.getMessage().length(), + coordinator.getMaximumReminderMessageLength() + ); + message = StringUtils.truncate(reminder.getMessage(), coordinator.getMaximumReminderMessageLength()); + } else { + message = reminder.getMessage(); + } + + final ByteBuffer buf = ByteBuffer.allocate(1 + 10 + message.getBytes(StandardCharsets.UTF_8).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(CMD_UPDATE); + buf.put((byte) (position & 0xFF)); + + final Calendar cal = BLETypeConversions.createCalendar(); + cal.setTime(reminder.getDate()); + + int reminderFlags = FLAG_ENABLED | 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 |= FLAG_REPEAT_MONTH; + break; + case Reminder.EVERY_YEAR: + reminderFlags |= 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); + + buf.put(message.getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0x00); + + write(builder, buf.array()); + } + + 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; + } + + 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); + } + + // TODO persist in database. Probably not trivial, because reminderPosition != reminderId + } + + public static int getSlotCount(final Prefs devicePrefs) { + return devicePrefs.getInt(PREF_CAPABILITY, 0); + } +}