1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-12-29 12:05:53 +01:00

Garmin: Add support for custom replies (notifications and calls)

To enable custom replies an override must be defined in the devices coordinator that actually support custom replies.

The custom preferences allow to:
- enable / disable the default message suffix (Instinct 2 appends "sent from my $vendor device" to each reply by default)
- define custom messages to reply to calls and incoming messages (leaving those lists empty will enable the default messages to be used)

Also adds a new protobuf definition file of mostly unknown values that enable toggling the message suffix on Instinct 2.
This commit is contained in:
Daniele Gobbetti 2024-04-23 16:07:32 +02:00
parent 15916635e1
commit 698908a589
11 changed files with 193 additions and 6 deletions

View File

@ -464,4 +464,6 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_CYCLING_SENSOR_PERSISTENCE_INTERVAL = "pref_cycling_persistence_interval";
public static final String PREF_CYCLING_SENSOR_WHEEL_DIAMETER = "pref_cycling_wheel_diameter";
public static final String PREF_GARMIN_DEFAULT_REPLY_SUFFIX = "pref_key_garmin_default_reply_suffix";
}

View File

@ -648,6 +648,8 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_SPEAK_NOTIFICATIONS_FOCUS_EXCLUSIVE);
addPreferenceHandlerFor(PREF_GARMIN_DEFAULT_REPLY_SUFFIX);
addPreferenceHandlerFor("lock");
String sleepTimeState = prefs.getString(PREF_SLEEP_TIME, PREF_DO_NOT_DISTURB_OFF);

View File

@ -39,6 +39,17 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
connection.add(R.xml.devicesettings_high_mtu);
if (getCannedRepliesSlotCount(device) > 0) {
final List<Integer> notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS);
notifications.add(R.xml.devicesettings_garmin_default_reply_suffix);
notifications.add(R.xml.devicesettings_canned_reply_16);
notifications.add(R.xml.devicesettings_canned_dismisscall_16);
}
final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
developer.add(R.xml.devicesettings_keep_activity_data_on_device);
return deviceSpecificSettings;
}

View File

