From e1cccd69533ce08c8e48280b99423465b5db4af0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Sat, 6 May 2023 21:02:54 +0100 Subject: [PATCH] Zepp OS: Refactor code for alarms, notifications, calendar, canned messages --- .../devices/huami/Huami2021Service.java | 75 -- .../devices/huami/Huami2021Support.java | 857 +----------------- .../service/devices/huami/HuamiSupport.java | 15 +- .../huami/zeppos/AbstractZeppOsService.java | 11 + .../zeppos/services/ZeppOsAlarmsService.java | 226 +++++ .../services/ZeppOsCalendarService.java | 219 +++++ .../services/ZeppOsCannedMessagesService.java | 245 +++++ .../services/ZeppOsNotificationService.java | 405 +++++++++ 8 files changed, 1133 insertions(+), 920 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsAlarmsService.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCalendarService.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCannedMessagesService.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java 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 e9b198b5f..265d3c656 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 @@ -21,10 +21,7 @@ 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_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_CONNECTION = 0x0015; public static final short CHUNKED2021_ENDPOINT_USER_INFO = 0x0017; public static final short CHUNKED2021_ENDPOINT_STEPS = 0x0016; @@ -33,7 +30,6 @@ 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_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; @@ -49,29 +45,6 @@ public class Huami2021Service { 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}. */ @@ -138,22 +111,6 @@ public class Huami2021Service { 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; - /** * Connection, for {@link Huami2021Service#CHUNKED2021_ENDPOINT_CONNECTION}. */ @@ -174,26 +131,6 @@ public class Huami2021Service { 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}. */ @@ -239,18 +176,6 @@ public class Huami2021Service { public static final int REMINDER_FLAG_REPEAT_YEAR = 0x2000; public static final String REMINDERS_PREF_CAPABILITY = "huami_2021_capability_reminders"; - /** - * 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; - /** * 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 2da14fa18..c434fb70d 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 @@ -17,7 +17,6 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami; import static org.apache.commons.lang3.ArrayUtils.subarray; -import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.SHORTCUT_CARDS_SORTABLE; 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; @@ -39,18 +38,11 @@ import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos. import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.TEMPERATURE_UNIT; import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsConfigService.ConfigArg.TIME_FORMAT; -import android.Manifest; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; import android.location.Location; import android.net.Uri; import android.os.Handler; import android.widget.Toast; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - import org.apache.commons.lang3.ArrayUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -77,14 +69,10 @@ import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; -import nodomain.freeyourgadget.gadgetbridge.BuildConfig; import nodomain.freeyourgadget.gadgetbridge.GBApplication; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; -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.Huami2021Coordinator; @@ -102,11 +90,9 @@ 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; @@ -121,6 +107,10 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.Upd import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.operations.ZeppOsAgpsUpdateOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAgpsService; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsAlarmsService; +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.ZeppOsNotificationService; 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.zeppos.services.ZeppOsContactsService; @@ -130,24 +120,16 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsPhoneService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWatchfaceService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWifiService; -import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; -import nodomain.freeyourgadget.gadgetbridge.util.BitmapUtil; 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.NotificationUtils; 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; @@ -163,6 +145,10 @@ public abstract class Huami2021Support extends HuamiSupport { private final ZeppOsPhoneService phoneService = new ZeppOsPhoneService(this); private final ZeppOsShortcutCardsService shortcutCardsService = new ZeppOsShortcutCardsService(this); private final ZeppOsWatchfaceService watchfaceService = new ZeppOsWatchfaceService(this); + private final ZeppOsAlarmsService alarmsService = new ZeppOsAlarmsService(this); + private final ZeppOsCalendarService calendarService = new ZeppOsCalendarService(this); + private final ZeppOsCannedMessagesService cannedMessagesService = new ZeppOsCannedMessagesService(this); + private final ZeppOsNotificationService notificationService = new ZeppOsNotificationService(this, fileUploadService); private final Map mServiceMap = new LinkedHashMap() {{ put(fileUploadService.getEndpoint(), fileUploadService); @@ -175,6 +161,10 @@ public abstract class Huami2021Support extends HuamiSupport { put(phoneService.getEndpoint(), phoneService); put(shortcutCardsService.getEndpoint(), shortcutCardsService); put(watchfaceService.getEndpoint(), watchfaceService); + put(alarmsService.getEndpoint(), alarmsService); + put(calendarService.getEndpoint(), calendarService); + put(cannedMessagesService.getEndpoint(), cannedMessagesService); + put(notificationService.getEndpoint(), notificationService); }}; public Huami2021Support() { @@ -330,94 +320,14 @@ public abstract class Huami2021Support extends HuamiSupport { 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.getBytes(StandardCharsets.UTF_8).length; - } - if (calendarEventSpec.description != null) { - length += calendarEventSpec.description.getBytes(StandardCharsets.UTF_8).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); - - // Remind - buf.put((byte) 0x00); // ? - buf.put((byte) 0x00); // ? - buf.put((byte) 0x00); // ? - buf.put((byte) 0x00); // ? - // Repeat - 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); // ? - // TODO: Description here - - writeToChunked2021("delete calendar event", CHUNKED2021_ENDPOINT_CALENDAR, buf.array(), false); + calendarService.addEvent(calendarEventSpec); } @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); + calendarService.deleteEvent(type, id); } @Override @@ -591,167 +501,17 @@ public abstract class Huami2021Support extends HuamiSupport { @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); + alarmsService.sendAlarm(alarm, builder); } @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); - } + notificationService.setCallState(callSpec); } @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)); - } else { - // Send the GB package name, otherwise the last notification icon will - // be used wrongly (eg. when receiving an SMS) - baos.write(BuildConfig.APPLICATION_ID.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; + notificationService.sendNotification(notificationSpec); } protected Huami2021Support requestReminders(final TransactionBuilder builder) { @@ -840,21 +600,7 @@ public abstract class Huami2021Support extends HuamiSupport { @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); + notificationService.deleteNotification(id); } @Override @@ -872,58 +618,7 @@ public abstract class Huami2021Support extends HuamiSupport { @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(StandardCharsets.UTF_8).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.getBytes(StandardCharsets.UTF_8).length); - buf.put((byte) 0x00); - buf.put(cannedMessage.getBytes(StandardCharsets.UTF_8)); - 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); - } + cannedMessagesService.setCannedMessages(cannedMessagesSpec); } @Override @@ -1353,25 +1048,6 @@ public abstract class Huami2021Support extends HuamiSupport { 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 public Huami2021Support requestDisplayItems(final TransactionBuilder builder) { LOG.info("Requesting display items"); @@ -1470,13 +1146,13 @@ public abstract class Huami2021Support extends HuamiSupport { setVibrationPattern(builder, HuamiConst.PREF_HUAMI_VIBRATION_PROFILE_PREFIX + typeKey); } - requestCannedMessages(builder); + cannedMessagesService.requestCannedMessages(builder); requestDisplayItems(builder); requestShortcuts(builder); if (coordinator.supportsControlCenter()) { requestControlCenter(builder); } - requestAlarms(builder); + alarmsService.requestAlarms(builder); //requestReminders(builder); for (AbstractZeppOsService service : mServiceMap.values()) { @@ -1525,15 +1201,9 @@ public abstract class Huami2021Support extends HuamiSupport { } 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; @@ -1555,15 +1225,9 @@ public abstract class Huami2021Support extends HuamiSupport { 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_CONNECTION: handle2021Connection(payload); return; @@ -1590,170 +1254,6 @@ public abstract class Huami2021Support extends HuamiSupport { } } - 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? - } - protected void handle2021Workout(final byte[] payload) { switch (payload[0]) { case WORKOUT_CMD_APP_OPEN: @@ -2085,98 +1585,6 @@ public abstract class Huami2021Support extends HuamiSupport { } } - 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; - final byte iconFormat = payload[pos]; - pos++; - int width = BLETypeConversions.toUint16(subarray(payload, pos, pos + 2)); - pos += 2; - int height = BLETypeConversions.toUint16(subarray(payload, pos, pos + 2)); - sendIconForPackage(packageName, iconFormat, 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); - } - protected void handle2021Weather(final byte[] payload) { switch (payload[0]) { case WEATHER_CMD_DEFAULT_LOCATION_ACK: @@ -2187,90 +1595,6 @@ public abstract class Huami2021Support extends HuamiSupport { } } - private void ackNotificationAfterIconSent(final String queuedIconPackage) { - LOG.info("Acknowledging icon send for {}", queuedIconPackage); - - final ByteBuffer buf = ByteBuffer.allocate(1 + queuedIconPackage.length() + 1 + 1); - buf.order(ByteOrder.LITTLE_ENDIAN); - buf.put(NOTIFICATION_CMD_ICON_REQUEST_ACK); - buf.put(queuedIconPackage.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 byte iconFormat, final int width, final int height) { - if (getMTU() < 247) { - LOG.warn("Sending icons requires high MTU, current MTU is {}", getMTU()); - return; - } - - // Without the expected tga id and format string they seem to get corrupted, - // but the encoding seems to actually be the same...? - final String format; - final String tgaId; - switch (iconFormat) { - case 0x04: - format = "TGA_RGB565_GCNANOLITE"; - tgaId = "SOMHP"; - break; - case 0x08: - format = "TGA_RGB565_DAVE2D"; - tgaId = "SOMH6"; - break; - default: - LOG.error("Unknown icon format {}", String.format("0x%02x", iconFormat)); - return; - } - - final Drawable icon = NotificationUtils.getAppIcon(getContext(), packageName); - if (icon == null) { - LOG.warn("Failed to get icon for {}", packageName); - return; - } - - final Bitmap bmp = BitmapUtil.toBitmap(icon); - - // The TGA needs to have this ID, or the band does not accept it - final byte[] tgaIdBytes = new byte[46]; - System.arraycopy(tgaId.getBytes(StandardCharsets.UTF_8), 0, tgaIdBytes, 0, 5); - - final byte[] tga565 = BitmapUtil.convertToTgaRGB565(bmp, width, height, tgaIdBytes); - - 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(".", "_")); - - fileUploadService.sendFile( - url, - filename, - tga565, - new ZeppOsFileUploadService.Callback() { - @Override - public void onFileUploadFinish(final boolean success) { - LOG.info("Finished sending icon, success={}", success); - if (success) { - ackNotificationAfterIconSent(packageName); - } - } - - @Override - public void onFileUploadProgress(final int progress) { - LOG.trace("Icon send progress: {}", progress); - } - } - ); - - LOG.info("Queueing icon for {}", packageName); - } - protected void handle2021Reminders(final byte[] payload) { switch (payload[0]) { case REMINDERS_CMD_CAPABILITIES_RESPONSE: @@ -2349,145 +1673,6 @@ public abstract class Huami2021Support extends HuamiSupport { // 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 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 e006359f3..9fb7b3a1b 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 @@ -116,6 +116,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsCannedMessagesService; import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarEvent; import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarManager; import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; @@ -201,10 +202,6 @@ import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.Dev 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; @@ -4130,17 +4127,17 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements return; } - if (type == CHUNKED2021_ENDPOINT_CANNED_MESSAGES && false) { // unsafe for now, disabled + if (type == ZeppOsCannedMessagesService.ENDPOINT && 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); + writeToChunked2021(builder, ZeppOsCannedMessagesService.ENDPOINT, new byte[]{(byte) ZeppOsCannedMessagesService.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) { + } else if (payload[0] == ZeppOsCannedMessagesService.CMD_REPLY_SMS) { String phoneNumber = null; String smsReply = null; for (int i = 1; i < payload.length; i++) { @@ -4161,8 +4158,8 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements 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); + byte[] ackSentCommand = new byte[]{ZeppOsCannedMessagesService.CMD_REPLY_SMS_ACK, 0x01}; + writeToChunked2021(builder, ZeppOsCannedMessagesService.ENDPOINT, 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/zeppos/AbstractZeppOsService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/AbstractZeppOsService.java index ced1111bc..4ab9368f3 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 @@ -16,6 +16,9 @@ along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos; +import android.content.Context; + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Support; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; @@ -67,6 +70,14 @@ public abstract class AbstractZeppOsService { this.mSupport.writeToChunked2021(builder, getEndpoint(), data, isEncrypted()); } + protected void evaluateGBDeviceEvent(final GBDeviceEvent event) { + getSupport().evaluateGBDeviceEvent(event); + } + + protected Context getContext() { + return getSupport().getContext(); + } + protected static Boolean booleanFromByte(final byte b) { switch (b) { case 0x00: diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsAlarmsService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsAlarmsService.java new file mode 100644 index 000000000..3d7f43206 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsAlarmsService.java @@ -0,0 +1,226 @@ +/* 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 android.content.Intent; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Calendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.database.DBHelper; +import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.model.Alarm; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceService; +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.AlarmUtils; +import nodomain.freeyourgadget.gadgetbridge.util.DeviceHelper; + +public class ZeppOsAlarmsService extends AbstractZeppOsService { + private static final Logger LOG = LoggerFactory.getLogger(ZeppOsAlarmsService.class); + + public static final short ENDPOINT = 0x000f; + + public static final byte CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte CMD_CREATE = 0x03; + public static final byte CMD_CREATE_ACK = 0x04; + public static final byte CMD_DELETE = 0x05; + public static final byte CMD_DELETE_ACK = 0x06; + public static final byte CMD_UPDATE = 0x07; + public static final byte CMD_UPDATE_ACK = 0x08; + public static final byte CMD_REQUEST = 0x09; + public static final byte CMD_RESPONSE = 0x0a; + public static final byte CMD_NOTIFY_CHANGE = 0x0f; + + public static final int IDX_FLAGS = 0; + public static final int IDX_POSITION = 1; + public static final int IDX_HOUR = 2; + public static final int IDX_MINUTE = 3; + public static final int IDX_REPETITION = 4; + + public static final int FLAG_SMART = 0x01; + public static final int FLAG_UNKNOWN_2 = 0x02; + public static final int FLAG_ENABLED = 0x04; + + public ZeppOsAlarmsService(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_CREATE_ACK: + LOG.info("Alarm create ACK, status = {}", payload[1]); + return; + case CMD_DELETE_ACK: + LOG.info("Alarm delete ACK, status = {}", payload[1]); + return; + case CMD_UPDATE_ACK: + LOG.info("Alarm update ACK, status = {}", payload[1]); + return; + case CMD_NOTIFY_CHANGE: + LOG.info("Alarms changed on band"); + requestAlarms(); + return; + case 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 requestAlarms() { + try { + final TransactionBuilder builder = new TransactionBuilder("request alarms"); + requestAlarms(builder); + builder.queue(getSupport().getQueue()); + } catch (final Exception e) { + LOG.error("Failed to request alarms", e); + } + } + + public void requestAlarms(final TransactionBuilder builder) { + LOG.info("Requesting alarms"); + + write(builder, new byte[]{CMD_REQUEST}); + } + + public void sendAlarm(final Alarm alarm, final TransactionBuilder builder) { + final DeviceCoordinator coordinator = DeviceHelper.getInstance().getCoordinator(getSupport().getDevice()); + + final Calendar calendar = AlarmUtils.toCalendar(alarm); + + final byte[] alarmMessage; + if (!alarm.getUnused()) { + int alarmFlags = 0; + if (alarm.getEnabled()) { + alarmFlags = FLAG_ENABLED; + } + if (coordinator.supportsSmartWakeup(getSupport().getDevice()) && alarm.getSmartWakeup()) { + alarmFlags |= FLAG_SMART; + } + alarmMessage = new byte[]{ + 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[]{ + CMD_DELETE, + (byte) 0x01, // ? + (byte) alarm.getPosition() + }; + } + + write(builder, alarmMessage); + } + + 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(getSupport().getDevice()); + 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 + IDX_POSITION]); + alarm.setEnabled((payload[offset + IDX_FLAGS] & FLAG_ENABLED) > 0); + alarm.setSmartWakeup((payload[offset + IDX_FLAGS] & FLAG_SMART) > 0); + alarm.setHour(payload[offset + IDX_HOUR]); + alarm.setMinute(payload[offset + IDX_MINUTE]); + alarm.setRepetition(payload[offset + IDX_REPETITION]); + + return alarm; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCalendarService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCalendarService.java new file mode 100644 index 000000000..c94eb3448 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCalendarService.java @@ -0,0 +1,219 @@ +/* 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 nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec; +import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.Huami2021Support; +import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.AbstractZeppOsService; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class ZeppOsCalendarService extends AbstractZeppOsService { + private static final Logger LOG = LoggerFactory.getLogger(ZeppOsCalendarService.class); + + public static final short ENDPOINT = 0x0007; + + public static final byte CMD_CAPABILITIES_REQUEST = 0x01; + public static final byte CMD_CAPABILITIES_RESPONSE = 0x02; + public static final byte CMD_EVENTS_REQUEST = 0x05; + public static final byte CMD_EVENTS_RESPONSE = 0x06; + public static final byte CMD_CREATE_EVENT = 0x07; + public static final byte CMD_CREATE_EVENT_ACK = 0x08; + public static final byte CMD_DELETE_EVENT = 0x09; + public static final byte CMD_DELETE_EVENT_ACK = 0x0a; + + public ZeppOsCalendarService(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_EVENTS_RESPONSE: + LOG.info("Got calendar events from band"); + decodeAndUpdateCalendarEvents(payload); + return; + case CMD_CREATE_EVENT_ACK: + LOG.info("Calendar create event ACK, status = {}", payload[1]); + return; + case 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])); + } + } + + public void requestCalendarEvents() { + LOG.info("Requesting calendar events from band"); + + write("request calendar events", new byte[]{CMD_EVENTS_REQUEST, 0x00, 0x00}); + } + + public void addEvent(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.getBytes(StandardCharsets.UTF_8).length; + } + if (calendarEventSpec.description != null) { + length += calendarEventSpec.description.getBytes(StandardCharsets.UTF_8).length; + } + + final ByteBuffer buf = ByteBuffer.allocate(length); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(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); + + // Remind + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + buf.put((byte) 0x00); // ? + // Repeat + 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); // ? + // TODO: Description here + + write("add calendar event", buf.array()); + } + + public void deleteEvent(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(CMD_DELETE_EVENT); + buf.putInt((int) id); + + write("delete calendar event", buf.array()); + } + + 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? + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCannedMessagesService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCannedMessagesService.java new file mode 100644 index 000000000..fee65cfd4 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsCannedMessagesService.java @@ -0,0 +1,245 @@ +/* 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 android.Manifest; +import android.content.pm.PackageManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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.deviceevents.GBDeviceEventCallControl; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; +import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; +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.StringUtils; + +public class ZeppOsCannedMessagesService extends AbstractZeppOsService { + private static final Logger LOG = LoggerFactory.getLogger(ZeppOsCannedMessagesService.class); + + public static final short ENDPOINT = 0x0013; + + 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_SET = 0x05; + public static final byte CMD_SET_ACK = 0x06; + public static final byte CMD_DELETE = 0x07; + public static final byte CMD_DELETE_ACK = 0x08; + public static final byte CMD_REPLY_SMS = 0x0b; + public static final byte CMD_REPLY_SMS_ACK = 0x0c; + public static final byte CMD_REPLY_SMS_CHECK = 0x0d; + public static final byte CMD_REPLY_SMS_ALLOW = 0x0e; + + public ZeppOsCannedMessagesService(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_RESPONSE: + decodeAndUpdateCannedMessagesResponse(payload); + return; + case CMD_SET_ACK: + LOG.info("Canned Message set ACK, status = {}", payload[1]); + return; + case CMD_DELETE_ACK: + LOG.info("Canned Message delete ACK, status = {}", payload[1]); + return; + case CMD_REPLY_SMS: + handleCannedSmsReply(payload); + return; + case 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])); + } + } + + public void setCannedMessages(final CannedMessagesSpec cannedMessagesSpec) { + if (cannedMessagesSpec.type != CannedMessagesSpec.TYPE_GENERIC) { + LOG.warn("Got unsupported canned messages type: {}", cannedMessagesSpec.type); + return; + } + + final TransactionBuilder builder = new TransactionBuilder("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(CMD_DELETE); + buf.putInt(i); + write(builder, buf.array()); + } + + 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(StandardCharsets.UTF_8).length + 7; + final ByteBuffer buf = ByteBuffer.allocate(length); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(CMD_SET); + buf.putInt(i++); + buf.put((byte) cannedMessage.getBytes(StandardCharsets.UTF_8).length); + buf.put((byte) 0x00); + buf.put(cannedMessage.getBytes(StandardCharsets.UTF_8)); + write(builder, buf.array()); + } + builder.queue(getSupport().getQueue()); + } + + public void requestCannedMessages(final TransactionBuilder builder) { + LOG.info("Requesting canned messages"); + + write(builder, new byte[]{CMD_REQUEST}); + } + + private void sendCannedSmsReplyAllow(final boolean allowed) { + LOG.info("Sending SMS reply allowed = {}", allowed); + + write("allow sms reply", new byte[]{CMD_REPLY_SMS_ALLOW, bool(allowed)}); + } + + private void handleCannedSmsReply(final byte[] payload) { + LOG.info("Canned Message SMS reply"); + + 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); + + write("ack sms reply", new byte[]{CMD_REPLY_SMS_ACK, bool(success)}); + } + + 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); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java new file mode 100644 index 000000000..4539c00b2 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/services/ZeppOsNotificationService.java @@ -0,0 +1,405 @@ +/* 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 android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl; +import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationType; +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.BitmapUtil; +import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue; +import nodomain.freeyourgadget.gadgetbridge.util.NotificationUtils; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; + +public class ZeppOsNotificationService extends AbstractZeppOsService { + private static final Logger LOG = LoggerFactory.getLogger(ZeppOsNotificationService.class); + + public static final short ENDPOINT = 0x001e; + + 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; + + // Keep track of Notification ID -> action handle, as BangleJSDeviceSupport. + // This needs to be simplified. + private final LimitedQueue mNotificationReplyAction = new LimitedQueue(16); + + private final ZeppOsFileUploadService fileUploadService; + + public ZeppOsNotificationService(final Huami2021Support support, final ZeppOsFileUploadService fileUploadService) { + super(support); + this.fileUploadService = fileUploadService; + } + + @Override + public short getEndpoint() { + return ENDPOINT; + } + + @Override + public boolean isEncrypted() { + return true; + } + + @Override + public void handlePayload(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? + deleteNotification(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; + final byte iconFormat = payload[pos]; + pos++; + int width = BLETypeConversions.toUint16(subarray(payload, pos, pos + 2)); + pos += 2; + int height = BLETypeConversions.toUint16(subarray(payload, pos, pos + 2)); + sendIconForPackage(packageName, iconFormat, width, height); + return; + default: + LOG.warn("Unexpected notification byte {}", String.format("0x%02x", payload[0])); + } + } + + public int maxLength() { + return 512; + } + + public void setCallState(final CallSpec callSpec) { + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + + try { + final TransactionBuilder builder = new TransactionBuilder("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 + + write(builder, baos.toByteArray()); + builder.queue(getSupport().getQueue()); + } catch (final Exception e) { + LOG.error("Failed to send call", e); + } + } + + public void sendNotification(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 = new TransactionBuilder("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)); + } else { + // Send the GB package name, otherwise the last notification icon will + // be used wrongly (eg. when receiving an SMS) + baos.write(BuildConfig.APPLICATION_ID.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, maxLength()).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)); + + write(builder, baos.toByteArray()); + builder.queue(getSupport().getQueue()); + } catch (final Exception e) { + LOG.error("Failed to send notification", e); + } + } + + public void deleteNotification(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); // ? + + write("delete notification", buf.array()); + } + + 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); // ? + + write("ack notification reply", buf.array()); + } + + private void ackNotificationAfterIconSent(final String queuedIconPackage) { + LOG.info("Acknowledging icon send for {}", queuedIconPackage); + + final ByteBuffer buf = ByteBuffer.allocate(1 + queuedIconPackage.length() + 1 + 1); + buf.order(ByteOrder.LITTLE_ENDIAN); + buf.put(NOTIFICATION_CMD_ICON_REQUEST_ACK); + buf.put(queuedIconPackage.getBytes(StandardCharsets.UTF_8)); + buf.put((byte) 0x00); + buf.put((byte) 0x01); + + write("ack icon send", buf.array()); + } + + private void sendIconForPackage(final String packageName, final byte iconFormat, final int width, final int height) { + if (getSupport().getMTU() < 247) { + LOG.warn("Sending icons requires high MTU, current MTU is {}", getSupport().getMTU()); + return; + } + + // Without the expected tga id and format string they seem to get corrupted, + // but the encoding seems to actually be the same...? + final String format; + final String tgaId; + switch (iconFormat) { + case 0x04: + format = "TGA_RGB565_GCNANOLITE"; + tgaId = "SOMHP"; + break; + case 0x08: + format = "TGA_RGB565_DAVE2D"; + tgaId = "SOMH6"; + break; + default: + LOG.error("Unknown icon format {}", String.format("0x%02x", iconFormat)); + return; + } + + final Drawable icon = NotificationUtils.getAppIcon(getContext(), packageName); + if (icon == null) { + LOG.warn("Failed to get icon for {}", packageName); + return; + } + + final Bitmap bmp = BitmapUtil.toBitmap(icon); + + // The TGA needs to have this ID, or the band does not accept it + final byte[] tgaIdBytes = new byte[46]; + System.arraycopy(tgaId.getBytes(StandardCharsets.UTF_8), 0, tgaIdBytes, 0, 5); + + final byte[] tga565 = BitmapUtil.convertToTgaRGB565(bmp, width, height, tgaIdBytes); + + 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(".", "_")); + + fileUploadService.sendFile( + url, + filename, + tga565, + new ZeppOsFileUploadService.Callback() { + @Override + public void onFileUploadFinish(final boolean success) { + LOG.info("Finished sending icon, success={}", success); + if (success) { + ackNotificationAfterIconSent(packageName); + } + } + + @Override + public void onFileUploadProgress(final int progress) { + LOG.trace("Icon send progress: {}", progress); + } + } + ); + + LOG.info("Queueing icon for {}", packageName); + } +}