package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; import android.location.Location; import com.google.protobuf.InvalidProtocolBufferException; import org.apache.commons.lang3.ArrayUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProviderType; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService; import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCalendarService; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCore; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDeviceStatus; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiFindMyWatch; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiHttpService; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto; import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmsNotification; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.HttpHandler; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.ProtobufStatusMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.CurrentPosition; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarEvent; import nodomain.freeyourgadget.gadgetbridge.util.calendar.CalendarManager; public class ProtocolBufferHandler implements MessageHandler { private static final Logger LOG = LoggerFactory.getLogger(ProtocolBufferHandler.class); private final GarminSupport deviceSupport; private final Map chunkedFragmentsMap; private final int maxChunkSize = 375; //tested on VĂ­vomove Style private int lastProtobufRequestId; private final Map cannedListTypeMap = new HashMap<>(); public ProtocolBufferHandler(GarminSupport deviceSupport) { this.deviceSupport = deviceSupport; chunkedFragmentsMap = new HashMap<>(); } private int getNextProtobufRequestId() { lastProtobufRequestId = (lastProtobufRequestId + 1) % 65536; return lastProtobufRequestId; } public ProtobufMessage handle(GFDIMessage protobufMessage) { if (protobufMessage instanceof ProtobufMessage) { return processIncoming((ProtobufMessage) protobufMessage); } else if (protobufMessage instanceof ProtobufStatusMessage) { return processIncoming((ProtobufStatusMessage) protobufMessage); } return null; } private ProtobufMessage processIncoming(ProtobufMessage message) { ProtobufFragment protobufFragment = processChunkedMessage(message); if (protobufFragment.isComplete()) { //message is now complete LOG.info("Received protobuf message #{}, {}B: {}", message.getRequestId(), protobufFragment.totalLength, GB.hexdump(protobufFragment.fragmentBytes, 0, protobufFragment.totalLength)); final GdiSmartProto.Smart smart; try { smart = GdiSmartProto.Smart.parseFrom(protobufFragment.fragmentBytes); } catch (InvalidProtocolBufferException e) { LOG.error("Failed to parse protobuf message ({}): {}", e.getLocalizedMessage(), GB.hexdump(protobufFragment.fragmentBytes)); return null; } boolean processed = false; if (smart.hasCoreService()) { //TODO: unify request and response??? return prepareProtobufResponse(processProtobufCoreRequest(smart.getCoreService()), message.getRequestId()); } if (smart.hasCalendarService()) { return prepareProtobufResponse(processProtobufCalendarRequest(smart.getCalendarService()), message.getRequestId()); } if (smart.hasSmsNotificationService()) { return prepareProtobufResponse(processProtobufSmsNotificationMessage(smart.getSmsNotificationService()), message.getRequestId()); } if (smart.hasHttpService()) { final GdiHttpService.HttpService response = HttpHandler.handle(smart.getHttpService()); if (response == null) { return null; } return prepareProtobufResponse(GdiSmartProto.Smart.newBuilder().setHttpService(response).build(), message.getRequestId()); } if (smart.hasDeviceStatusService()) { processed = true; processProtobufDeviceStatusResponse(smart.getDeviceStatusService()); } if (smart.hasFindMyWatchService()) { processed = true; processProtobufFindMyWatchResponse(smart.getFindMyWatchService()); } if (!processed) { LOG.warn("Unknown protobuf request: {}", smart); message.setStatusMessage(new ProtobufStatusMessage(message.getMessageType(), GFDIMessage.Status.ACK, message.getRequestId(), message.getDataOffset(), ProtobufStatusMessage.ProtobufChunkStatus.DISCARDED, ProtobufStatusMessage.ProtobufStatusCode.UNKNOWN_REQUEST_ID)); } } return null; } private ProtobufMessage processIncoming(ProtobufStatusMessage statusMessage) { LOG.info("Processing protobuf status message #{}@{}: status={}, error={}", statusMessage.getRequestId(), statusMessage.getDataOffset(), statusMessage.getProtobufChunkStatus(), statusMessage.getProtobufStatusCode()); //TODO: check status and react accordingly, right now we blindly proceed to next chunk if (chunkedFragmentsMap.containsKey(statusMessage.getRequestId()) && statusMessage.isOK()) { final ProtobufFragment protobufFragment = chunkedFragmentsMap.get(statusMessage.getRequestId()); LOG.debug("Protobuf message #{} found in queue: {}", statusMessage.getRequestId(), GB.hexdump(protobufFragment.fragmentBytes)); if (protobufFragment.totalLength <= (statusMessage.getDataOffset() + maxChunkSize)) { chunkedFragmentsMap.remove(protobufFragment); } return protobufFragment.getNextChunk(statusMessage); } return null; } private ProtobufFragment processChunkedMessage(ProtobufMessage message) { if (message.isComplete()) //comment this out if for any reason also smaller messages should end up in the map return new ProtobufFragment(message.getMessageBytes()); if (message.getDataOffset() == 0) { //store new messages beginning at 0, overwrite old messages chunkedFragmentsMap.put(message.getRequestId(), new ProtobufFragment(message)); LOG.info("Protobuf request put in queue: #{} , {}", message.getRequestId(), GB.hexdump(message.getMessageBytes())); } else { if (chunkedFragmentsMap.containsKey(message.getRequestId())) { ProtobufFragment oldFragment = chunkedFragmentsMap.get(message.getRequestId()); chunkedFragmentsMap.put(message.getRequestId(), new ProtobufFragment(oldFragment, message)); } } return chunkedFragmentsMap.get(message.getRequestId()); } private GdiSmartProto.Smart processProtobufCalendarRequest(GdiCalendarService.CalendarService calendarService) { if (calendarService.hasCalendarRequest()) { GdiCalendarService.CalendarService.CalendarServiceRequest calendarServiceRequest = calendarService.getCalendarRequest(); CalendarManager upcomingEvents = new CalendarManager(deviceSupport.getContext(), deviceSupport.getDevice().getAddress()); List mEvents = upcomingEvents.getCalendarEventList(); List watchEvents = new ArrayList<>(); for (CalendarEvent mEvt : mEvents) { if (mEvt.getEndSeconds() < calendarServiceRequest.getBegin() || mEvt.getBeginSeconds() > calendarServiceRequest.getEnd()) { LOG.debug("CalendarService Skipping event {} that is out of requested time range", mEvt.getTitle()); continue; } if (!calendarServiceRequest.getIncludeAllDay() && mEvt.isAllDay()) { LOG.debug("CalendarService Skipping event {} that is AllDay", mEvt.getTitle()); continue; } if (watchEvents.size() >= calendarServiceRequest.getMaxEvents() * 2) { //NOTE: Tested with values higher than double of the reported max without issues LOG.debug("Reached the maximum number of events supported by the watch"); break; } final GdiCalendarService.CalendarService.CalendarEvent.Builder event = GdiCalendarService.CalendarService.CalendarEvent.newBuilder() .setTitle(mEvt.getTitle().substring(0, Math.min(mEvt.getTitle().length(), calendarServiceRequest.getMaxTitleLength()))) .setAllDay(mEvt.isAllDay()) .setStartDate(mEvt.getBeginSeconds()) .setEndDate(mEvt.getEndSeconds()); if (calendarServiceRequest.getIncludeLocation() && mEvt.getLocation() != null) { event.setLocation(mEvt.getLocation().substring(0, Math.min(mEvt.getLocation().length(), calendarServiceRequest.getMaxLocationLength()))); } if (calendarServiceRequest.getIncludeDescription() && mEvt.getDescription() != null) { event.setDescription(mEvt.getDescription().substring(0, Math.min(mEvt.getDescription().length(), calendarServiceRequest.getMaxDescriptionLength()))); } if (calendarServiceRequest.getIncludeOrganizer() && mEvt.getOrganizer() != null) { event.setDescription(mEvt.getOrganizer().substring(0, Math.min(mEvt.getOrganizer().length(), calendarServiceRequest.getMaxOrganizerLength()))); } watchEvents.add(event.build()); } LOG.debug("CalendarService Sending {} events to watch", watchEvents.size()); return GdiSmartProto.Smart.newBuilder().setCalendarService( GdiCalendarService.CalendarService.newBuilder().setCalendarResponse( GdiCalendarService.CalendarService.CalendarServiceResponse.newBuilder() .addAllCalendarEvent(watchEvents) .setStatus(GdiCalendarService.CalendarService.CalendarServiceResponse.ResponseStatus.OK) ) ).build(); } LOG.warn("Unknown CalendarService request: {}", calendarService); return GdiSmartProto.Smart.newBuilder().setCalendarService( GdiCalendarService.CalendarService.newBuilder().setCalendarResponse( GdiCalendarService.CalendarService.CalendarServiceResponse.newBuilder() .setStatus(GdiCalendarService.CalendarService.CalendarServiceResponse.ResponseStatus.UNKNOWN_RESPONSE_STATUS) ) ).build(); } private void processProtobufDeviceStatusResponse(GdiDeviceStatus.DeviceStatusService deviceStatusService) { if (deviceStatusService.hasRemoteDeviceBatteryStatusResponse()) { final GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusResponse batteryStatusResponse = deviceStatusService.getRemoteDeviceBatteryStatusResponse(); final int batteryLevel = batteryStatusResponse.getCurrentBatteryLevel(); LOG.info("Received remote battery status {}: level={}", batteryStatusResponse.getStatus(), batteryLevel); final GBDeviceEventBatteryInfo batteryEvent = new GBDeviceEventBatteryInfo(); batteryEvent.level = (short) batteryLevel; deviceSupport.evaluateGBDeviceEvent(batteryEvent); return; } if (deviceStatusService.hasActivityStatusResponse()) { final GdiDeviceStatus.DeviceStatusService.ActivityStatusResponse activityStatusResponse = deviceStatusService.getActivityStatusResponse(); LOG.info("Received activity status: {}", activityStatusResponse.getStatus()); return; } LOG.warn("Unknown DeviceStatusService response: {}", deviceStatusService); } private GdiSmartProto.Smart processProtobufCoreRequest(GdiCore.CoreService coreService) { if (coreService.hasSyncResponse()) { final GdiCore.CoreService.SyncResponse syncResponse = coreService.getSyncResponse(); LOG.info("Received sync status: {}", syncResponse.getStatus()); return null; } if (coreService.hasGetLocationRequest()) { LOG.info("Got location request"); final Location location = new CurrentPosition().getLastKnownLocation(); final GdiCore.CoreService.GetLocationResponse.Builder response = GdiCore.CoreService.GetLocationResponse.newBuilder(); if (location.getLatitude() == 0 && location.getLongitude() == 0) { response.setStatus(GdiCore.CoreService.GetLocationResponse.Status.NO_VALID_LOCATION); } else { response.setStatus(GdiCore.CoreService.GetLocationResponse.Status.OK) .setLocationData(GarminUtils.toLocationData(location, GdiCore.CoreService.DataType.GENERAL_LOCATION)); } return GdiSmartProto.Smart.newBuilder().setCoreService( GdiCore.CoreService.newBuilder().setGetLocationResponse(response)).build(); } if (coreService.hasLocationUpdatedSetEnabledRequest()) { final GdiCore.CoreService.LocationUpdatedSetEnabledRequest locationUpdatedSetEnabledRequest = coreService.getLocationUpdatedSetEnabledRequest(); LOG.info("Received locationUpdatedSetEnabledRequest status: {}", locationUpdatedSetEnabledRequest.getEnabled()); GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Builder response = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.newBuilder() .setStatus(GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Status.OK); final boolean sendGpsPref = deviceSupport.getDevicePrefs().getBoolean(DeviceSettingsPreferenceConst.PREF_WORKOUT_SEND_GPS_TO_BAND, false); GdiCore.CoreService.Request realtimeRequest = null; if (locationUpdatedSetEnabledRequest.getEnabled()) { for (final GdiCore.CoreService.Request request : locationUpdatedSetEnabledRequest.getRequestsList()) { final GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus requestedStatus; if (GdiCore.CoreService.DataType.REALTIME_TRACKING.equals(request.getRequested())) { realtimeRequest = request; if (sendGpsPref) { requestedStatus = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus.OK; } else { requestedStatus = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus.KO; } } else { requestedStatus = GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.RequestedStatus.KO; } response.addRequests( GdiCore.CoreService.LocationUpdatedSetEnabledResponse.Requested.newBuilder() .setRequested(request.getRequested()) .setStatus(requestedStatus) ); } } if (sendGpsPref) { if (realtimeRequest != null) { GBLocationService.start( deviceSupport.getContext(), deviceSupport.getDevice(), GBLocationProviderType.GPS, 1000 // TODO from realtimeRequest ); } else { GBLocationService.stop(deviceSupport.getContext(), deviceSupport.getDevice()); } } return GdiSmartProto.Smart.newBuilder().setCoreService( GdiCore.CoreService.newBuilder().setLocationUpdatedSetEnabledResponse(response)).build(); } LOG.warn("Unknown CoreService request: {}", coreService); return null; } private GdiSmartProto.Smart processProtobufSmsNotificationMessage(GdiSmsNotification.SmsNotificationService smsNotificationService) { if (smsNotificationService.hasSmsCannedListRequest()) { LOG.debug("Got request for sms canned list"); // Mark canned messages as supported deviceSupport.evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences(GarminPreferences.PREF_FEAT_CANNED_MESSAGES, true)); if (this.cannedListTypeMap.isEmpty()) { List requestedTypes = smsNotificationService.getSmsCannedListRequest().getRequestedTypesList(); for (GdiSmsNotification.SmsNotificationService.CannedListType type : requestedTypes) { if (GdiSmsNotification.SmsNotificationService.CannedListType.SMS_MESSAGE_RESPONSE.equals(type)) { final ArrayList messages = new ArrayList<>(); for (int i = 1; i <= 16; i++) { String message = deviceSupport.getDevicePrefs().getString("canned_reply_" + i, null); if (message != null && !message.isEmpty()) { messages.add(message); } } if (!messages.isEmpty()) this.cannedListTypeMap.put(type, messages.toArray(new String[0])); } else if (GdiSmsNotification.SmsNotificationService.CannedListType.PHONE_CALL_RESPONSE.equals(type)) { final ArrayList messages = new ArrayList<>(); for (int i = 1; i <= 16; i++) { String message = deviceSupport.getDevicePrefs().getString("canned_message_dismisscall_" + i, null); if (message != null && !message.isEmpty()) { messages.add(message); } } if (!messages.isEmpty()) this.cannedListTypeMap.put(type, messages.toArray(new String[0])); } } } List requestedTypes = smsNotificationService.getSmsCannedListRequest().getRequestedTypesList(); GdiSmsNotification.SmsNotificationService.SmsCannedListResponse.Builder builder = GdiSmsNotification.SmsNotificationService.SmsCannedListResponse.newBuilder() .setStatus(GdiSmsNotification.SmsNotificationService.ResponseStatus.SUCCESS); for (GdiSmsNotification.SmsNotificationService.CannedListType requestedType : requestedTypes) { if (this.cannedListTypeMap.containsKey(requestedType)) { builder.addLists(GdiSmsNotification.SmsNotificationService.SmsCannedList.newBuilder() .addAllResponse(Arrays.asList(Objects.requireNonNull(this.cannedListTypeMap.get(requestedType)))) .setType(requestedType) ); } else { builder.setStatus(GdiSmsNotification.SmsNotificationService.ResponseStatus.GENERIC_ERROR); LOG.info("Missing canned messages data for type {}", requestedType); } } return GdiSmartProto.Smart.newBuilder().setSmsNotificationService(GdiSmsNotification.SmsNotificationService.newBuilder().setSmsCannedListResponse(builder)).build(); } else { LOG.warn("Protobuf smsNotificationService request not implemented: {}", smsNotificationService); return null; } } private void processProtobufFindMyWatchResponse(GdiFindMyWatch.FindMyWatchService findMyWatchService) { if (findMyWatchService.hasCancelRequest()) { LOG.info("Watch found"); } if (findMyWatchService.hasCancelResponse() || findMyWatchService.hasFindResponse()) { LOG.debug("Received findMyWatch response"); } LOG.warn("Unknown FindMyWatchService response: {}", findMyWatchService); } public ProtobufMessage prepareProtobufRequest(GdiSmartProto.Smart protobufPayload) { if (null == protobufPayload) return null; final int requestId = getNextProtobufRequestId(); return prepareProtobufMessage(protobufPayload.toByteArray(), GFDIMessage.GarminMessage.PROTOBUF_REQUEST, requestId); } private ProtobufMessage prepareProtobufResponse(GdiSmartProto.Smart protobufPayload, int requestId) { if (null == protobufPayload) return null; return prepareProtobufMessage(protobufPayload.toByteArray(), GFDIMessage.GarminMessage.PROTOBUF_RESPONSE, requestId); } private ProtobufMessage prepareProtobufMessage(byte[] bytes, GFDIMessage.GarminMessage garminMessage, int requestId) { if (bytes == null || bytes.length == 0) return null; LOG.info("Preparing protobuf message. Type{}, #{}, {}B: {}", garminMessage, requestId, bytes.length, GB.hexdump(bytes, 0, bytes.length)); if (bytes.length > maxChunkSize) { chunkedFragmentsMap.put(requestId, new ProtobufFragment(bytes)); return new ProtobufMessage(garminMessage, requestId, 0, bytes.length, maxChunkSize, ArrayUtils.subarray(bytes, 0, maxChunkSize)); } return new ProtobufMessage(garminMessage, requestId, 0, bytes.length, bytes.length, bytes); } public ProtobufMessage setCannedMessages(CannedMessagesSpec cannedMessagesSpec) { final GdiSmsNotification.SmsNotificationService.CannedListType cannedListType; switch (cannedMessagesSpec.type) { case CannedMessagesSpec.TYPE_REJECTEDCALLS: cannedListType = GdiSmsNotification.SmsNotificationService.CannedListType.PHONE_CALL_RESPONSE; break; case CannedMessagesSpec.TYPE_GENERIC: case CannedMessagesSpec.TYPE_NEWSMS: cannedListType = GdiSmsNotification.SmsNotificationService.CannedListType.SMS_MESSAGE_RESPONSE; break; default: LOG.warn("Unknown canned messages type, ignoring."); return null; } this.cannedListTypeMap.put(cannedListType, cannedMessagesSpec.cannedMessages); GdiSmartProto.Smart smart = GdiSmartProto.Smart.newBuilder() .setSmsNotificationService(GdiSmsNotification.SmsNotificationService.newBuilder() .setSmsCannedListChangedNotification( GdiSmsNotification.SmsNotificationService.SmsCannedListChangedNotification.newBuilder().addChangedType(cannedListType) ) ).build(); return prepareProtobufRequest(smart); } private class ProtobufFragment { private final byte[] fragmentBytes; private final int totalLength; public ProtobufFragment(byte[] fragmentBytes) { this.fragmentBytes = fragmentBytes; this.totalLength = fragmentBytes.length; } public ProtobufFragment(ProtobufMessage message) { if (message.getDataOffset() != 0) throw new IllegalArgumentException("Cannot create fragment if message is not the first of the sequence"); this.fragmentBytes = message.getMessageBytes(); this.totalLength = message.getTotalProtobufLength(); } public ProtobufFragment(ProtobufFragment existing, ProtobufMessage toMerge) { if (toMerge.getDataOffset() != existing.fragmentBytes.length) throw new IllegalArgumentException("Cannot merge fragment: incoming message has different offset than needed"); this.fragmentBytes = ArrayUtils.addAll(existing.fragmentBytes, toMerge.getMessageBytes()); this.totalLength = existing.totalLength; } public ProtobufMessage getNextChunk(ProtobufStatusMessage protobufStatusMessage) { int start = protobufStatusMessage.getDataOffset() + maxChunkSize; int length = Math.min(maxChunkSize, this.fragmentBytes.length - start); return new ProtobufMessage(protobufStatusMessage.getMessageType(), protobufStatusMessage.getRequestId(), start, this.totalLength, length, ArrayUtils.subarray(this.fragmentBytes, start, start + length)); } public boolean isComplete() { return totalLength == fragmentBytes.length; } } }