@ -4,6 +4,7 @@ import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
public class GarminInstinct2SCoordinator extends GarminCoordinator {
@Override
@ -11,6 +12,11 @@ public class GarminInstinct2SCoordinator extends GarminCoordinator {
return Pattern.compile("Instinct 2S");
}
@Override
public int getCannedRepliesSlotCount(final GBDevice device) {
return 16;
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_instinct_2s;

View File

@ -25,6 +25,7 @@ import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec;
import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
@ -32,6 +33,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.Weather;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiDeviceStatus;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiFindMyWatch;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSettingsService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
@ -60,6 +62,7 @@ import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.StringUtils;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_ALLOW_HIGH_MTU;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_GARMIN_DEFAULT_REPLY_SUFFIX;
public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommunicator.Callback {
@ -364,6 +367,28 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
gbDevice.sendDeviceUpdateIntent(getContext());
sendOutgoingMessage(new SupportedFileTypesMessage());
sendOutgoingMessage(toggleDefaultReplySuffix(getDevicePrefs().getBoolean(PREF_GARMIN_DEFAULT_REPLY_SUFFIX, true)));
}
private ProtobufMessage toggleDefaultReplySuffix(boolean value) {
final GdiSettingsService.SettingsService.Builder enableSignature = GdiSettingsService.SettingsService.newBuilder()
.setChangeRequest(
GdiSettingsService.ChangeRequest.newBuilder()
.setPointer1(65566) //TODO: this might be device specific, tested on Instinct 2s
.setPointer2(3) //TODO: this might be device specific, tested on Instinct 2s
.setEnable(GdiSettingsService.ChangeRequest.Switch.newBuilder().setValue(value)));
return protocolBufferHandler.prepareProtobufRequest(
GdiSmartProto.Smart.newBuilder()
.setSettingsService(enableSignature).build());
}
@Override
public void onSendConfiguration(String config) {
if (PREF_GARMIN_DEFAULT_REPLY_SUFFIX.equals(config)) {
sendOutgoingMessage(toggleDefaultReplySuffix(getDevicePrefs().getBoolean(PREF_GARMIN_DEFAULT_REPLY_SUFFIX, true)));
}
}
private void processDownloadQueue() {
@ -451,6 +476,11 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni
sendOutgoingMessage(findMyWatch);
}
@Override
public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
sendOutgoingMessage(protocolBufferHandler.setCannedMessages(cannedMessagesSpec));
}
@Override
public void onSetMusicInfo(MusicSpec musicSpec) {

View File

@ -179,8 +179,8 @@ public class NotificationsHandler implements MessageHandler {
deviceEvtNotificationControl.handle = notificationSpec.getId();
final GBDeviceEventCallControl deviceEvtCallControl = new GBDeviceEventCallControl();
switch (message.getNotificationAction()) {
case REPLY_CUSTOM_MESSAGES:
case REPLY_STANDARD_MESSAGES:
case REPLY_INCOMING_CALL:
case REPLY_MESSAGES:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY;
deviceEvtNotificationControl.reply = message.getActionString();
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE)) {
@ -339,7 +339,7 @@ public class NotificationsHandler implements MessageHandler {
final List<byte[]> garminActions = new ArrayList<>();
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE)) {
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_STANDARD_MESSAGES, " ")); //text is not shown on watch
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_INCOMING_CALL, " ")); //text is not shown on watch
garminActions.add(encodeNotificationAction(NotificationAction.REJECT_INCOMING_CALL, " ")); //text is not shown on watch
garminActions.add(encodeNotificationAction(NotificationAction.ACCEPT_INCOMING_CALL, " ")); //text is not shown on watch
}
@ -347,7 +347,7 @@ public class NotificationsHandler implements MessageHandler {
for (NotificationSpec.Action action : notificationSpec.attachedActions) {
switch (action.type) {
case NotificationSpec.Action.TYPE_WEARABLE_REPLY:
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_STANDARD_MESSAGES, action.title));
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_MESSAGES, action.title));
break;
case NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS:
garminActions.add(encodeNotificationAction(NotificationAction.DISMISS_NOTIFICATION, action.title));
@ -389,8 +389,8 @@ public class NotificationsHandler implements MessageHandler {
}
public enum NotificationAction {
REPLY_CUSTOM_MESSAGES(94, NotificationActionIconPosition.RIGHT), // uses the customized replies (set through protobuf??) does nothing if not available and on watches that do not support custom replies
REPLY_STANDARD_MESSAGES(95, NotificationActionIconPosition.BOTTOM), // uses predefined replies
REPLY_INCOMING_CALL(94, NotificationActionIconPosition.BOTTOM),
REPLY_MESSAGES(95, NotificationActionIconPosition.BOTTOM),
ACCEPT_INCOMING_CALL(96, NotificationActionIconPosition.RIGHT),
REJECT_INCOMING_CALL(97, NotificationActionIconPosition.LEFT),
DISMISS_NOTIFICATION(98, NotificationActionIconPosition.LEFT),

View File

@ -8,16 +8,19 @@ 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 nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo;
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.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmsNotification;
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;
@ -33,6 +36,8 @@ public class ProtocolBufferHandler implements MessageHandler {
private final int maxChunkSize = 375; //tested on Vívomove Style
private int lastProtobufRequestId;
private Map<GdiSmsNotification.SmsNotificationService.CannedListType, String[]> cannedListTypeMap;
public ProtocolBufferHandler(GarminSupport deviceSupport) {
this.deviceSupport = deviceSupport;
chunkedFragmentsMap = new HashMap<>();
@ -74,6 +79,9 @@ public class ProtocolBufferHandler implements MessageHandler {
if (smart.hasCalendarService()) {
return prepareProtobufResponse(processProtobufCalendarRequest(smart.getCalendarService()), message.getRequestId());
}
if (smart.hasSmsNotificationService()) {
return prepareProtobufResponse(processProtobufSmsNotificationMessage(smart.getSmsNotificationService()), message.getRequestId());
}
if (smart.hasDeviceStatusService()) {
processed = true;
processProtobufDeviceStatusResponse(smart.getDeviceStatusService());
@ -220,6 +228,62 @@ public class ProtocolBufferHandler implements MessageHandler {
// return null;
// }
private GdiSmartProto.Smart processProtobufSmsNotificationMessage(GdiSmsNotification.SmsNotificationService smsNotificationService) {
if (smsNotificationService.hasSmsCannedListRequest()) {
if (null == this.cannedListTypeMap || this.cannedListTypeMap.isEmpty()) {
this.cannedListTypeMap = new HashMap<>();
List<GdiSmsNotification.SmsNotificationService.CannedListType> requestedTypes = smsNotificationService.getSmsCannedListRequest().getRequestedTypesList();
for (GdiSmsNotification.SmsNotificationService.CannedListType type :
requestedTypes) {
if (GdiSmsNotification.SmsNotificationService.CannedListType.SMS_MESSAGE_RESPONSE.equals(type)) {
final ArrayList<String> 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<String> 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<GdiSmsNotification.SmsNotificationService.CannedListType> 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(this.cannedListTypeMap.get(requestedType)))
.setType(requestedType)
);
} else {
builder.setStatus(GdiSmsNotification.SmsNotificationService.ResponseStatus.GENERIC_ERROR);
LOG.error("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");
@ -260,6 +324,36 @@ public class ProtocolBufferHandler implements MessageHandler {
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;
}
if (null == this.cannedListTypeMap) {
this.cannedListTypeMap = new HashMap<>();
}
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;

View File

@ -0,0 +1,29 @@
syntax = "proto2";
package garmin_vivomovehr;
option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr";
message SettingsService {
optional ChangeRequest change_request = 5;
optional ChangeResponse change_response = 6;
}
message ChangeRequest {
optional uint32 pointer1 = 1;
optional uint32 pointer2 = 2;
optional Switch enable = 3;
message Switch {
required bool value = 1;
}
}
message ChangeResponse {
optional ResponseStatus status = 1;
}
enum ResponseStatus {
SUCCESS = 0;
GENERIC_ERROR = 1;
}

View File

@ -9,6 +9,7 @@ import "garmin_vivomovehr/gdi_find_my_watch.proto";
import "garmin_vivomovehr/gdi_core.proto";
import "garmin_vivomovehr/gdi_sms_notification.proto";
import "garmin_vivomovehr/gdi_calendar_service.proto";
import "garmin_vivomovehr/gdi_settings_service.proto";
message Smart {
optional CalendarService calendar_service = 1;
@ -16,6 +17,7 @@ message Smart {
optional FindMyWatchService find_my_watch_service = 12;
optional CoreService core_service = 13;
optional SmsNotificationService sms_notification_service = 16;
optional SettingsService settings_service = 42;
}
/*

View File

@ -2819,6 +2819,8 @@
<string name="pref_title_bottom_navigation_bar">Bottom navigation bar</string>
<string name="pref_summary_bottom_navigation_bar_on">Switch between main screens using the navigation bar or horizontal swiping</string>
<string name="pref_summary_bottom_navigation_bar_off">Switch between main screens only using horizontal swiping</string>
<string name="pref_summary_garmin_default_reply_suffix">Appended in addition to the suffix set in Gadgetbridge</string>
<string name="pref_title_garmin_default_reply_suffix">Use predefined reply suffix</string>
<string name="pref_dashboard_widget_today_upside_down_title">Midnight at bottom</string>
<string name="pref_dashboard_widget_today_upside_down_summary">In 24h mode, draw midnight at the bottom, midday at the top of the chart</string>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<SwitchPreferenceCompat
android:key="pref_key_garmin_default_reply_suffix"
android:summary="@string/pref_summary_garmin_default_reply_suffix"
android:title="@string/pref_title_garmin_default_reply_suffix">
</SwitchPreferenceCompat>
</androidx.preference.PreferenceScreen>