From b7aec071ff6a984560a9e17fd784d15c8f642757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Rebelo?= Date: Fri, 7 Jun 2024 23:39:12 +0100 Subject: [PATCH] Garmin: Realtime settings --- app/src/main/AndroidManifest.xml | 4 + .../AbstractPreferenceFragment.java | 7 +- .../DeviceSettingsPreferenceConst.java | 2 - .../DeviceSpecificSettingsFragment.java | 2 - .../devices/garmin/GarminCoordinator.java | 12 +- .../devices/garmin/GarminPreferences.java | 1 + .../GarminRealtimeSettingsActivity.java | 93 ++ .../GarminRealtimeSettingsFragment.java | 893 ++++++++++++++++++ .../garmin/GarminSettingsCustomizer.java | 11 + .../devices/vivomovehr/GarminCapability.java | 43 +- .../service/devices/garmin/GarminSupport.java | 98 +- .../devices/garmin/ProtocolBufferHandler.java | 56 +- .../deviceevents/CapabilitiesDeviceEvent.java | 14 + .../garmin/messages/ConfigurationMessage.java | 10 +- .../gadgetbridge/util/Optional.java | 10 + .../gadgetbridge/util/StringUtils.java | 6 +- .../gadgetbridge/util/XDatePreference.java | 114 +++ .../util/XDatePreferenceFragment.java | 80 ++ .../gadgetbridge/util/XTimePreference.java | 44 +- .../util/XTimePreferenceFragment.java | 2 +- .../proto/garmin/gdi_settings_service.proto | 211 ++++- app/src/main/res/drawable/ic_add_gray.xml | 12 + .../menu/menu_garmin_realtime_settings.xml | 17 + app/src/main/res/values/strings.xml | 8 + ...cesettings_garmin_default_reply_suffix.xml | 9 - ...evicesettings_garmin_realtime_settings.xml | 7 + .../main/res/xml/garmin_realtime_settings.xml | 5 + 27 files changed, 1703 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsActivity.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/CapabilitiesDeviceEvent.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreferenceFragment.java create mode 100644 app/src/main/res/drawable/ic_add_gray.xml create mode 100644 app/src/main/res/menu/menu_garmin_realtime_settings.xml delete mode 100644 app/src/main/res/xml/devicesettings_garmin_default_reply_suffix.xml create mode 100644 app/src/main/res/xml/devicesettings_garmin_realtime_settings.xml create mode 100644 app/src/main/res/xml/garmin_realtime_settings.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c5219698..82e08c474 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -179,6 +179,10 @@ android:name=".activities.loyaltycards.LoyaltyCardsSettingsActivity" android:label="@string/loyalty_cards" android:parentActivityName=".activities.devicesettings.DeviceSettingsActivity" /> + notifications = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CALLS_AND_NOTIFICATIONS); notifications.add(R.xml.devicesettings_send_app_notifications); if (getCannedRepliesSlotCount(device) > 0) { - 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); } @@ -221,4 +226,9 @@ public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator { public boolean supportsAgpsUpdates(final GBDevice device) { return !getPrefs(device).getString(GarminPreferences.PREF_AGPS_KNOWN_URLS, "").isEmpty(); } + + public boolean supports(final GBDevice device, final GarminCapability capability) { + return getPrefs(device).getStringSet(GarminPreferences.PREF_GARMIN_CAPABILITIES, Collections.emptySet()) + .contains(capability.name()); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java index d3f614b54..1e5a88579 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminPreferences.java @@ -11,6 +11,7 @@ public class GarminPreferences { public static final String PREF_GARMIN_AGPS_UPDATE_TIME = "garmin_agps_update_time_%s"; public static final String PREF_GARMIN_AGPS_FOLDER = "garmin_agps_folder"; public static final String PREF_GARMIN_AGPS_FILENAME = "garmin_agps_filename_%s"; + public static final String PREF_GARMIN_REALTIME_SETTINGS = "garmin_realtime_settings"; public static String agpsStatus(final String url) { return String.format(GarminPreferences.PREF_GARMIN_AGPS_STATUS, CheckSums.md5(url)); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsActivity.java new file mode 100644 index 000000000..52b756599 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsActivity.java @@ -0,0 +1,93 @@ +package nodomain.freeyourgadget.gadgetbridge.devices.garmin; + +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.preference.PreferenceFragmentCompat; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractSettingsActivityV2; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.Optional; + +public class GarminRealtimeSettingsActivity extends AbstractSettingsActivityV2 { + private GBDevice device; + private int screenId; + + public static final String EXTRA_SCREEN_ID = "screenId"; + + @Override + protected String fragmentTag() { + return GarminRealtimeSettingsFragment.FRAGMENT_TAG; + } + + @Override + protected PreferenceFragmentCompat newFragment() { + return GarminRealtimeSettingsFragment.newInstance(device, screenId); + } + + @Override + protected void onCreate(final Bundle savedInstanceState) { + device = getIntent().getParcelableExtra(GBDevice.EXTRA_DEVICE); + screenId = getIntent().getIntExtra(EXTRA_SCREEN_ID, GarminRealtimeSettingsFragment.ROOT_SCREEN_ID); + + super.onCreate(savedInstanceState); + + if (device == null || !device.isInitialized()) { + GB.toast(getString(R.string.watch_not_connected), Toast.LENGTH_SHORT, GB.INFO); + finish(); + } + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + final MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_garmin_realtime_settings, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + final int itemId = item.getItemId(); + + if (itemId == R.id.garmin_rt_debug_toggle) { + getFragment().ifPresent(GarminRealtimeSettingsFragment::toggleDebug); + return true; + } else if (itemId == R.id.garmin_rt_debug_share) { + getFragment().ifPresent(GarminRealtimeSettingsFragment::shareDebug); + return true; + } + + return super.onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(final int requestCode, final int resultCode, final @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + getFragment().ifPresent(GarminRealtimeSettingsFragment::refreshFromDevice); + } + + private Optional getFragment() { + final FragmentManager fragmentManager = getSupportFragmentManager(); + final Fragment fragment = fragmentManager.findFragmentByTag(GarminRealtimeSettingsFragment.FRAGMENT_TAG); + if (fragment == null) { + return Optional.empty(); + } + + if (fragment instanceof GarminRealtimeSettingsFragment) { + return Optional.of((GarminRealtimeSettingsFragment) fragment); + } + + return Optional.empty(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java new file mode 100644 index 000000000..b68ab95de --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminRealtimeSettingsFragment.java @@ -0,0 +1,893 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.devices.garmin; + +import android.content.ActivityNotFoundException; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.text.Editable; +import android.text.InputFilter; +import android.text.InputType; +import android.text.TextWatcher; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.fragment.app.FragmentActivity; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.preference.DialogPreference; +import androidx.preference.EditTextPreference; +import androidx.preference.ListPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceCategory; +import androidx.preference.PreferenceScreen; +import androidx.preference.SwitchPreferenceCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.protobuf.InvalidProtocolBufferException; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import nodomain.freeyourgadget.gadgetbridge.BuildConfig; +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.activities.AbstractPreferenceFragment; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSettingsService; +import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmartProto; +import nodomain.freeyourgadget.gadgetbridge.util.GB; +import nodomain.freeyourgadget.gadgetbridge.util.Prefs; +import nodomain.freeyourgadget.gadgetbridge.util.StringUtils; +import nodomain.freeyourgadget.gadgetbridge.util.XDatePreference; +import nodomain.freeyourgadget.gadgetbridge.util.XTimePreference; + +public class GarminRealtimeSettingsFragment extends AbstractPreferenceFragment { + private static final Logger LOG = LoggerFactory.getLogger(GarminRealtimeSettingsFragment.class); + + public static final String EXTRA_SCREEN_ID = "screenId"; + public static final String PREF_DEBUG = "garmin_rt_debug_mode"; + + public static final int ROOT_SCREEN_ID = 36352; + + static final String FRAGMENT_TAG = "GARMIN_REALTIME_SETTINGS_FRAGMENT"; + + private GBDevice device; + private int screenId = ROOT_SCREEN_ID; + + private GdiSettingsService.ScreenDefinition screenDefinition; + private GdiSettingsService.ScreenState screenState; + + public static final String EXTRA_PROTOBUF = "protobuf"; + + public static final String ACTION_SCREEN_DEFINITION = "nodomain.freeyourgadget.gadgetbridge.garmin.realtime_settings.screen_definition"; + public static final String ACTION_SCREEN_STATE = "nodomain.freeyourgadget.gadgetbridge.garmin.realtime_settings.screen_state"; + public static final String ACTION_CHANGE = "nodomain.freeyourgadget.gadgetbridge.garmin.realtime_settings.change"; + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + final String action = intent.getAction(); + if (action == null) { + LOG.error("Got null action"); + return; + } + + switch (action) { + case ACTION_SCREEN_DEFINITION: + final GdiSettingsService.ScreenDefinition incomingScreen; + try { + incomingScreen = GdiSettingsService.ScreenDefinition.parseFrom(intent.getByteArrayExtra(EXTRA_PROTOBUF)); + } catch (final InvalidProtocolBufferException e) { + // should never happen + LOG.error("Failed to parse protobuf for screen definition on {}", screenId, e); + return; + } + if (incomingScreen.getScreenId() != screenId) { + return; + } + LOG.debug("Got screen definition for screenId={}", screenId); + screenDefinition = incomingScreen; + break; + case ACTION_SCREEN_STATE: + final GdiSettingsService.ScreenState incomingState; + try { + incomingState = GdiSettingsService.ScreenState.parseFrom(intent.getByteArrayExtra(EXTRA_PROTOBUF)); + } catch (final InvalidProtocolBufferException e) { + // should never happen + LOG.error("Failed to parse protobuf for screen state on {}", screenId, e); + return; + } + if (incomingState.getScreenId() != screenId) { + return; + } + LOG.debug("Got screen state for screenId={}", screenId); + screenState = incomingState; + break; + case ACTION_CHANGE: + final GdiSettingsService.ChangeResponse incomingChange; + try { + incomingChange = GdiSettingsService.ChangeResponse.parseFrom(intent.getByteArrayExtra(EXTRA_PROTOBUF)); + } catch (final InvalidProtocolBufferException e) { + // should never happen + LOG.error("Failed to parse protobuf for change", e); + return; + } + if (incomingChange.getState().getScreenId() != screenId) { + return; + } + + if (incomingChange.getShouldReturn()) { + LOG.debug("Returning from {}", screenId); + requireActivity().finish(); + return; + } + + LOG.debug("Got screen change for screenId={}", screenId); + + GBApplication.deviceService(device).onReadConfiguration("screenId:" + screenId); + return; + default: + LOG.error("Unknown action {}", action); + return; + } + + reload(); + } + }; + + private void setDevice(final GBDevice device) { + final Bundle args = getArguments() != null ? getArguments() : new Bundle(); + args.putParcelable("device", device); + setArguments(args); + } + + private void setScreenId(final int screenId) { + final Bundle args = getArguments() != null ? getArguments() : new Bundle(); + args.putInt("screenId", screenId); + setArguments(args); + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + final Bundle arguments = getArguments(); + if (arguments == null) { + return; + } + this.device = arguments.getParcelable(GBDevice.EXTRA_DEVICE); + if (device == null) { + return; + } + this.screenId = arguments.getInt(EXTRA_SCREEN_ID, ROOT_SCREEN_ID); + if (screenId == 0) { + return; + } + + LOG.info("Opened realtime preferences screen for {}", screenId); + + getPreferenceManager().setSharedPreferencesName("garmin_rt_" + device.getAddress()); + setPreferencesFromResource(R.xml.garmin_realtime_settings, rootKey); + + final IntentFilter filter = new IntentFilter(); + filter.addAction(ACTION_SCREEN_DEFINITION); + filter.addAction(ACTION_SCREEN_STATE); + filter.addAction(ACTION_CHANGE); + LocalBroadcastManager.getInstance(requireContext()).registerReceiver(mReceiver, filter); + + GBApplication.deviceService(device).onReadConfiguration("screenId:" + screenId); + } + + @Override + public void onResume() { + super.onResume(); + reload(); + } + + @Override + public void onDestroyView() { + LocalBroadcastManager.getInstance(requireContext()).unregisterReceiver(mReceiver); + super.onDestroyView(); + } + + static GarminRealtimeSettingsFragment newInstance(final GBDevice device, final int screenId) { + final GarminRealtimeSettingsFragment fragment = new GarminRealtimeSettingsFragment(); + fragment.setDevice(device); + fragment.setScreenId(screenId); + + return fragment; + } + + void refreshFromDevice() { + screenDefinition = null; + screenState = null; + reload(); + GBApplication.deviceService(device).onReadConfiguration("screenId:" + screenId); + } + + void reload() { + final boolean debug = GBApplication.getDevicePrefs(device.getAddress()).getBoolean(PREF_DEBUG, BuildConfig.DEBUG); + + final FragmentActivity activity = getActivity(); + if (activity == null) { + LOG.error("Activity is null"); + return; + } + + final PreferenceScreen prefScreen = findPreference(GarminPreferences.PREF_GARMIN_REALTIME_SETTINGS); + if (prefScreen == null) { + LOG.error("Preference screen for {} is null", GarminPreferences.PREF_GARMIN_REALTIME_SETTINGS); + activity.finish(); + return; + } + + if (screenDefinition == null || screenState == null) { + ((GarminRealtimeSettingsActivity) activity).setActionBarTitle(activity.getString(R.string.loading)); + + // Disable all existing preferences while loading + for (int i = 0; i < prefScreen.getPreferenceCount(); i++) { + prefScreen.getPreference(i).setEnabled(false); + } + + return; + } + + prefScreen.removeAll(); + + if (debug) { + final Preference pref = new PreferenceCategory(activity); + pref.setIconSpaceReserved(false); + pref.setTitle("Screen ID: " + screenId); + pref.setPersistent(false); + pref.setKey("rt_pref_header_" + screenId); + prefScreen.addPreference(pref); + } + + // Update the screen title, if any + if (screenDefinition.hasTitle()) { + final String title = screenDefinition.getTitle().getText(); + + ((GarminRealtimeSettingsActivity) activity).setActionBarTitle(title); + } + + final Map stateById = new HashMap<>(); + for (final GdiSettingsService.EntryState state : screenState.getStateList()) { + stateById.put(state.getId(), state); + } + + for (final GdiSettingsService.ScreenEntry entry : screenDefinition.getEntryList()) { + final GdiSettingsService.EntryState state = stateById.get(entry.getId()); + + final Preference pref; + boolean supported = true; + + if (entry.hasTarget()) { + switch (entry.getTarget().getType()) { + case 0: // subscreen + case 9: // subscreen with options for a specific preference + pref = new Preference(activity); + pref.setOnPreferenceClickListener(preference -> { + final Intent newIntent = new Intent(requireContext(), GarminRealtimeSettingsActivity.class); + newIntent.putExtra(GBDevice.EXTRA_DEVICE, device); + newIntent.putExtra(GarminRealtimeSettingsActivity.EXTRA_SCREEN_ID, entry.getTarget().getSubscreen()); + activity.startActivityForResult(newIntent, 0); + return true; + }); + break; + case 1: // list preference + pref = new ListPreference(activity); + final CharSequence[] entries = new String[entry.getTarget().getOptions().getOptionList().size()]; + int optionIndex = 0; + for (final GdiSettingsService.TargetOptionEntry option : entry.getTarget().getOptions().getOptionList()) { + entries[optionIndex++] = option.getTitle().getText(); + } + final ListPreference listPreference = (ListPreference) pref; + listPreference.setEntries(entries); + listPreference.setEntryValues(entries); + listPreference.setValue(entries[Objects.requireNonNull(state).getSummary().getValueList().getIndex()].toString()); + listPreference.setOnPreferenceChangeListener((preference, newValue) -> { + int newValueIdx = -1; + for (int i = 0; i < entries.length; i++) { + if (entries[i].equals(newValue.toString())) { + newValueIdx = i; + break; + } + } + if (newValueIdx < 0) { + LOG.error("Failed to find index for {}", newValue); + return false; + } + + pref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(entry.getId()) + .setOption(GdiSettingsService.ChangeRequest.Option.newBuilder() + .setIndex(newValueIdx) + ) + ); + return true; + }); + break; + + case 3: // time + pref = new XTimePreference(activity, null); + ((XTimePreference) pref).setValue( + Objects.requireNonNull(state).getSummary().getValueTime().getSeconds() / 3600, + (Objects.requireNonNull(state).getSummary().getValueTime().getSeconds() % 3600) / 60 + ); + if (state.getSummary().getValueTime().hasTimeFormat()) { + final int timeFormat = state.getSummary().getValueTime().getTimeFormat(); + switch (timeFormat) { + case 0: // 12h + ((XTimePreference) pref).setFormat(XTimePreference.Format.FORMAT_12H); + break; + case 1: // 24h + ((XTimePreference) pref).setFormat(XTimePreference.Format.FORMAT_24H); + break; + } + } + pref.setSummary(state.getSummary().getValueDate().getSubtitle().getText()); + pref.setOnPreferenceChangeListener((preference, newValue) -> { + final String[] pieces = newValue.toString().split(":"); + + final int hour = Integer.parseInt(pieces[0]); + final int minute = Integer.parseInt(pieces[1]); + + pref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(entry.getId()) + .setTime(GdiSettingsService.ChangeRequest.Time.newBuilder() + .setSeconds(hour * 3600 + minute * 60) + ) + ); + return true; + }); + break; + case 5: // number picker + pref = new EditTextPreference(activity); + ((EditTextPreference) pref).setText(String.valueOf(state.getSummary().getValueNumber().getValue())); + ((EditTextPreference) pref).setSummary(state.getSummary().getValueNumber().getSubtitle().getText()); + + ((EditTextPreference) pref).setOnBindEditTextListener(p -> { + p.setInputType(InputType.TYPE_CLASS_NUMBER); + int minValue = Integer.MIN_VALUE; + int maxValue = Integer.MAX_VALUE; + if (entry.getTarget().getNumberPicker().hasMin()) { + minValue = entry.getTarget().getNumberPicker().getMin(); + } + if (entry.getTarget().getNumberPicker().hasMax()) { + maxValue = entry.getTarget().getNumberPicker().getMax(); + } + p.addTextChangedListener(new MinMaxTextWatcher(p, minValue, maxValue)); + p.setSelection(p.getText().length()); + }); + ((EditTextPreference) pref).setOnPreferenceChangeListener((preference, newValue) -> { + final int newValueInt = Integer.parseInt(newValue.toString()); + + pref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(entry.getId()) + .setNumber(GdiSettingsService.ChangeRequest.Number.newBuilder() + .setValue(newValueInt) + ) + ); + return true; + }); + break; + case 6: // activity + switch (entry.getTarget().getActivity()) { + case 2: // garmin pay + case 7: // text responses + case 8: // music providers + pref = new Preference(activity); + pref.setVisible(debug); + pref.setEnabled(false); + break; + default: + supported = false; + pref = new Preference(activity); + break; + } + + break; + case 7: // hidden? + pref = new Preference(activity); + pref.setVisible(debug); + pref.setEnabled(false); + break; + case 10: // date picker + pref = new XDatePreference(activity, null); + ((XDatePreference) pref).setValue( + Objects.requireNonNull(state).getSummary().getValueDate().getCurrentDate().getYear(), + Objects.requireNonNull(state).getSummary().getValueDate().getCurrentDate().getMonth(), + Objects.requireNonNull(state).getSummary().getValueDate().getCurrentDate().getDay() + ); + if (state.getSummary().getValueDate().hasMinDate()) { + final Calendar calendar = GregorianCalendar.getInstance(); + calendar.set( + state.getSummary().getValueDate().getMinDate().getYear(), + state.getSummary().getValueDate().getMinDate().getMonth() - 1, + state.getSummary().getValueDate().getMinDate().getDay() + ); + ((XDatePreference) pref).setMinDate(calendar.getTimeInMillis()); + } + if (state.getSummary().getValueDate().hasMaxDate()) { + final Calendar calendar = GregorianCalendar.getInstance(); + calendar.set( + state.getSummary().getValueDate().getMaxDate().getYear(), + state.getSummary().getValueDate().getMaxDate().getMonth() - 1, + state.getSummary().getValueDate().getMaxDate().getDay() + ); + ((XDatePreference) pref).setMaxDate(calendar.getTimeInMillis()); + } + pref.setSummary(state.getSummary().getValueDate().getSubtitle().getText()); + pref.setOnPreferenceChangeListener((preference, newValue) -> { + final String[] pieces = newValue.toString().split("-"); + + final int year = Integer.parseInt(pieces[0]); + final int month = Integer.parseInt(pieces[1]); + final int day = Integer.parseInt(pieces[2]); + + pref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(entry.getId()) + .setNewDate(GdiSettingsService.ChangeRequest.NewDate.newBuilder() + .setValue(GdiSettingsService.Date.newBuilder() + .setYear(year).setMonth(month).setDay(day) + ) + ) + ); + + return true; + }); + break; + case 12: // Connect IQ Store + pref = new Preference(activity); + pref.setVisible(debug); + pref.setEnabled(false); + break; + case 13: // height + pref = new EditTextPreference(activity); + ((EditTextPreference) pref).setText(String.valueOf(state.getSummary().getValueHeight().getValue())); + ((EditTextPreference) pref).setSummary(state.getSummary().getValueHeight().getSubtitle().getText()); + if (state.getSummary().getValueHeight().getUnit() == 0) { + ((EditTextPreference) pref).setDialogTitle(R.string.activity_prefs_height_cm); + ((EditTextPreference) pref).setTitle(R.string.activity_prefs_height_cm); + } else { + ((EditTextPreference) pref).setDialogTitle(R.string.activity_prefs_height_inches); + ((EditTextPreference) pref).setTitle(R.string.activity_prefs_height_inches); + } + ((EditTextPreference) pref).setOnBindEditTextListener(p -> { + p.setInputType(InputType.TYPE_CLASS_NUMBER); + p.addTextChangedListener(new MinMaxTextWatcher(p, 0, 300)); + p.setSelection(p.getText().length()); + }); + ((EditTextPreference) pref).setOnPreferenceChangeListener((preference, newValue) -> { + final int newValueInt = Integer.parseInt(newValue.toString()); + + pref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(entry.getId()) + .setHeight(GdiSettingsService.ChangeRequest.Height.newBuilder() + .setValue(newValueInt) + .setUnit(state.getSummary().getValueHeight().getUnit()) + ) + ); + return true; + }); + break; + default: + supported = false; + pref = new Preference(activity); + } + } else { // No target + switch (entry.getType()) { + case 0: // notice + pref = new Preference(activity); + pref.setSummary(entry.getTitle().getText()); + break; + case 1: // category + pref = new PreferenceCategory(activity); + break; + case 2: // space + pref = new PreferenceCategory(activity); + pref.setTitle(""); + break; + case 3: // switch + pref = new SwitchPreferenceCompat(activity); + pref.setLayoutResource(R.layout.preference_checkbox); + ((SwitchPreferenceCompat) pref).setChecked(Objects.requireNonNull(state).getSwitch().getEnabled()); + ((SwitchPreferenceCompat) pref).setSummary(Objects.requireNonNull(state).getSwitch().getTitle().getText()); + pref.setOnPreferenceChangeListener((preference, newValue) -> { + pref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(entry.getId()) + .setSwitch(GdiSettingsService.ChangeRequest.Switch.newBuilder() + .setValue((Boolean) newValue) + ) + ); + return true; + }); + + break; + case 4: // single line + optional icon + case 5: // double line + pref = new Preference(activity); + break; + case 18: // single line with action (eg. glances) + case 7: // single line, normally in list for selection? + pref = new Preference(activity); + pref.setOnPreferenceClickListener(preference -> { + pref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(entry.getId()) + ); + return true; + }); + break; + case 8: // device + status? + case 9: // finish setup + case 10: // find my device + case 11: // preferred activity tracker + case 13: // help & info + pref = new Preference(activity); + pref.setVisible(debug); + pref.setEnabled(false); + break; + case 15: // sortable + delete + // Add all sortable items and then continue + final String moveUpStr = activity.getString(R.string.widget_move_up); + final String moveDownStr = activity.getString(R.string.widget_move_down); + final String deleteStr = activity.getString(R.string.appmananger_app_delete); + + for (int i = 0; i < entry.getSortOptions().getEntriesCount(); i++) { + final GdiSettingsService.SortEntry sortEntry = entry.getSortOptions().getEntries(i); + final List sortableOptions = new ArrayList<>(3); + if (i > 0) { + sortableOptions.add(moveUpStr); + } + if (i < entry.getSortOptions().getEntriesCount() - 1) { + sortableOptions.add(moveDownStr); + } + sortableOptions.add(deleteStr); + final ArrayAdapter sortOptionsAdapter = new ArrayAdapter<>(activity, android.R.layout.simple_list_item_1, sortableOptions); + final int iFinal = i; + final Preference sortPref = new Preference(activity); + sortPref.setTitle(sortEntry.getTitle().getText()); + sortPref.setPersistent(false); + sortPref.setIconSpaceReserved(false); + sortPref.setKey("rt_pref_" + screenId + "_" + entry.getId() + "__" + sortEntry.getId()); + sortPref.setOnPreferenceClickListener(preference -> { + new MaterialAlertDialogBuilder(activity) + .setTitle(sortPref.getTitle()) + .setAdapter(sortOptionsAdapter, (dialogInterface, j) -> { + final String option = sortableOptions.get(j); + int moveOffset = 0; + if (option.equals(moveUpStr)) { + moveOffset = -1; + } else if (option.equals(moveDownStr)) { + moveOffset = 1; + } + + if (moveOffset != 0) { + sortPref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(sortEntry.getId()) + .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder() + .setIndex(iFinal + moveOffset) + ) + ); + return; + } + + if (option.equals(deleteStr)) { + sortPref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(sortEntry.getId()) + .setPosition(GdiSettingsService.ChangeRequest.Position.newBuilder() + .setDelete(true) + ) + ); + } + }).setNegativeButton(android.R.string.cancel, null) + .create().show(); + return true; + }); + prefScreen.addPreference(sortPref); + } + + continue; // We already added all options above, continue + case 16: // text + pref = new EditTextPreference(activity); + + ((EditTextPreference) pref).setOnBindEditTextListener(p -> { + int maxValue = Integer.MAX_VALUE; + if (entry.getTextOption().hasLimits() && entry.getTextOption().getLimits().hasMaxLength()) { + p.setFilters(new InputFilter[]{new InputFilter.LengthFilter(entry.getTextOption().getLimits().getMaxLength())}); + } + p.setSelection(p.getText().length()); + }); + ((EditTextPreference) pref).setOnPreferenceChangeListener((preference, newValue) -> { + if (StringUtils.isNullOrEmpty(newValue.toString())) { + return true; + } + pref.setEnabled(false); + sendChangeRequest( + GdiSettingsService.ChangeRequest.newBuilder() + .setScreenId(screenId) + .setEntryId(entry.getId()) + .setText(GdiSettingsService.ChangeRequest.Text.newBuilder() + .setValue(newValue.toString()) + ) + ); + return true; + }); + break; + default: + supported = false; + pref = new Preference(activity); + } + } + + if (StringUtils.isNullOrEmpty(pref.getTitle()) && entry.getType() != 0 && entry.getType() != 2) { + pref.setTitle(!StringUtils.isEmpty(entry.getTitle().getText()) ? entry.getTitle().getText() : activity.getString(R.string.unknown)); + + if (pref instanceof DialogPreference) { + ((DialogPreference) pref).setDialogTitle(pref.getTitle()); + } + } + + final int icon = getIcon(entry); + if (icon != 0) { + pref.setIcon(icon); + } else { + pref.setIconSpaceReserved(false); + } + + if (state != null && !StringUtils.isEmpty(state.getSummary().getTitle().getText())) { + pref.setSummary(state.getSummary().getTitle().getText()); + } + + if (state != null && state.hasState()) { + switch (state.getState()) { + case 1: + pref.setVisible(false); + break; + case 2: + pref.setEnabled(false); + break; + default: + LOG.warn("Unknown state value {}", state.getState()); + } + } + + if (!supported) { + pref.setEnabled(false); + + if (StringUtils.isNullOrEmpty(pref.getSummary())) { + pref.setSummary(R.string.unsupported); + } else { + pref.setSummary(activity.getString(R.string.menuitem_unsupported, pref.getSummary())); + } + } + + if (debug) { + final StringBuilder sb = new StringBuilder(); + + if (pref.getSummary() != null && pref.getSummary().length() != 0) { + sb.append(pref.getSummary()).append("\n"); + } + + sb.append("id=").append(entry.getId()); + sb.append(", type=").append(entry.getType()); + + if (entry.hasTarget()) { + sb.append(", targetType=").append(entry.getTarget().getType()); + } + + pref.setSummary(sb.toString()); + } + + pref.setPersistent(false); + pref.setKey("rt_pref_" + screenId + "_" + entry.getId()); + prefScreen.addPreference(pref); + } + + // If no preferences after the last visible preference category are visible, hide it + for (int i = prefScreen.getPreferenceCount() - 1; i >= 0; i--) { + final Preference lastVisiblePreference = prefScreen.getPreference(i); + if (lastVisiblePreference.isVisible()) { + break; + } + if (lastVisiblePreference instanceof PreferenceCategory) { + lastVisiblePreference.setVisible(false); + break; + } + } + } + + @DrawableRes + private int getIcon(final GdiSettingsService.ScreenEntry entry) { + if (entry.hasIcon()) { + switch (entry.getIcon()) { + // + // Main menu + case 20: // Garmin Pay + return 0; + case 21: // Text Responses + return R.drawable.ic_reply; + case 4: // Clocks + return R.drawable.ic_access_time; + case 2: // Glances + return R.drawable.ic_widgets; + case 3: // Controls + return R.drawable.ic_menu; + case 1: // Activities / Apps, have the same icon + return R.drawable.ic_activity_unknown_small; + case 39: // Shortcut + return R.drawable.ic_shortcut; + case 27: // Notifications & Alerts + return R.drawable.ic_notifications; + case 46: // Watch Sensors + return R.drawable.ic_sensor_calibration; + case 47: // Accessories + return R.drawable.ic_bluetooth_searching; + case 7: // Music + return R.drawable.ic_music_note; + case 13: // Audio Prompts + return R.drawable.ic_volume_up; + case 14: // User Profile + return R.drawable.ic_person; + case 15: // Safety & Tracking + return R.drawable.ic_health; + case 16: // Activity Tracking + return R.drawable.ic_activity_unknown_small; + case 19: // System + return R.drawable.ic_settings; + + // + // Sortable screens (glances, apps, etc) + case 33: + return R.drawable.ic_add_gray; + } + } + + return 0; + } + + void toggleDebug() { + final Prefs prefs = GBApplication.getDevicePrefs(device.getAddress()); + prefs.getPreferences().edit() + .putBoolean(PREF_DEBUG, !prefs.getBoolean(PREF_DEBUG, BuildConfig.DEBUG)) + .apply(); + + reload(); + } + + void shareDebug() { + final Intent intent = new Intent(android.content.Intent.ACTION_SEND); + intent.setType("text/plain"); + + final StringBuilder sb = new StringBuilder(); + + sb.append("screenId: ").append(screenId); + sb.append("\n"); + + sb.append("settingsScreen: "); + if (screenDefinition != null) { + sb.append(GB.hexdump(screenDefinition.toByteArray())); + } else { + sb.append("null"); + } + sb.append("\n"); + + sb.append("settingsState: "); + if (screenState != null) { + sb.append(GB.hexdump(screenState.toByteArray())); + } else { + sb.append("null"); + } + sb.append("\n"); + + intent.putExtra(android.content.Intent.EXTRA_SUBJECT, "Garmin Settings Screen " + screenId); + intent.putExtra(android.content.Intent.EXTRA_TEXT, sb.toString()); + + try { + startActivity(Intent.createChooser(intent, "Share debug info")); + } catch (final ActivityNotFoundException e) { + Toast.makeText(requireContext(), "Failed to share text", Toast.LENGTH_LONG).show(); + } + } + + private void sendChangeRequest(final GdiSettingsService.ChangeRequest.Builder changeRequest) { + screenDefinition = null; + screenState = null; + final GdiSmartProto.Smart smart = GdiSmartProto.Smart.newBuilder() + .setSettingsService(GdiSettingsService.SettingsService.newBuilder() + .setChangeRequest(changeRequest) + ).build(); + GBApplication.deviceService(device).onSendConfiguration("protobuf:" + GB.hexdump(smart.toByteArray())); + } + + private static class MinMaxTextWatcher implements TextWatcher { + private final EditText editText; + private final int min; + private final int max; + + private MinMaxTextWatcher(final EditText editText, final int min, final int max) { + this.editText = editText; + this.min = min; + this.max = max; + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) { + } + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { + } + + @Override + public void afterTextChanged(final Editable editable) { + try { + final int val = Integer.parseInt(editable.toString()); + editText.getRootView().findViewById(android.R.id.button1) + .setEnabled(val >= min && val <= max); + if (val < min) { + editText.setError(editText.getContext().getString(R.string.min_val, min)); + } else if (val > max) { + editText.setError(editText.getContext().getString(R.string.max_val, max)); + } else { + editText.setError(null); + } + } catch (final NumberFormatException e) { + editText.getRootView().findViewById(android.R.id.button1) + .setEnabled(false); + } + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java index a0b69005e..78e426b2f 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/garmin/GarminSettingsCustomizer.java @@ -34,6 +34,7 @@ import nodomain.freeyourgadget.gadgetbridge.R; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer; import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsHandler; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.agps.GarminAgpsStatus; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.Prefs; @@ -49,6 +50,16 @@ public class GarminSettingsCustomizer implements DeviceSpecificSettingsCustomize @Override public void customizeSettings(final DeviceSpecificSettingsHandler handler, final Prefs prefs) { + final Preference realtimeSettings = handler.findPreference(GarminPreferences.PREF_GARMIN_REALTIME_SETTINGS); + if (realtimeSettings != null) { + realtimeSettings.setOnPreferenceClickListener(preference -> { + final Intent intent = new Intent(handler.getContext(), GarminRealtimeSettingsActivity.class); + intent.putExtra(GBDevice.EXTRA_DEVICE, handler.getDevice()); + handler.getContext().startActivity(intent); + return true; + }); + } + final PreferenceCategory prefAgpsHeader = handler.findPreference(DeviceSettingsPreferenceConst.PREF_HEADER_AGPS); if (prefAgpsHeader != null) { final List urls = prefs.getList(GarminPreferences.PREF_AGPS_KNOWN_URLS, Collections.emptyList(), "\n"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/GarminCapability.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/GarminCapability.java index 081cdd9ca..25663c63b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/GarminCapability.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/vivomovehr/GarminCapability.java @@ -103,25 +103,50 @@ public enum GarminCapability { GOLF_9_PLUS_9, ANTI_THEFT_ALARM, INREACH, - EVENT_SHARING; + EVENT_SHARING, + UNK_82, + UNK_83, + UNK_84, + UNK_85, + UNK_86, + UNK_87, + UNK_88, + UNK_89, + UNK_90, + UNK_91, + REALTIME_SETTINGS, + UNK_93, + UNK_94, + UNK_95, + UNK_96, + UNK_97, + UNK_98, + UNK_99, + UNK_100, + UNK_101, + UNK_102, + UNK_103, + ; public static final Set ALL_CAPABILITIES = new HashSet<>(values().length); private static final Map FROM_ORDINAL = new HashMap<>(values().length); static { - for (GarminCapability cap : values()) { + for (final GarminCapability cap : values()) { FROM_ORDINAL.put(cap.ordinal(), cap); ALL_CAPABILITIES.add(cap); } } - public static Set setFromBinary(byte[] bytes) { + public static Set setFromBinary(final byte[] bytes) { final Set result = new HashSet<>(GarminCapability.values().length); int current = 0; for (int b : bytes) { - for (int curr = 1; curr < 0x100; curr <<= 1) { - if ((b & curr) != 0) { - result.add(FROM_ORDINAL.get(current)); + for (int i = 0; i < 8; i++) { + if ((b & (1 << i)) != 0) { + if (FROM_ORDINAL.containsKey(current)) { + result.add(FROM_ORDINAL.get(current)); + } } ++current; } @@ -129,7 +154,7 @@ public enum GarminCapability { return result; } - public static byte[] setToBinary(Set capabilities) { + public static byte[] setToBinary(final Set capabilities) { final GarminCapability[] values = values(); final byte[] result = new byte[(values.length + 7) / 8]; int bytePos = 0; @@ -147,9 +172,9 @@ public enum GarminCapability { return result; } - public static String setToString(Set capabilities) { + public static String setToString(final Set capabilities) { final StringBuilder result = new StringBuilder(); - for (GarminCapability cap : capabilities) { + for (final GarminCapability cap : capabilities) { if (result.length() > 0) result.append(", "); result.append(cap.name()); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java index 21d669eca..3572518f6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/GarminSupport.java @@ -25,6 +25,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Queue; +import java.util.Set; import java.util.Timer; import java.util.TimerTask; import java.util.UUID; @@ -60,6 +61,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateA import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v1.CommunicatorV1; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2.CommunicatorV2; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.CapabilitiesDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.FileDownloadedDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.NotificationSubscriptionDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.SupportedFileTypesDeviceEvent; @@ -84,7 +86,6 @@ import nodomain.freeyourgadget.gadgetbridge.util.Prefs; 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; import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS; @@ -251,6 +252,24 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni if (weather != null) { sendWeatherConditions(weather); } + } else if (deviceEvent instanceof CapabilitiesDeviceEvent) { + final Set capabilities = ((CapabilitiesDeviceEvent) deviceEvent).capabilities; + if (capabilities.contains(GarminCapability.REALTIME_SETTINGS)) { + final String language = Locale.getDefault().getLanguage(); + final String country = Locale.getDefault().getCountry(); + final String localeString = language + "_" + country.toUpperCase(); + final ProtobufMessage realtimeSettingsInit = protocolBufferHandler.prepareProtobufRequest(GdiSmartProto.Smart.newBuilder() + .setSettingsService( + GdiSettingsService.SettingsService.newBuilder() + .setInitRequest( + GdiSettingsService.InitRequest.newBuilder() + .setLanguage(localeString.length() == 5 ? localeString : "en_US") + .setRegion("us") // FIXME choose region + ) + ) + .build()); + sendOutgoingMessage("init realtime settings", realtimeSettingsInit); + } } else if (deviceEvent instanceof NotificationSubscriptionDeviceEvent) { final boolean enable = ((NotificationSubscriptionDeviceEvent) deviceEvent).enable; notificationsHandler.setEnabled(enable); @@ -327,13 +346,8 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni communicator.sendMessage(taskName, message.getOutgoingMessage()); } - private boolean supports(final GarminCapability capability) { - return getDevicePrefs().getStringSet(GarminPreferences.PREF_GARMIN_CAPABILITIES, Collections.emptySet()) - .contains(capability.name()); - } - private void sendWeatherConditions(WeatherSpec weather) { - if (!supports(GarminCapability.WEATHER_CONDITIONS)) { + if (!getCoordinator().supports(getDevice(), GarminCapability.WEATHER_CONDITIONS)) { // Device does not support sending weather as fit return; } @@ -444,39 +458,34 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni sendOutgoingMessage("request supported file types", new SupportedFileTypesMessage()); - sendOutgoingMessage("toggle default reply suffix", toggleDefaultReplySuffix(getDevicePrefs().getBoolean(PREF_GARMIN_DEFAULT_REPLY_SUFFIX, true))); - if (mFirstConnect) { sendOutgoingMessage("set sync complete", new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_COMPLETE, 0)); this.mFirstConnect = false; } } - 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) { + public void onSendConfiguration(final String config) { + if (config.startsWith("protobuf:")) { + try { + final GdiSmartProto.Smart smart = GdiSmartProto.Smart.parseFrom(GB.hexStringToByteArray(config.replaceFirst("protobuf:", ""))); + sendOutgoingMessage("send config", protocolBufferHandler.prepareProtobufRequest(smart)); + } catch (final Exception e) { + LOG.error("Failed to send {} as protobuf", config, e); + } + + return; + } + switch (config) { - case PREF_GARMIN_DEFAULT_REPLY_SUFFIX: - sendOutgoingMessage("toggle default reply suffix", toggleDefaultReplySuffix(getDevicePrefs().getBoolean(PREF_GARMIN_DEFAULT_REPLY_SUFFIX, true))); - break; case PREF_SEND_APP_NOTIFICATIONS: NotificationSubscriptionDeviceEvent notificationSubscriptionDeviceEvent = new NotificationSubscriptionDeviceEvent(); notificationSubscriptionDeviceEvent.enable = true; // actual status is fetched from preferences evaluateGBDeviceEvent(notificationSubscriptionDeviceEvent); - break; + return; } + + } private void processDownloadQueue() { @@ -754,6 +763,41 @@ public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommuni return super.connect(); } + @Override + public void onReadConfiguration(final String config) { + if (config.startsWith("screenId:")) { + final int screenId = Integer.parseInt(config.replaceFirst("screenId:", "")); + + LOG.debug("Requesting screen {}", screenId); + + final String language = Locale.getDefault().getLanguage(); + final String country = Locale.getDefault().getCountry(); + final String localeString = language + "_" + country.toUpperCase(); + + sendOutgoingMessage("get settings screen " + screenId, protocolBufferHandler.prepareProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setSettingsService(GdiSettingsService.SettingsService.newBuilder() + .setDefinitionRequest( + GdiSettingsService.ScreenDefinitionRequest.newBuilder() + .setScreenId(screenId) + .setUnk2(0) + .setLanguage(localeString.length() == 5 ? localeString : "en_US") + ) + ).build() + )); + + sendOutgoingMessage("get settings state " + screenId, protocolBufferHandler.prepareProtobufRequest( + GdiSmartProto.Smart.newBuilder() + .setSettingsService(GdiSettingsService.SettingsService.newBuilder() + .setStateRequest( + GdiSettingsService.ScreenStateRequest.newBuilder() + .setScreenId(screenId) + ) + ).build() + )); + } + } + @Override public void onTestNewFunction() { parseAllFitFilesFromStorage(); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java index c24d432f3..10f60d8e3 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/ProtocolBufferHandler.java @@ -1,7 +1,10 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin; +import android.content.Intent; import android.location.Location; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + import com.google.protobuf.InvalidProtocolBufferException; import org.apache.commons.lang3.ArrayUtils; @@ -19,6 +22,7 @@ import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSett import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences; +import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminRealtimeSettingsFragment; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProviderType; import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService; import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; @@ -28,6 +32,7 @@ import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiDataTransferService; import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiDeviceStatus; import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiFindMyWatch; import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiHttpService; +import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSettingsService; import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmartProto; import nodomain.freeyourgadget.gadgetbridge.proto.garmin.GdiSmsNotification; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http.DataTransferHandler; @@ -119,9 +124,29 @@ public class ProtocolBufferHandler implements MessageHandler { processed = true; processProtobufFindMyWatchResponse(smart.getFindMyWatchService()); } - if (!processed) { + if (smart.hasSettingsService()) { + processed = true; + processProtobufSettingsService(smart.getSettingsService()); + } + if (processed) { + message.setStatusMessage(new ProtobufStatusMessage( + message.getMessageType(), + GFDIMessage.Status.ACK, + message.getRequestId(), + message.getDataOffset(), + ProtobufStatusMessage.ProtobufChunkStatus.KEPT, + ProtobufStatusMessage.ProtobufStatusCode.NO_ERROR + )); + } else { 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)); + message.setStatusMessage(new ProtobufStatusMessage( + message.getMessageType(), + GFDIMessage.Status.ACK, + message.getRequestId(), + message.getDataOffset(), + ProtobufStatusMessage.ProtobufChunkStatus.DISCARDED, + ProtobufStatusMessage.ProtobufStatusCode.UNKNOWN_REQUEST_ID + )); } } return null; @@ -387,6 +412,33 @@ public class ProtocolBufferHandler implements MessageHandler { LOG.warn("Unknown FindMyWatchService response: {}", findMyWatchService); } + private boolean processProtobufSettingsService(final GdiSettingsService.SettingsService settingsService) { + boolean processed = false; + + if (settingsService.hasDefinitionResponse()) { + processed = true; + final Intent intent = new Intent(GarminRealtimeSettingsFragment.ACTION_SCREEN_DEFINITION); + intent.putExtra(GarminRealtimeSettingsFragment.EXTRA_PROTOBUF, settingsService.getDefinitionResponse().getDefinition().toByteArray()); + LocalBroadcastManager.getInstance(deviceSupport.getContext()).sendBroadcast(intent); + } + + if (settingsService.hasStateResponse()) { + processed = true; + final Intent intent = new Intent(GarminRealtimeSettingsFragment.ACTION_SCREEN_STATE); + intent.putExtra(GarminRealtimeSettingsFragment.EXTRA_PROTOBUF, settingsService.getStateResponse().getState().toByteArray()); + LocalBroadcastManager.getInstance(deviceSupport.getContext()).sendBroadcast(intent); + } + + if (settingsService.hasChangeResponse()) { + processed = true; + final Intent intent = new Intent(GarminRealtimeSettingsFragment.ACTION_CHANGE); + intent.putExtra(GarminRealtimeSettingsFragment.EXTRA_PROTOBUF, settingsService.getChangeResponse().toByteArray()); + LocalBroadcastManager.getInstance(deviceSupport.getContext()).sendBroadcast(intent); + } + + return processed; + } + public ProtobufMessage prepareProtobufRequest(GdiSmartProto.Smart protobufPayload) { if (null == protobufPayload) return null; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/CapabilitiesDeviceEvent.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/CapabilitiesDeviceEvent.java new file mode 100644 index 000000000..538252e7f --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/deviceevents/CapabilitiesDeviceEvent.java @@ -0,0 +1,14 @@ +package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents; + +import java.util.Set; + +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; +import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; + +public class CapabilitiesDeviceEvent extends GBDeviceEvent { + public Set capabilities; + + public CapabilitiesDeviceEvent(final Set capabilities) { + this.capabilities = capabilities; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java index 415fb19ca..a6a77b685 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/messages/ConfigurationMessage.java @@ -1,5 +1,6 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -9,6 +10,7 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences; import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability; +import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.CapabilitiesDeviceEvent; public class ConfigurationMessage extends GFDIMessage { @@ -29,9 +31,8 @@ public class ConfigurationMessage extends GFDIMessage { } public static ConfigurationMessage parseIncoming(MessageReader reader, GarminMessage garminMessage) { - final int endOfPayload = reader.readByte(); - ConfigurationMessage configurationMessage = new ConfigurationMessage(garminMessage, reader.readBytes(endOfPayload - reader.getPosition())); - return configurationMessage; + final int numBytes = reader.readByte(); + return new ConfigurationMessage(garminMessage, reader.readBytes(numBytes)); } @Override @@ -40,7 +41,8 @@ public class ConfigurationMessage extends GFDIMessage { for (final GarminCapability capability : capabilities) { capabilitiesPref.add(capability.name()); } - return Collections.singletonList( + return Arrays.asList( + new CapabilitiesDeviceEvent(capabilities), new GBDeviceEventUpdatePreferences(GarminPreferences.PREF_GARMIN_CAPABILITIES, capabilitiesPref) ); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Optional.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Optional.java index 1d1bf4e34..89438d193 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Optional.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/Optional.java @@ -52,6 +52,12 @@ public final class Optional { return value != null ? value : other; } + public void ifPresent(final Consumer consumer) { + if (value != null) { + consumer.consume(value); + } + } + public static Optional empty() { return new Optional<>(); } @@ -63,4 +69,8 @@ public final class Optional { public static Optional ofNullable(final T value) { return value == null ? empty() : of(value); } + + public static interface Consumer { + void consume(final T value); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java index 9e7296ce4..994f0e951 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/StringUtils.java @@ -128,11 +128,11 @@ public class StringUtils { return ""; } - public static boolean isNullOrEmpty(String string){ - return string == null || string.isEmpty(); + public static boolean isNullOrEmpty(CharSequence string){ + return string == null || string.length() == 0; } - public static boolean isEmpty(String string) { + public static boolean isEmpty(CharSequence string) { return string != null && string.length() == 0; } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java new file mode 100644 index 000000000..87217673d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreference.java @@ -0,0 +1,114 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; + +import androidx.preference.DialogPreference; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public class XDatePreference extends DialogPreference { + private int year; + private int month; + private int day; + private long minDate; // TODO actually read minDate + private long maxDate; // TODO actually read maxDate + + public XDatePreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected Object onGetDefaultValue(final TypedArray a, final int index) { + return a.getString(index); + } + + @Override + protected void onSetInitialValue(final Object defaultValue) { + final String persistedString = getPersistedString((String) defaultValue); + + final String dateStr; + + if (StringUtils.isNullOrEmpty(persistedString)) { + final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.ROOT); + dateStr = getPersistedString(sdf.format(new Date())); + } else { + dateStr = persistedString; + } + + final String[] pieces = dateStr.split("-"); + + year = Integer.parseInt(pieces[0]); + month = Integer.parseInt(pieces[1]); + day = Integer.parseInt(pieces[2]); + + updateSummary(); + } + + public void setMinDate(final long minDate) { + this.minDate = minDate; + } + + public void setMaxDate(final long maxDate) { + this.maxDate = maxDate; + } + + public int getYear() { + return year; + } + + public int getMonth() { + return month; + } + + public int getDay() { + return day; + } + + public long getMinDate() { + return minDate; + } + + public long getMaxDate() { + return maxDate; + } + + String getPrefValue() { + return String.format(Locale.ROOT, "%04d-%02d-%02d", year, month, day); + } + + public void setValue(final int year, final int month, final int day) { + this.year = year; + this.month = month; + this.day = day; + + persistStringValue(getPrefValue()); + } + + void updateSummary() { + setSummary(String.format(Locale.ROOT, "%04d-%02d-%02d", year, month, day)); + } + + void persistStringValue(final String value) { + persistString(value); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreferenceFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreferenceFragment.java new file mode 100644 index 000000000..74d6ae246 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XDatePreferenceFragment.java @@ -0,0 +1,80 @@ +/* Copyright (C) 2024 José Rebelo + + This file is part of Gadgetbridge. + + Gadgetbridge is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Gadgetbridge is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . */ +package nodomain.freeyourgadget.gadgetbridge.util; + +import android.content.Context; +import android.view.View; +import android.widget.DatePicker; + +import androidx.annotation.NonNull; +import androidx.preference.DialogPreference; +import androidx.preference.Preference; + +import nodomain.freeyourgadget.gadgetbridge.util.dialogs.MaterialPreferenceDialogFragment; + +public class XDatePreferenceFragment extends MaterialPreferenceDialogFragment implements DialogPreference.TargetFragment { + private DatePicker picker = null; + + @Override + protected View onCreateDialogView(final Context context) { + picker = new DatePicker(context); + picker.setPadding(0, 50, 0, 50); + + return picker; + } + + @Override + protected void onBindDialogView(final View v) { + super.onBindDialogView(v); + final XDatePreference pref = (XDatePreference) getPreference(); + + picker.init(pref.getYear(), pref.getMonth() - 1, pref.getDay(), null); + + if (pref.getMinDate() != 0) { + picker.setMinDate(pref.getMinDate()); + } + + if (pref.getMaxDate() != 0) { + picker.setMaxDate(pref.getMaxDate()); + } + } + + @Override + public void onDialogClosed(final boolean positiveResult) { + if (!positiveResult) { + return; + } + + final XDatePreference pref = (XDatePreference) getPreference(); + pref.setValue( + picker.getYear(), + picker.getMonth() + 1, + picker.getDayOfMonth() + ); + + final String date = pref.getPrefValue(); + if (pref.callChangeListener(date)) { + pref.persistStringValue(date); + pref.updateSummary(); + } + } + + @Override + public Preference findPreference(@NonNull final CharSequence key) { + return getPreference(); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java index 17d4e7bac..ac3349d2c 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreference.java @@ -23,10 +23,14 @@ import android.util.AttributeSet; import androidx.preference.DialogPreference; +import java.util.Locale; + public class XTimePreference extends DialogPreference { protected int hour = 0; protected int minute = 0; + protected Format format = Format.AUTO; + public XTimePreference(Context context, AttributeSet attrs) { super(context, attrs); } @@ -62,8 +66,19 @@ public class XTimePreference extends DialogPreference { updateSummary(); } + public String getPrefValue() { + return String.format(Locale.ROOT, "%02d:%02d", hour, minute); + } + + public void setValue(final int hour, final int minute) { + this.hour = hour; + this.minute = minute; + + persistStringValue(getPrefValue()); + } + void updateSummary() { - if (DateFormat.is24HourFormat(getContext())) + if (is24HourFormat()) setSummary(getTime24h()); else setSummary(getTime12h()); @@ -80,7 +95,34 @@ public class XTimePreference extends DialogPreference { return h + ":" + String.format("%02d", minute) + suffix; } + public void setFormat(final Format format) { + this.format = format; + } + + public Format getFormat() { + return format; + } + void persistStringValue(String value) { persistString(value); } + + public boolean is24HourFormat() { + switch (format) { + case FORMAT_24H: + return true; + case FORMAT_12H: + return false; + case AUTO: + default: + return DateFormat.is24HourFormat(getContext()); + } + } + + public enum Format { + AUTO, + FORMAT_24H, + FORMAT_12H, + ; + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreferenceFragment.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreferenceFragment.java index dfb616b05..b8f6bf91e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreferenceFragment.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/util/XTimePreferenceFragment.java @@ -32,7 +32,7 @@ public class XTimePreferenceFragment extends MaterialPreferenceDialogFragment im @Override protected View onCreateDialogView(Context context) { picker = new TimePicker(context); - picker.setIs24HourView(DateFormat.is24HourFormat(getContext())); + picker.setIs24HourView(((XTimePreference) getPreference()).is24HourFormat()); picker.setPadding(0, 50, 0, 50); return picker; diff --git a/app/src/main/proto/garmin/gdi_settings_service.proto b/app/src/main/proto/garmin/gdi_settings_service.proto index 88bbcc998..4ebbb08b8 100644 --- a/app/src/main/proto/garmin/gdi_settings_service.proto +++ b/app/src/main/proto/garmin/gdi_settings_service.proto @@ -5,25 +5,224 @@ package garmin_vivomovehr; option java_package = "nodomain.freeyourgadget.gadgetbridge.proto.garmin"; message SettingsService { - optional ChangeRequest change_request = 5; - optional ChangeResponse change_response = 6; + optional ScreenDefinitionRequest definitionRequest = 1; + optional ScreenDefinitionResponse definitionResponse = 2; + + optional ScreenStateRequest stateRequest = 3; + optional ScreenStateResponse stateResponse = 4; + + optional ChangeRequest changeRequest = 5; + optional ChangeResponse changeResponse = 6; + + optional InitRequest initRequest = 8; + optional InitResponse initResponse = 9; +} + +message ScreenDefinitionRequest { + optional uint32 screenId = 1; + optional uint32 unk2 = 2; // 0 + optional string language = 3; // en_US +} + +message ScreenDefinitionResponse { + optional uint32 unk1 = 1; // 0, status? + optional ScreenDefinition definition = 2; +} + +message ScreenDefinition { + optional uint32 screenId = 1; + optional uint32 unk2 = 2; // 0 + optional uint32 unk3 = 3; // 928002 + optional Label title = 4; + repeated ScreenEntry entry = 5; +} + +message Label { + optional string id = 1; + optional string text = 2; +} + +message ScreenEntry { + optional uint32 id = 1; + optional uint32 type = 2; + optional Label title = 3; + optional uint32 icon = 5; + optional Target target = 9; + optional SortOptions sortOptions = 12; + optional TextOption textOption = 13; +} + +message SortOptions { + optional uint32 unk3 = 3; // 1 + repeated SortEntry entries = 5; +} + +message SortEntry { + optional uint32 id = 1; + optional Label title = 2; + optional uint32 unk5 = 5; // 1 +} + +message TextOption { + optional TextLimits limits = 1; + optional uint32 unk2 = 2; // 0 +} + +message TextLimits { + optional uint32 maxLength = 1; +} + +message Target { + optional uint32 type = 1; // 0 subscreen, 1 list preference, 6 other activity, 7 hidden, 9 subscreen with options + optional uint32 subscreen = 2; // when 0 + optional uint32 activity = 3; // when 6 + optional TargetOptions options = 4; // when 1 + optional TargetNumberPicker numberPicker = 8; +} + +message ScreenStateRequest { + optional uint32 screenId = 1; +} + +message TargetOptions { + repeated TargetOptionEntry option = 1; +} + +message TargetOptionEntry { + optional Label title = 3; +} + +message TargetNumberPicker { + optional uint32 min = 1; + optional uint32 max = 2; + optional uint32 step = 3; // maybe? 1 on weight +} + +message ScreenStateResponse { + optional uint32 unk1 = 1; // 0 + optional ScreenState state = 2; +} + +message ScreenState { + optional uint32 screenId = 1; + optional uint32 unk2 = 2; // 0 + optional uint32 unk3 = 3; // 928002 + repeated EntryState state = 4; +} + +message EntryState { + optional uint32 id = 1; + optional uint32 state = 2; + optional Switch switch = 3; + optional Summary summary = 4; +} + +message Switch { + optional bool enabled = 1; + optional Label title = 2; +} + +message Summary { + optional Label title = 1; + optional ValueList valueList = 2; + optional ValueTime valueTime = 4; + optional ValueNumber valueNumber = 6; + optional ValueDate valueDate = 8; + optional ValueHeight valueHeight = 10; +} + +message ValueList { + optional uint32 index = 1; +} + +message ValueTime { + optional uint32 seconds = 1; + optional uint32 timeFormat = 3; // 0 12h, 1 24h +} + +message ValueNumber { + optional uint32 value = 1; + optional Label subtitle = 2; + optional Label title = 3; + optional Label unit = 4; +} + +message ValueDate { + optional Label subtitle = 1; + optional Date currentDate = 2; + optional Date minDate = 3; + optional Date maxDate = 4; +} + +message ValueHeight { + optional Label subtitle = 1; + optional uint32 value = 2; + optional uint32 unit = 3; // 0 cm +} + +message Date { + optional uint32 month = 1; + optional uint32 day = 2; + optional uint32 year = 3; } message ChangeRequest { - optional uint32 pointer1 = 1; - optional uint32 pointer2 = 2; - optional Switch enable = 3; + optional uint32 screenId = 1; + optional uint32 entryId = 2; + optional Switch switch = 3; + optional Option option = 4; + optional Time time = 6; + optional Number number = 8; + optional Position position = 11; + optional NewDate newDate = 12; + optional Text text = 14; + optional Height height = 15; message Switch { required bool value = 1; } + message Option { + optional uint32 index = 1; + } + message Time { + optional uint32 seconds = 1; + } + message Number { + optional uint32 value = 1; + } + message Position { + optional uint32 index = 1; + optional bool delete = 2; + } + message NewDate { + optional Date value = 1; + } + message Text { + optional string value = 1; + } + message Height { + optional uint32 value = 1; + optional uint32 unit = 2; + } } message ChangeResponse { optional ResponseStatus status = 1; + optional ScreenState state = 3; + optional bool shouldReturn = 5; +} + +message InitRequest { + optional string language = 1; // en_US + optional string region = 2; // us +} + +message InitResponse { + optional string unk1 = 1; + optional string unk2 = 2; } enum ResponseStatus { SUCCESS = 0; GENERIC_ERROR = 1; -} \ No newline at end of file +} diff --git a/app/src/main/res/drawable/ic_add_gray.xml b/app/src/main/res/drawable/ic_add_gray.xml new file mode 100644 index 000000000..3732fe476 --- /dev/null +++ b/app/src/main/res/drawable/ic_add_gray.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/menu/menu_garmin_realtime_settings.xml b/app/src/main/res/menu/menu_garmin_realtime_settings.xml new file mode 100644 index 000000000..7bc674916 --- /dev/null +++ b/app/src/main/res/menu/menu_garmin_realtime_settings.xml @@ -0,0 +1,17 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c35a7e925..dec49cb1a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1103,6 +1103,7 @@ Year of birth Gender Height in cm + Height in inches Weight in kg Target weight in kg Step length in cm @@ -2890,4 +2891,11 @@ Local file The list below contains all URLs requested by the watch for AGPS updates. You can select a file from the phone\'s storage that will be sent to the watch when it requests an update. Copied to clipboard + Loading… + Toggle debug mode + Share debug info + Realtime settings + Unsupported + Minimum: %d + Maximum: %d diff --git a/app/src/main/res/xml/devicesettings_garmin_default_reply_suffix.xml b/app/src/main/res/xml/devicesettings_garmin_default_reply_suffix.xml deleted file mode 100644 index ab3d63e4d..000000000 --- a/app/src/main/res/xml/devicesettings_garmin_default_reply_suffix.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - diff --git a/app/src/main/res/xml/devicesettings_garmin_realtime_settings.xml b/app/src/main/res/xml/devicesettings_garmin_realtime_settings.xml new file mode 100644 index 000000000..89c718c71 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_garmin_realtime_settings.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/garmin_realtime_settings.xml b/app/src/main/res/xml/garmin_realtime_settings.xml new file mode 100644 index 000000000..f94b2efca --- /dev/null +++ b/app/src/main/res/xml/garmin_realtime_settings.xml @@ -0,0 +1,5 @@ + + + +