Compare commits

...

51 Commits

Author SHA1 Message Date
José Rebelo ff51fa7309 Garmin HACK: Parse messageTypes > 0x8000 2024-04-25 18:17:56 +01:00
José Rebelo 81ea814681 Garmin: Send location to watch 2024-04-25 18:14:07 +02:00
Daniele Gobbetti 2605dff105 Garmin: calendar integration improvements
use the protobuf fields described in the documentation[0]
build the message according to the requested fields

[0] https://gadgetbridge.org/internals/specifics/garmin-protocol/#calendarevent
2024-04-25 18:09:36 +02:00
Daniele Gobbetti 39aea72ef7 Garmin: fixup use developer device setting for keeping data on device
Logic was inverted
2024-04-25 18:09:36 +02:00
a0z 60c3834e0b Garmin: Initial support of Instinct 2 Solar 2024-04-25 18:09:36 +02:00
Daniele Gobbetti 6e0d7edfa7 Garmin: fix notification crashes and handle SMS correctly
It looks like (some) watches really don't like having an empty list of actions, hence enable the legacy "refuse" action in every case, leaving it empty and inactive.
Further display the SMS sender in the notification and enable the correct code path for the reply action to work.
2024-04-25 18:09:36 +02:00
José Rebelo 025f15e0fa Garmin: Auto-detect canned messages support 2024-04-25 18:09:36 +02:00
José Rebelo db378faf47 Garmin: Fix reply to sms 2024-04-25 18:09:36 +02:00
José Rebelo 7bf73c6b41 Garmin: Add setting to disable notifications 2024-04-25 18:09:36 +02:00
José Rebelo f6754df211 Garmin Venu 3: Enable canned replies 2024-04-25 18:09:36 +02:00
Daniele Gobbetti b4414f0799 Garmin: use developer device setting for keeping data on device
Make use of the previously added preference to toggle file archival (deletion) on the watch.

Default is true (keep data on device) until we are sure of the consequences.
2024-04-25 18:09:36 +02:00
José Rebelo ce0a61e211 Garmin: Map all known files types 2024-04-25 18:09:36 +02:00
José Rebelo 3386e86158 Garmin: Add support for http weather requests 2024-04-25 18:09:36 +02:00
Daniele Gobbetti c5a94d2927 Garmin: Rename LocalMessage to PredefinedLocalMessage and clarify its usage
PredefinedLocalMessage are only useful for FIT messages and should not interfere with FIT files. The only impact of using the local message in fit files was in the textual output, but it was confusing.

Add an explicit constructor to RecordHeader if PredefinedLocalMessage should be taken into account, and use this only in fit messages leaving the default constructor for fit files.

Also adjusts the test case as textual output comparison needs to be fixed.
2024-04-25 18:09:36 +02:00
kuhy 18a6a6b1c7 Initial support for Garmin Vivoactive 4S 2024-04-25 18:09:36 +02:00
Daniele Gobbetti f1c7c97558 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.
2024-04-25 18:09:36 +02:00
myxor 3baeb2b14e Initial support for Garmin Vivoactive 5 2024-04-25 18:09:36 +02:00
Daniele Gobbetti d6ade723d3 Garmin: Add support for replying to notifications
This uses the (assumed) new method of passing multiple actions, instead of the (assumed) legacy accept/decline approach.
At the moment the preset messages stored on the watch firmware are used for replying, the code supports using custom messages already but those have to be updated to the watch somehow (probably by protobuf) and this is not supported yet. Using custom messages if they are not set will just do nothing.
The NotificationActionIconPosition values have been determined on a vĂ­vomove Style and might not work properly on other watches.
The evaluation of GBDeviceEvent have been moved in GarminSupport since the notification actions handling uses device events.

Also adds a method to read null terminated strings to GarminByteBufferReader.
Also adds a warning in NotificationListener if the wrong handle is used for replying to a notification.
2024-04-25 18:09:36 +02:00
Daniele Gobbetti 863c1a5657 Garmin: Add FileDownloadedDeviceEvent and (disabled) file deletion
Also adds (disabled) file deletion in case of already downloaded files
2024-04-25 18:09:36 +02:00
Daniele Gobbetti 16f5890a95 Garmin: Add DST/Timezone support 2024-04-25 18:09:36 +02:00
hrdl 10c5286ec1 Add Garmin Forerunner 245 2024-04-25 18:09:36 +02:00
Daniele Gobbetti 6b821a2f1f Garmin: Support file archival (deletion) on watch
Also add original timestamp to local cache filename as the file identifier are reused
Also fix imports of Test class
2024-04-25 18:09:36 +02:00
José Rebelo bcfaf7b3e8 Garmin: Fetch activity on demand 2024-04-25 18:09:36 +02:00
José Rebelo 2f2b95beee Garmin: Fix proguard rules for release builds 2024-04-25 18:09:36 +02:00
José Rebelo 6569cd74ba Garmin: Allow high MTU 2024-04-25 18:09:36 +02:00
José Rebelo 49d4792677 Garmin protocol: Simplify FILE_TYPE 2024-04-25 18:09:36 +02:00
José Rebelo 418bb7d37a Garmin protocol: Fix linter warnings 2024-04-25 18:09:36 +02:00
José Rebelo 044ac1e917 Garmin protocol: Introduce GarminCoordinator 2024-04-25 18:09:36 +02:00
José Rebelo bad5cd4045 Garmin protocol: fix crash when stopping find phone 2024-04-25 18:09:35 +02:00
Daniele Gobbetti b3da377b34 Garmin protocol: basic file transfer and notification handling
adds synchronization of supported files from watch to external directory
adds support for Activity and Monitoring files (workouts and activity samples), but those are not integrated yet
adds upload functionality (not used ATM and not tested)
adds notification support without actions
introduces centralized processing of "messageHandlers" (protobuf, file transfer, notifications)

also properly dispose of the music timer when disconnecting
2024-04-25 18:09:35 +02:00
Daniele Gobbetti 790019fa5a Garmin protocol: enable media volume control from watch 2024-04-25 18:09:35 +02:00
Daniele Gobbetti c96d1da1e5 Garmin protocol: store max packet size from DeviceInformationMessage
also adds messageType to the warnifleftover log message
2024-04-25 18:09:35 +02:00
Daniele Gobbetti 293449b5e0 Garmin protocol: various changes
- add FitFile class that deals with parsing and generating outgoing files
- consider all field definitions with number 253 as Timestamps [0]
- add support for "compressed timestamps" in fit file parsing. Those are not returned among the other normal fields but are available through a method of RecordData
- adjust the test cases

[0]48b6554d8a/fitdecode/reader.py (L719)
2024-04-25 18:09:35 +02:00
Daniele Gobbetti 57db0c7c33 Garmin protocol: change naming and logic of several FIT classes
- refactor the logic of Global and Local messages
- add some Global messages with naming taken from [1]
- Global messages are not enum because there are too many
- introduce the concept of FieldDefinitionPrimitive
- add new Field Definitions
- add support for developer fields and array fields
- add test case for FIT files taken from [0]

[0] https://github.com/polyvertex/fitdecode/
[1] https://www.fitfileviewer.com/
2024-04-25 18:09:35 +02:00
Daniele Gobbetti 379b8912cb Garmin protocol: create helper class GarminByteBufferReader
separate the logic specific for GFDI messages from the generally useful logic.
Also centralize the logging in case of leftover bytes while parsing GFDI messages.
2024-04-25 18:09:35 +02:00
Daniele Gobbetti 2aa8667998 Garmin protocol: create custom GBDeviceEvent for weather request 2024-04-25 18:09:35 +02:00
Daniele Gobbetti 3a3ff5bc6a Garmin protocol: use message enum instead of id in GFDI Messages 2024-04-25 18:09:35 +02:00
Daniele Gobbetti eff233d93a Garmin protocol: refactoring and fixes of BaseTypes
The boundaries are enforced on the stored value when decoding, before applying the adjustments for scale and offset.
Also add some tests for the BaseTypes
Introduce new FieldDefinition for Temperature and WeatherCondition (removing the static class)
Add accessors for field data in the containing RecordData, thus keeping the FieldData private
2024-04-25 18:09:35 +02:00
Daniele Gobbetti e73d4a7130 Garmin protocol: create specific field definition for day of week 2024-04-25 18:09:35 +02:00
Daniele Gobbetti 40d064cf7f Garmin protocol: move field encode/decode interface to the FieldDefinition
This allows for semantic subclassing the FieldDefinition.
A FieldDefinitionTimestamp subclass is introduced as example
2024-04-25 18:09:35 +02:00
Daniele Gobbetti 719a104811 Garmin protocol: fix invalid signed int base type value 2024-04-25 18:09:35 +02:00
Daniele Gobbetti fb3338f099 Garmin protocol: add initial support for FIT messages
note: only weather message definition and data tested so far
also enable weather support for Instinct 2S and vivomove style
also cleanup some unused constants that have been migrated to new enums in GFDIMessage
additionally switch to new local implementation of GarminTimeUtils with needed methods
2024-04-25 18:09:35 +02:00
Daniele Gobbetti fe1f610546 Garmin protocol: fixes
- fix DEVICE_SETTINGS message ID
- put all status messages in own package
- allow protobuf handler to change the returned status message to signal unsupported requests
- fix various bugs
2024-04-25 18:09:35 +02:00
Daniele Gobbetti 1d1c6146a7 Garmin protocol: initial refactoring and basic functionalities
This commit takes aims to bring many new garmin devices up to a working status, with basic functionalities such as:
- garmin protocol initialization
- basic message exchange
- support for some messages in Garmin own format
- support for some messages in protobuf format
2024-04-25 18:09:35 +02:00
José Rebelo 500e930237 Refactor location service
- Refactor the code from a static global instance to a lifecycle-aware
  service instantiated in the DeviceCommunicationService
- Fix number of devices reported in the notification
- Prevents leaks and properly stops when devices get disconnected
2024-04-25 17:08:53 +01:00
José Rebelo 3799ffb72c Zepp OS: Sync calendar event reminders 2024-04-25 15:58:57 +01:00
José Rebelo 13d6c49bb5 Xiaomi: Sync calendar event reminders 2024-04-25 15:00:48 +01:00
Vitaliy Tomin 67cf9b2f00 huawei: Add huawei account support (#3721)
* this feature allows to pair HarmonyOS devices without factory reset to
  GB and Huawei Health.

* huawei account has form of 17 digit string and could be retrived from
  logcat filtering by huid=

Reviewed-on: https://codeberg.org/Freeyourgadget/Gadgetbridge/pulls/3721
Co-authored-by: Vitaliy Tomin <highwaystar.ru@gmail.com>
Co-committed-by: Vitaliy Tomin <highwaystar.ru@gmail.com>
2024-04-25 12:19:00 +00:00
Daniele Gobbetti 173e2d29b0 Include Organizer and Reminders when reading calendar events
Also use the named column indexes instead of numeric ids when retrieving the contents to make it more clear and more robust in case further fields are added later.

Reminders are set as absolute timestamp.
2024-04-25 11:46:34 +02:00
Marcel Alexandru Nitan 2190c82ed7 feature: Sleep as android support
Implement support for Sleep As Android with an usable example for ZeppOs
devices

Sleep as Android documentation:

https://docs.sleep.urbandroid.org/devs/wearable_api.html

Signed-off-by: Marcel Alexandru Nitan <nitan.marcel@protonmail.com>
2024-04-20 12:15:42 +03:00
Arjan Schrijver f186053dab Dashboard: Add preference for drawing midnight at bottom of 24h chart 2024-04-18 20:06:56 +02:00
171 changed files with 10837 additions and 501 deletions

View File

@ -1,2 +0,0 @@
connection.project.dir=
eclipse.preferences.version=1

View File

@ -34,6 +34,12 @@
}
-keepattributes JavascriptInterface
# Keep parseIncoming for GFDIMessage classes, as it is called by reflection in GFDIMessage#parseIncoming
-keep public class * extends nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage
-keepclassmembers class * extends nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage {
public static *** parseIncoming(...);
}
# https://github.com/tony19/logback-android/issues/29
-dontwarn javax.mail.**

View File

@ -110,6 +110,24 @@
android:requestLegacyExternalStorage="true"
android:theme="@style/GadgetbridgeTheme"
tools:replace="android:label">
<receiver
android:name=".externalevents.sleepasandroid.SleepAsAndroidReceiver"
android:enabled="true"
android:exported="true" >
<intent-filter>
<action android:name="com.urbandroid.sleep.watch.START_TRACKING" />
<action android:name="com.urbandroid.sleep.watch.STOP_TRACKING" />
<action android:name="com.urbandroid.sleep.watch.SET_PAUSE" />
<action android:name="com.urbandroid.sleep.watch.SET_SUSPENDED" />
<action android:name="com.urbandroid.sleep.watch.SET_BATCH_SIZE" />
<action android:name="com.urbandroid.sleep.watch.START_ALARM" />
<action android:name="com.urbandroid.sleep.watch.STOP_ALARM" />
<action android:name="com.urbandroid.sleep.watch.UPDATE_ALARM" />
<action android:name="com.urbandroid.sleep.watch.SHOW_NOTIFICATION" />
<action android:name="com.urbandroid.sleep.watch.HINT" />
<action android:name="com.urbandroid.sleep.watch.CHECK_CONNECTED" />
</intent-filter>
</receiver>
<activity
android:name=".activities.ControlCenterv2"
android:label="@string/title_activity_controlcenter"
@ -132,6 +150,10 @@
android:name=".activities.DashboardPreferencesActivity"
android:label="@string/dashboard_settings"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.SleepAsAndroidPreferencesActivity"
android:label="@string/sleepasandroid_settings"
android:parentActivityName=".activities.SettingsActivity" />
<activity
android:name=".activities.AboutUserPreferencesActivity"
android:label="@string/activity_prefs_about_you"
@ -859,4 +881,4 @@
android:parentActivityName=".activities.ControlCenterv2" />
</application>
</manifest>
</manifest>

View File

@ -106,7 +106,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceManager;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationManager;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService;
import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksContentObserver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.opentracks.OpenTracksController;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
@ -220,6 +220,12 @@ public class DebugActivity extends AbstractGBActivity {
replyAction.title = "Reply";
replyAction.type = NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR;
notificationSpec.attachedActions.add(replyAction);
} else if (notificationSpec.type == NotificationType.CONVERSATIONS) {
// REPLY action
NotificationSpec.Action replyAction = new NotificationSpec.Action();
replyAction.title = "Reply";
replyAction.type = NotificationSpec.Action.TYPE_WEARABLE_REPLY;
notificationSpec.attachedActions.add(replyAction);
}
GBApplication.deviceService().onNotification(notificationSpec);
@ -659,7 +665,7 @@ public class DebugActivity extends AbstractGBActivity {
stopPhoneGpsLocationListener.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
GBLocationManager.stopAll(getBaseContext());
GBLocationService.stop(DebugActivity.this, null);
}
});

View File

@ -421,6 +421,15 @@ public class SettingsActivity extends AbstractSettingsActivityV2 {
});
}
pref = findPreference("pref_category_sleepasandroid");
if (pref != null) {
pref.setOnPreferenceClickListener(preference -> {
Intent enableIntent = new Intent(requireContext(), SleepAsAndroidPreferencesActivity.class);
startActivity(enableIntent);
return true;
});
}
final Preference theme = findPreference("pref_key_theme");
final Preference amoled_black = findPreference("pref_key_theme_amoled_black");

View File

@ -0,0 +1,138 @@
package nodomain.freeyourgadget.gadgetbridge.activities;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.devices.SleepAsAndroidFeature;
public class SleepAsAndroidPreferencesActivity extends AbstractSettingsActivityV2 {
@Override
protected String fragmentTag() {
return SleepAsAndroidPreferencesFragment.FRAGMENT_TAG;
}
@Override
protected PreferenceFragmentCompat newFragment() {
return new SleepAsAndroidPreferencesFragment();
}
public static class SleepAsAndroidPreferencesFragment extends AbstractPreferenceFragment {
static final String FRAGMENT_TAG = "SLEEPASANDROID_PREFERENCES_FRAGMENT";
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
setPreferencesFromResource(R.xml.sleepasandroid_preferences, rootKey);
final ListPreference sleepAsAndroidSlots = findPreference("sleepasandroid_alarm_slot");
if (sleepAsAndroidSlots != null)
{
loadAlarmSlots(sleepAsAndroidSlots);
}
final ListPreference sleepAsAndroidDevices = findPreference("sleepasandroid_device");
if (sleepAsAndroidDevices != null) {
loadDevicesList(sleepAsAndroidDevices);
sleepAsAndroidDevices.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
@Override
public boolean onPreferenceChange(@NonNull Preference preference, Object newValue) {
GBDevice device = GBApplication.app().getDeviceManager().getDeviceByAddress(newValue.toString());
if (device != null) {
GBApplication.getPrefs().getPreferences().edit().putString("sleepasandroid_device", device.getAddress()).apply();
Set<SleepAsAndroidFeature> supportedFeatures = device.getDeviceCoordinator().getSleepAsAndroidFeatures();
findPreference("sleepasandroid_alarm_slot").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.ALARMS));
findPreference("pref_key_sleepasandroid_feat_alarms").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.ALARMS));
findPreference("pref_key_sleepasandroid_feat_notifications").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.NOTIFICATIONS));
findPreference("pref_key_sleepasandroid_feat_movement").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.ACCELEROMETER));
findPreference("pref_key_sleepasandroid_feat_hr").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.HEART_RATE));
findPreference("pref_key_sleepasandroid_feat_oximetry").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.OXIMETRY));
findPreference("pref_key_sleepasandroid_feat_spo2").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.SPO2));
ListPreference alarmSlots = findPreference("sleepasandroid_alarm_slot");
if (alarmSlots != null)
{
loadAlarmSlots(alarmSlots);
if (alarmSlots.getEntries().length > 0)
{
alarmSlots.setValueIndex(0);
GB.toast(getString(R.string.alarm_slot_reset), Toast.LENGTH_SHORT, GB.WARN);
}
}
}
return false;
}
});
}
String defaultDeviceAddr = GBApplication.getPrefs().getString("sleepasandroid_device", "");
if (!defaultDeviceAddr.isEmpty()) {
GBDevice device = GBApplication.app().getDeviceManager().getDeviceByAddress(defaultDeviceAddr);
if (device != null) {
Set<SleepAsAndroidFeature> supportedFeatures = device.getDeviceCoordinator().getSleepAsAndroidFeatures();
findPreference("sleepasandroid_alarm_slot").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.ALARMS));
findPreference("pref_key_sleepasandroid_feat_alarms").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.ALARMS));
findPreference("pref_key_sleepasandroid_feat_notifications").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.NOTIFICATIONS));
findPreference("pref_key_sleepasandroid_feat_movement").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.ACCELEROMETER));
findPreference("pref_key_sleepasandroid_feat_hr").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.HEART_RATE));
findPreference("pref_key_sleepasandroid_feat_oximetry").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.OXIMETRY));
findPreference("pref_key_sleepasandroid_feat_spo2").setEnabled(supportedFeatures.contains(SleepAsAndroidFeature.SPO2));
}
}
}
}
private static void loadAlarmSlots(ListPreference sleepAsAndroidSlots) {
if (sleepAsAndroidSlots != null) {
String defaultDeviceAddr = GBApplication.getPrefs().getString("sleepasandroid_device", "");
if (!defaultDeviceAddr.isEmpty()) {
GBDevice device = GBApplication.app().getDeviceManager().getDeviceByAddress(defaultDeviceAddr);
if (device != null) {
int maxAlarmSlots = device.getDeviceCoordinator().getAlarmSlotCount(device);
if (maxAlarmSlots > 0) {
List<String> alarmSlots = new ArrayList<>();
int reservedAlarmSlots = GBApplication.getPrefs().getInt(DeviceSettingsPreferenceConst.PREF_RESERVER_ALARMS_CALENDAR, 0);
for (int i = reservedAlarmSlots + 1;i < maxAlarmSlots; i++) {
alarmSlots.add(String.valueOf(i));
}
sleepAsAndroidSlots.setEntryValues(alarmSlots.toArray(new String[0]));
sleepAsAndroidSlots.setEntries(alarmSlots.toArray(new String[0]));
}
}
}
}
}
private static void loadDevicesList(ListPreference sleepAsAndroidDevices) {
List<GBDevice> devices = GBApplication.app().getDeviceManager().getDevices();
List<String> deviceMACs = new ArrayList<>();
List<String> deviceNames = new ArrayList<>();
for (GBDevice dev : devices) {
if (dev.getDeviceCoordinator().supportsSleepAsAndroid()) {
deviceMACs.add(dev.getAddress());
deviceNames.add(dev.getAliasOrName());
}
}
sleepAsAndroidDevices.setEntryValues(deviceMACs.toArray(new String[0]));
sleepAsAndroidDevices.setEntries(deviceNames.toArray(new String[0]));
}
}

View File

@ -137,6 +137,9 @@ public class DashboardTodayWidget extends AbstractDashboardWidget {
}
private void draw() {
Prefs prefs = GBApplication.getPrefs();
boolean upsideDown24h = prefs.getBoolean("dashboard_widget_today_24h_upside_down", false);
// Prepare circular chart
long midDaySecond = dashboardData.timeFrom + (12 * 60 * 60);
int width = 500;
@ -183,7 +186,16 @@ public class DashboardTodayWidget extends AbstractDashboardWidget {
textPaint.setTextSize(hourTextPixels);
textPaint.setTextAlign(Paint.Align.CENTER);
Rect textBounds = new Rect();
if (mode_24h) {
if (mode_24h && upsideDown24h) {
textPaint.getTextBounds(hours.get(6), 0, hours.get(6).length(), textBounds);
canvas.drawText(hours.get(6), clockMargin + clockStripesWidth + textBounds.width() / 2f, height / 2f + textBounds.height() / 2f, textPaint);
textPaint.getTextBounds(hours.get(12), 0, hours.get(12).length(), textBounds);
canvas.drawText(hours.get(12), width / 2f, clockMargin + clockStripesWidth + textBounds.height(), textPaint);
textPaint.getTextBounds(hours.get(18), 0, hours.get(18).length(), textBounds);
canvas.drawText(hours.get(18), width - (clockMargin + clockStripesWidth + textBounds.width()), height / 2f + textBounds.height() / 2f, textPaint);
textPaint.getTextBounds(hours.get(24), 0, hours.get(24).length(), textBounds);
canvas.drawText(hours.get(24), width / 2f, height - (clockMargin + clockStripesWidth), textPaint);
} else if (mode_24h) {
textPaint.getTextBounds(hours.get(6), 0, hours.get(6).length(), textBounds);
canvas.drawText(hours.get(6), width - (clockMargin + clockStripesWidth + textBounds.width()), height / 2f + textBounds.height() / 2f, textPaint);
textPaint.getTextBounds(hours.get(12), 0, hours.get(12).length(), textBounds);
@ -216,6 +228,7 @@ public class DashboardTodayWidget extends AbstractDashboardWidget {
// Draw generalized activities on circular chart
long secondIndex = dashboardData.timeFrom;
long currentTime = Calendar.getInstance().getTimeInMillis() / 1000;
int startAngle = mode_24h && upsideDown24h ? 90 : 270;
synchronized (dashboardData.generalizedActivities) {
for (DashboardFragment.DashboardData.GeneralizedActivity activity : dashboardData.generalizedActivities) {
// Determine margin depending on 24h/12h mode
@ -224,15 +237,15 @@ public class DashboardTodayWidget extends AbstractDashboardWidget {
if (!mode_24h && secondIndex < midDaySecond && activity.timeFrom >= midDaySecond) {
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (midDaySecond - secondIndex) / degreeFactor, false, paint);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, startAngle + (secondIndex - dashboardData.timeFrom) / degreeFactor, (midDaySecond - secondIndex) / degreeFactor, false, paint);
secondIndex = midDaySecond;
}
if (activity.timeFrom > secondIndex) {
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(margin, margin, width - margin, height - margin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (activity.timeFrom - secondIndex) / degreeFactor, false, paint);
canvas.drawArc(margin, margin, width - margin, height - margin, startAngle + (secondIndex - dashboardData.timeFrom) / degreeFactor, (activity.timeFrom - secondIndex) / degreeFactor, false, paint);
}
float start_angle = 270 + (activity.timeFrom - dashboardData.timeFrom) / degreeFactor;
float start_angle = startAngle + (activity.timeFrom - dashboardData.timeFrom) / degreeFactor;
float sweep_angle = (activity.timeTo - activity.timeFrom) / degreeFactor;
if (activity.activityKind == ActivityKind.TYPE_NOT_MEASURED) {
paint.setStrokeWidth(barWidth / 3f);
@ -271,11 +284,11 @@ public class DashboardTodayWidget extends AbstractDashboardWidget {
// Fill inner bar up until current time
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (currentTime - secondIndex) / degreeFactor, false, paint);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, startAngle + (secondIndex - dashboardData.timeFrom) / degreeFactor, (currentTime - secondIndex) / degreeFactor, false, paint);
// Fill inner bar up until midday
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, 270 + (currentTime - dashboardData.timeFrom) / degreeFactor, (midDaySecond - currentTime) / degreeFactor, false, paint);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, startAngle + (currentTime - dashboardData.timeFrom) / degreeFactor, (midDaySecond - currentTime) / degreeFactor, false, paint);
// Fill outer bar up until midnight
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
@ -287,24 +300,24 @@ public class DashboardTodayWidget extends AbstractDashboardWidget {
if (!mode_24h && secondIndex < midDaySecond) {
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (midDaySecond - secondIndex) / degreeFactor, false, paint);
canvas.drawArc(innerCircleMargin, innerCircleMargin, width - innerCircleMargin, height - innerCircleMargin, startAngle + (secondIndex - dashboardData.timeFrom) / degreeFactor, (midDaySecond - secondIndex) / degreeFactor, false, paint);
secondIndex = midDaySecond;
}
// Fill outer bar up until current time
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (currentTime - secondIndex) / degreeFactor, false, paint);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, startAngle + (secondIndex - dashboardData.timeFrom) / degreeFactor, (currentTime - secondIndex) / degreeFactor, false, paint);
// Fill outer bar up until midnight
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, 270 + (currentTime - dashboardData.timeFrom) / degreeFactor, (dashboardData.timeTo - currentTime) / degreeFactor, false, paint);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, startAngle + (currentTime - dashboardData.timeFrom) / degreeFactor, (dashboardData.timeTo - currentTime) / degreeFactor, false, paint);
}
// Only when displaying a past day
if (secondIndex < dashboardData.timeTo && currentTime > dashboardData.timeTo) {
// Fill outer bar up until midnight
paint.setStrokeWidth(barWidth / 3f);
paint.setColor(color_unknown);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, 270 + (secondIndex - dashboardData.timeFrom) / degreeFactor, (dashboardData.timeTo - secondIndex) / degreeFactor, false, paint);
canvas.drawArc(outerCircleMargin, outerCircleMargin, width - outerCircleMargin, height - outerCircleMargin, startAngle + (secondIndex - dashboardData.timeFrom) / degreeFactor, (dashboardData.timeTo - secondIndex) / degreeFactor, false, paint);
}
todayChart.setImageBitmap(todayBitmap);

View File

@ -441,4 +441,5 @@ public class DeviceSettingsPreferenceConst {
public static final String PREF_AUTO_REPLY_INCOMING_CALL = "pref_auto_reply_phonecall";
public static final String PREF_AUTO_REPLY_INCOMING_CALL_DELAY = "pref_auto_reply_phonecall_delay";
public static final String PREF_SPEAK_NOTIFICATIONS_ALOUD = "pref_speak_notifications_aloud";
public static final String PREF_GARMIN_DEFAULT_REPLY_SUFFIX = "pref_key_garmin_default_reply_suffix";
}

View File

@ -19,23 +19,6 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.activities.devicesettings;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_CONTROL_CENTER_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_DISPLAY_ITEMS;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_EXPOSE_HR_THIRDPARTY;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_SHORTCUTS;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_SHORTCUTS_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_MI2_DATEFORMAT;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_MI2_ROTATE_WRIST_TO_SWITCH_INFO;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_END;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_OFF;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_SCHEDULED;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_START;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_SWIPE_UNLOCK;
import android.content.Context;
import android.content.Intent;
import android.media.AudioManager;
@ -78,6 +61,23 @@ import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.*;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_CONTROL_CENTER_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_DISPLAY_ITEMS;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_DISPLAY_ITEMS_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_EXPOSE_HR_THIRDPARTY;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_SHORTCUTS;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_SHORTCUTS_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiConst.PREF_WORKOUT_ACTIVITY_TYPES_SORTABLE;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_MI2_DATEFORMAT;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_MI2_ROTATE_WRIST_TO_SWITCH_INFO;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_END;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_OFF;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_SCHEDULED;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_NIGHT_MODE_START;
import static nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst.PREF_SWIPE_UNLOCK;
public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment implements DeviceSpecificSettingsHandler {
private static final Logger LOG = LoggerFactory.getLogger(DeviceSpecificSettingsFragment.class);
@ -356,7 +356,7 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
});
}
addPreferenceHandlerFor(PREF_SEND_APP_NOTIFICATIONS);
addPreferenceHandlerFor(PREF_SWIPE_UNLOCK);
addPreferenceHandlerFor(PREF_MI2_DATEFORMAT);
addPreferenceHandlerFor(PREF_DATEFORMAT);
@ -622,6 +622,8 @@ public class DeviceSpecificSettingsFragment extends AbstractPreferenceFragment i
addPreferenceHandlerFor(PREF_HEARTRATE_AUTOMATIC_ENABLE);
addPreferenceHandlerFor(PREF_SPO_AUTOMATIC_ENABLE);
addPreferenceHandlerFor(PREF_GARMIN_DEFAULT_REPLY_SUFFIX);
addPreferenceHandlerFor("lock");
String sleepTimeState = prefs.getString(PREF_SLEEP_TIME, PREF_DO_NOT_DISTURB_OFF);

View File

@ -30,6 +30,7 @@ public enum DeviceSpecificSettingsScreen {
DEVELOPER("pref_screen_developer", R.xml.devicesettings_root_developer),
DISPLAY("pref_screen_display", R.xml.devicesettings_root_display),
GENERIC("pref_screen_generic", R.xml.devicesettings_root_generic),
LOCATION("pref_screen_location", R.xml.devicesettings_root_location),
NOTIFICATIONS("pref_screen_notifications", R.xml.devicesettings_root_notifications),
DATE_TIME("pref_screen_date_time", R.xml.devicesettings_root_date_time),
WORKOUT("pref_screen_workout", R.xml.devicesettings_root_workout),

View File

@ -843,7 +843,6 @@ public class GBDeviceAdapterv2 extends ListAdapter<GBDevice, GBDeviceAdapterv2.V
private void showDeviceSubmenu(final View v, final GBDevice device) {
boolean deviceConnected = device.getState() != GBDevice.State.NOT_CONNECTED;
PopupMenu menu = new PopupMenu(v.getContext(), v);
menu.inflate(R.menu.fragment_devices_device_submenu);

View File

@ -78,6 +78,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.Spo2Sample;
import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
@ -586,6 +587,16 @@ public abstract class AbstractDeviceCoordinator implements DeviceCoordinator {
return false;
}
@Override
public boolean supportsSleepAsAndroid() {
return false;
}
@Override
public Set<SleepAsAndroidFeature> getSleepAsAndroidFeatures() {
return Collections.emptySet();
}
@Override
public int[] getSupportedDeviceSpecificConnectionSettings() {
int[] settings = new int[0];

View File

@ -29,6 +29,7 @@ import java.io.IOException;
import java.util.Collection;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
@ -57,6 +58,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.StressSample;
import nodomain.freeyourgadget.gadgetbridge.model.TemperatureSample;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.ServiceDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
/**
* This interface is implemented at least once for every supported gadget device.
@ -511,6 +513,11 @@ public interface DeviceCoordinator {
*/
boolean supportsMusicInfo();
/**
* Indicates whether the device supports features required by Sleep As Android
*/
boolean supportsSleepAsAndroid();
/**
* Indicates the maximum reminder message length.
*/
@ -569,6 +576,12 @@ public interface DeviceCoordinator {
*/
boolean supportsUnicodeEmojis();
/**
* Returns the set of supported sleep as Android features
* @return Set
*/
Set<SleepAsAndroidFeature> getSleepAsAndroidFeatures();
/**
* Returns device specific settings related to connection
*

View File

@ -20,6 +20,7 @@ package nodomain.freeyourgadget.gadgetbridge.devices;
import android.location.Location;
import android.net.Uri;
import android.os.Bundle;
import java.util.ArrayList;
import java.util.UUID;
@ -148,4 +149,6 @@ public interface EventHandler {
void onPowerOff();
void onSetGpsLocation(Location location);
void onSleepAsAndroidAction(String action, Bundle extras);
}

View File

@ -0,0 +1,11 @@
package nodomain.freeyourgadget.gadgetbridge.devices;
public enum SleepAsAndroidFeature {
HEART_RATE,
ALARMS,
NOTIFICATIONS,
ACCELEROMETER,
OXIMETRY,
SPO2
}

View File

@ -0,0 +1,90 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
import androidx.annotation.NonNull;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen;
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public abstract class GarminCoordinator extends AbstractBLEDeviceCoordinator {
@Override
protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) throws GBException {
}
@Override
public String getManufacturer() {
return "Garmin";
}
@NonNull
@Override
public Class<? extends DeviceSupport> getDeviceSupportClass() {
return GarminSupport.class;
}
@Override
public DeviceSpecificSettings getDeviceSpecificSettings(final GBDevice device) {
final DeviceSpecificSettings deviceSpecificSettings = new DeviceSpecificSettings();
final List<Integer> 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);
}
final List<Integer> location = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.LOCATION);
location.add(R.xml.devicesettings_workout_send_gps_to_band);
final List<Integer> connection = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.CONNECTION);
connection.add(R.xml.devicesettings_high_mtu);
final List<Integer> developer = deviceSpecificSettings.addRootScreen(DeviceSpecificSettingsScreen.DEVELOPER);
developer.add(R.xml.devicesettings_keep_activity_data_on_device);
return deviceSpecificSettings;
}
@Override
public boolean supportsActivityDataFetching() {
return true;
}
@Override
public boolean supportsFindDevice() {
return true;
}
@Override
public boolean supportsWeather() {
return true;
}
@Override
public int getCannedRepliesSlotCount(final GBDevice device) {
if (getPrefs(device).getBoolean(GarminPreferences.PREF_FEAT_CANNED_MESSAGES, false)) {
return 16;
}
return 0;
}
protected static Prefs getPrefs(final GBDevice device) {
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()));
}
}

View File

@ -0,0 +1,6 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin;
public class GarminPreferences {
public static final String PREF_GARMIN_CAPABILITIES = "garmin_capabilities";
public static final String PREF_FEAT_CANNED_MESSAGES = "feat_canned_messages";
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.forerunner245;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminForerunner245Coordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("Forerunner 245");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_forerunner_245;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminInstinct2SCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("Instinct 2S");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_instinct_2s;
}
}

View File

@ -0,0 +1,19 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2solar;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import java.util.regex.Pattern;
public class GarminInstinct2SolarCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("Instinct 2 Solar");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_instinct_2_solar;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.venu3;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVenu3Coordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("Venu 3");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivomove_style;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive4s;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVivoActive4SCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("vĂ­voactive 4S");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivoactive_4s;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive5;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVivoActive5Coordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("vĂ­voactive 5");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivoactive_5;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivomove;
import java.util.regex.Pattern;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminCoordinator;
public class GarminVivomoveStyleCoordinator extends GarminCoordinator {
@Override
protected Pattern getSupportedDeviceName() {
return Pattern.compile("vĂ­vomove Style");
}
@Override
public int getDeviceNameResource() {
return R.string.devicetype_garmin_vivomove_style;
}
}

View File

@ -27,6 +27,8 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -43,6 +45,7 @@ import nodomain.freeyourgadget.gadgetbridge.capabilities.password.PasswordCapabi
import nodomain.freeyourgadget.gadgetbridge.GBException;
import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler;
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider;
import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiExtendedSampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample;
@ -73,6 +76,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.HuamiVibration
import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsPhoneService;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
import nodomain.freeyourgadget.gadgetbridge.devices.SleepAsAndroidFeature;
public abstract class ZeppOsCoordinator extends HuamiCoordinator {
public abstract String getDeviceBluetoothName();
@ -196,6 +200,16 @@ public abstract class ZeppOsCoordinator extends HuamiCoordinator {
return true;
}
@Override
public boolean supportsSleepAsAndroid() {
return true;
}
@Override
public Set<SleepAsAndroidFeature> getSleepAsAndroidFeatures() {
return EnumSet.of(SleepAsAndroidFeature.ACCELEROMETER, SleepAsAndroidFeature.HEART_RATE, SleepAsAndroidFeature.ALARMS, SleepAsAndroidFeature.NOTIFICATIONS);
}
@Override
public int getWorldClocksSlotCount() {
return 20; // as enforced by Zepp

View File

@ -30,6 +30,7 @@ import java.util.Collections;
import java.util.List;
import de.greenrobot.dao.query.QueryBuilder;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
@ -83,6 +84,11 @@ public abstract class HuaweiBRCoordinator extends AbstractBLClassicDeviceCoordin
return huaweiCoordinator.getSupportedLanguageSettings(device);
}
@Override
public int[] getSupportedDeviceSpecificAuthenticationSettings() {
return new int[]{R.xml.devicesettings_huawei_account};
}
@Override
public int getBondingStyle(){
return BONDING_STYLE_ASK;

View File

@ -71,6 +71,7 @@ public final class HuaweiConstants {
public static final String PREF_HUAWEI_ADDRESS = "huawei_address";
public static final String PREF_HUAWEI_WORKMODE = "workmode";
public static final String PREF_HUAWEI_TRUSLEEP = "trusleep";
public static final String PREF_HUAWEI_ACCOUNT = "huawei_account";
public static final String PREF_HUAWEI_DND_LIFT_WRIST_TYPE = "dnd_lift_wrist_type"; // SharedPref for 0x01 0x1D
public static final String PREF_HUAWEI_DEBUG_REQUEST = "debug_huawei_request";

View File

@ -30,6 +30,7 @@ import java.util.Collections;
import java.util.List;
import de.greenrobot.dao.query.QueryBuilder;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings;
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsCustomizer;
import nodomain.freeyourgadget.gadgetbridge.GBException;
@ -82,7 +83,12 @@ public abstract class HuaweiLECoordinator extends AbstractBLEDeviceCoordinator i
public String[] getSupportedLanguageSettings(GBDevice device) {
return huaweiCoordinator.getSupportedLanguageSettings(device);
}
@Override
public int[] getSupportedDeviceSpecificAuthenticationSettings() {
return new int[]{R.xml.devicesettings_huawei_account};
}
@Override
public int getBondingStyle(){
return BONDING_STYLE_NONE;

View File

@ -26,15 +26,18 @@ public class AccountRelated {
public static final byte id = 0x01;
public static class Request extends HuaweiPacket {
public Request (ParamsProvider paramsProvider) {
public Request (ParamsProvider paramsProvider, String account) {
super(paramsProvider);
this.serviceId = AccountRelated.id;
this.commandId = id;
this.tlv = new HuaweiTLV()
.put(0x01);
this.tlv = new HuaweiTLV();
if (account.length() > 0) {
tlv.put(0x01, account);
} else {
tlv.put(0x01);
}
this.complete = true;
}
}
@ -50,14 +53,19 @@ public class AccountRelated {
public static final byte id = 0x05;
public static class Request extends HuaweiPacket {
public Request (ParamsProvider paramsProvider, boolean accountPairingOptimization) {
public Request (ParamsProvider paramsProvider, boolean accountPairingOptimization, String account) {
super(paramsProvider);
this.serviceId = AccountRelated.id;
this.commandId = id;
this.tlv = new HuaweiTLV()
.put(0x01, (byte)0x00);
this.tlv = new HuaweiTLV();
if (account.length() > 0) {
tlv.put(0x01, account);
} else {
tlv.put(0x01, (byte)0x00);
}
if (accountPairingOptimization) {
this.tlv.put(0x03, (byte)0x01);
}

View File

@ -143,7 +143,7 @@ public class VivomoveHrCoordinator extends AbstractBLEDeviceCoordinator {
@Override
public int getDeviceNameResource() {
return R.string.devicetype_vivomove_hr;
return R.string.devicetype_garmin_vivomove_hr;
}
@Override

View File

@ -26,6 +26,7 @@ import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Enumeration;
import java.util.GregorianCalendar;
@ -188,6 +189,7 @@ public class CalendarReceiver extends BroadcastReceiver {
calendarEventSpec.id = i;
calendarEventSpec.title = calendarEvent.getTitle();
calendarEventSpec.allDay = calendarEvent.isAllDay();
calendarEventSpec.reminders = new ArrayList<>(calendarEvent.getRemindersAbsoluteTs());
calendarEventSpec.timestamp = calendarEvent.getBeginSeconds();
calendarEventSpec.durationInSeconds = calendarEvent.getDurationSeconds(); //FIXME: leads to problems right now
if (calendarEvent.isAllDay()) {

View File

@ -221,6 +221,8 @@ public class NotificationListener extends NotificationListenerService {
} catch (PendingIntent.CanceledException e) {
LOG.warn("replyToLastNotification error: " + e.getLocalizedMessage());
}
} else {
LOG.warn("Received ACTION_REPLY but cannot find the corresponding wearableAction");
}
break;
}

View File

@ -21,10 +21,14 @@ import android.location.LocationListener;
import android.os.Bundle;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.devices.EventHandler;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
/**
* An implementation of a {@link LocationListener} that forwards the location updates to the
@ -33,18 +37,18 @@ import nodomain.freeyourgadget.gadgetbridge.devices.EventHandler;
public class GBLocationListener implements LocationListener {
private static final Logger LOG = LoggerFactory.getLogger(GBLocationListener.class);
private final EventHandler eventHandler;
private final GBDevice device;
private Location previousLocation;
// divide by 3.6 to get km/h to m/s
private static final double SPEED_THRESHOLD = 1.0 / 3.6;
public GBLocationListener(final EventHandler eventHandler) {
this.eventHandler = eventHandler;
public GBLocationListener(final GBDevice device) {
this.device = device;
}
@Override
public void onLocationChanged(final Location location) {
public void onLocationChanged(@NonNull final Location location) {
LOG.info("Location changed: {}", location);
// Correct the location time
@ -61,16 +65,16 @@ public class GBLocationListener implements LocationListener {
previousLocation = location;
eventHandler.onSetGpsLocation(location);
GBApplication.deviceService(device).onSetGpsLocation(location);
}
@Override
public void onProviderDisabled(final String provider) {
public void onProviderDisabled(@NonNull final String provider) {
LOG.info("onProviderDisabled: {}", provider);
}
@Override
public void onProviderEnabled(final String provider) {
public void onProviderEnabled(@NonNull final String provider) {
LOG.info("onProviderDisabled: {}", provider);
}

View File

@ -1,140 +0,0 @@
/* Copyright (C) 2022-2024 halemmerich, José Rebelo, LukasEdl, Martin Boonk
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps;
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Bundle;
import android.os.Looper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.devices.EventHandler;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/**
* A static location manager, which keeps track of what providers are currently running. A notification is kept
* while there is at least one provider running.
*/
public class GBLocationManager {
private static final Logger LOG = LoggerFactory.getLogger(GBLocationManager.class);
/**
* The current number of running listeners.
*/
private static Map<EventHandler, Map<LocationProviderType, AbstractLocationProvider>> providers = new HashMap<>();
public static void start(final Context context, final EventHandler eventHandler) {
GBLocationManager.start(context, eventHandler, LocationProviderType.GPS, null);
}
public static void start(final Context context, final EventHandler eventHandler, final LocationProviderType providerType, Integer updateInterval) {
LOG.info("Starting");
if (providers.containsKey(eventHandler) && providers.get(eventHandler).containsKey(providerType)) {
LOG.warn("EventHandler already registered");
return;
}
GB.createGpsNotification(context, providers.size());
final GBLocationListener locationListener = new GBLocationListener(eventHandler);
final AbstractLocationProvider locationProvider;
switch (providerType) {
case GPS:
LOG.info("Using gps location provider");
locationProvider = new PhoneGpsLocationProvider(locationListener);
break;
case NETWORK:
LOG.info("Using network location provider");
locationProvider = new PhoneNetworkLocationProvider(locationListener);
break;
default:
LOG.info("Using default location provider: GPS");
locationProvider = new PhoneGpsLocationProvider(locationListener);
}
if (updateInterval != null) {
locationProvider.start(context, updateInterval);
} else {
locationProvider.start(context);
}
if (providers.containsKey(eventHandler)) {
providers.get(eventHandler).put(providerType, locationProvider);
} else {
Map<LocationProviderType, AbstractLocationProvider> providerMap = new HashMap<>();
providerMap.put(providerType, locationProvider);
providers.put(eventHandler, providerMap);
}
}
public static void stop(final Context context, final EventHandler eventHandler) {
GBLocationManager.stop(context, eventHandler, null);
}
public static void stop(final Context context, final EventHandler eventHandler, final LocationProviderType gpsType) {
if (!providers.containsKey(eventHandler)) return;
Map<LocationProviderType, AbstractLocationProvider> providerMap = providers.get(eventHandler);
if (gpsType == null) {
Set<LocationProviderType> toBeRemoved = new HashSet<>();
for (LocationProviderType providerType: providerMap.keySet()) {
stopProvider(context, providerMap.get(providerType));
toBeRemoved.add(providerType);
}
for (final LocationProviderType providerType : toBeRemoved) {
providerMap.remove(providerType);
}
} else {
stopProvider(context, providerMap.get(gpsType));
providerMap.remove(gpsType);
}
LOG.debug("Remaining providers: " + providers.size());
if (providers.get(eventHandler).size() == 0)
providers.remove(eventHandler);
updateNotification(context);
}
private static void updateNotification(final Context context){
if (!providers.isEmpty()) {
GB.createGpsNotification(context, providers.size());
} else {
GB.removeGpsNotification(context);
}
}
private static void stopProvider(final Context context, AbstractLocationProvider locationProvider) {
if (locationProvider != null) {
locationProvider.stop(context);
}
}
public static void stopAll(final Context context) {
for (EventHandler eventHandler : providers.keySet()) {
stop(context, eventHandler);
}
}
}

View File

@ -22,35 +22,30 @@ import android.location.LocationListener;
/**
* An abstract location provider, which periodically sends a location update to the provided {@link LocationListener}.
*/
public abstract class AbstractLocationProvider {
public abstract class GBLocationProvider {
private final Context context;
private final LocationListener locationListener;
public AbstractLocationProvider(final LocationListener locationListener) {
public GBLocationProvider(final Context context, final LocationListener locationListener) {
this.context = context;
this.locationListener = locationListener;
}
protected final LocationListener getLocationListener() {
public final Context getContext() {
return this.context;
}
public final LocationListener getLocationListener() {
return this.locationListener;
}
/**
* Start sending periodic location updates.
*
* @param context the {@link Context}.
*/
abstract void start(final Context context);
/**
* Start sending periodic location updates.
*
* @param context the {@link Context}.
*/
abstract void start(final Context context, final int interval);
public abstract void start(final int interval);
/**
* Stop sending periodic location updates.
*
* @param context the {@link Context}.
*/
abstract void stop(final Context context);
public abstract void stop();
}

View File

@ -0,0 +1,47 @@
/* Copyright (C) 2022-2024 LukasEdl
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps;
import android.content.Context;
import android.location.LocationManager;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.providers.MockLocationProvider;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.providers.PhoneLocationProvider;
public enum GBLocationProviderType {
GPS {
@Override
public GBLocationProvider newInstance(final Context context, final GBLocationListener locationListener) {
return new PhoneLocationProvider(context, locationListener, LocationManager.GPS_PROVIDER);
}
},
NETWORK {
@Override
public GBLocationProvider newInstance(final Context context, final GBLocationListener locationListener) {
return new PhoneLocationProvider(context, locationListener, LocationManager.NETWORK_PROVIDER);
}
},
MOCK {
@Override
public GBLocationProvider newInstance(final Context context, final GBLocationListener locationListener) {
return new MockLocationProvider(context, locationListener);
}
},
;
public abstract GBLocationProvider newInstance(final Context context, final GBLocationListener locationListener);
}

View File

@ -0,0 +1,184 @@
/* Copyright (C) 2022-2024 halemmerich, José Rebelo, LukasEdl, Martin Boonk
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.PendingIntentUtils;
/**
* A static location manager, which keeps track of what providers are currently running. A notification is kept
* while there is at least one provider running.
*/
public class GBLocationService extends BroadcastReceiver {
private static final Logger LOG = LoggerFactory.getLogger(GBLocationService.class);
public static final String ACTION_START = "GBLocationService.START";
public static final String ACTION_STOP = "GBLocationService.STOP";
public static final String ACTION_STOP_ALL = "GBLocationService.STOP_ALL";
public static final String EXTRA_TYPE = "extra_type";
public static final String EXTRA_INTERVAL = "extra_interval";
private final Context context;
private final Map<GBDevice, List<GBLocationProvider>> providersByDevice = new HashMap<>();
public GBLocationService(final Context context) {
this.context = context;
}
@Override
public void onReceive(final Context context, final Intent intent) {
if (intent.getAction() == null) {
LOG.warn("Action is null");
return;
}
final GBDevice device = intent.getParcelableExtra(GBDevice.EXTRA_DEVICE);
switch (intent.getAction()) {
case ACTION_START:
if (device == null) {
LOG.error("Device is null for {}", intent.getAction());
return;
}
final GBLocationProviderType providerType = GBLocationProviderType.valueOf(
intent.hasExtra(EXTRA_TYPE) ? intent.getStringExtra(EXTRA_TYPE) : "GPS"
);
final int updateInterval = intent.getIntExtra(EXTRA_INTERVAL, 1000);
LOG.debug("Starting location provider {} for {}", providerType, device.getAliasOrName());
if (!providersByDevice.containsKey(device)) {
providersByDevice.put(device, new ArrayList<>());
}
updateNotification();
final List<GBLocationProvider> existingProviders = providersByDevice.get(device);
final GBLocationListener locationListener = new GBLocationListener(device);
final GBLocationProvider locationProvider = providerType.newInstance(context, locationListener);
locationProvider.start(updateInterval);
Objects.requireNonNull(existingProviders).add(locationProvider);
return;
case ACTION_STOP:
if (device != null) {
stopDevice(device);
updateNotification();
} else {
stopAll();
}
return;
case ACTION_STOP_ALL:
stopAll();
return;
default:
LOG.warn("Unknown action {}", intent.getAction());
}
}
public void stopDevice(final GBDevice device) {
LOG.debug("Stopping location providers for {}", device.getAliasOrName());
final List<GBLocationProvider> providers = providersByDevice.remove(device);
if (providers != null) {
for (final GBLocationProvider provider : providers) {
provider.stop();
}
}
}
public IntentFilter buildFilter() {
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(ACTION_START);
intentFilter.addAction(ACTION_STOP);
return intentFilter;
}
public void stopAll() {
LOG.info("Stopping location service for all devices");
final List<GBDevice> gbDevices = new ArrayList<>(providersByDevice.keySet());
for (GBDevice d : gbDevices) {
stopDevice(d);
}
updateNotification();
}
public static void start(final Context context,
@NonNull final GBDevice device,
final GBLocationProviderType providerType,
final int updateInterval) {
final Intent intent = new Intent(ACTION_START);
intent.putExtra(GBDevice.EXTRA_DEVICE, device);
intent.putExtra(EXTRA_TYPE, providerType.name());
intent.putExtra(EXTRA_INTERVAL, updateInterval);
LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
}
public static void stop(final Context context, @Nullable final GBDevice device) {
final Intent intent = new Intent(ACTION_STOP);
intent.putExtra(GBDevice.EXTRA_DEVICE, device);
LocalBroadcastManager.getInstance(context).sendBroadcast(intent);
}
private void updateNotification() {
if (!providersByDevice.isEmpty()) {
final Intent notificationIntent = new Intent(context, GBLocationService.class);
notificationIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
final PendingIntent pendingIntent = PendingIntentUtils.getActivity(context, 0, notificationIntent, 0, false);
final NotificationCompat.Builder nb = new NotificationCompat.Builder(context, GB.NOTIFICATION_CHANNEL_ID_GPS)
.setTicker(context.getString(R.string.notification_gps_title))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentTitle(context.getString(R.string.notification_gps_title))
.setContentText(context.getString(R.string.notification_gps_text, providersByDevice.size()))
.setContentIntent(pendingIntent)
.setSmallIcon(R.drawable.ic_gps_location)
.setOngoing(true);
GB.notify(GB.NOTIFICATION_ID_GPS, nb.build(), context);
} else {
GB.removeNotification(GB.NOTIFICATION_ID_GPS, context);
}
}
}

View File

@ -1,22 +0,0 @@
/* Copyright (C) 2022-2024 LukasEdl
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps;
public enum LocationProviderType {
GPS,
NETWORK,
}

View File

@ -1,80 +0,0 @@
/* Copyright (C) 2022-2024 Lukas, LukasEdl
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps;
import android.Manifest;
import android.content.Context;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Looper;
import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/**
* A location provider that uses the phone GPS, using {@link LocationManager}.
*/
public class PhoneNetworkLocationProvider extends AbstractLocationProvider {
private static final Logger LOG = LoggerFactory.getLogger(PhoneNetworkLocationProvider.class);
private static final int INTERVAL_MIN_TIME = 1000;
private static final int INTERVAL_MIN_DISTANCE = 0;
public PhoneNetworkLocationProvider(LocationListener locationListener) {
super(locationListener);
}
@Override
void start(final Context context) {
start(context, INTERVAL_MIN_TIME);
}
@Override
void start(Context context, int interval) {
LOG.info("Starting phone network location provider");
if (!GB.checkPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) && !GB.checkPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)) {
GB.toast("Location permission not granted", Toast.LENGTH_SHORT, GB.ERROR);
return;
}
final LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
locationManager.removeUpdates(getLocationListener());
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
interval,
INTERVAL_MIN_DISTANCE,
getLocationListener(),
Looper.getMainLooper()
);
final Location lastKnownLocation = locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER);
LOG.debug("Last known network location: {}", lastKnownLocation);
}
@Override
void stop(final Context context) {
LOG.info("Stopping phone network location provider");
final LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
locationManager.removeUpdates(getLocationListener());
}
}

View File

@ -14,7 +14,7 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps;
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps.providers;
import android.content.Context;
import android.location.Location;
@ -26,13 +26,14 @@ import android.os.SystemClock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProvider;
import nodomain.freeyourgadget.gadgetbridge.service.devices.pebble.webview.CurrentPosition;
/**
* A mock location provider which keeps updating the location at a constant speed, starting from the
* last known location. Useful for local tests.
*/
public class MockLocationProvider extends AbstractLocationProvider {
public class MockLocationProvider extends GBLocationProvider {
private static final Logger LOG = LoggerFactory.getLogger(MockLocationProvider.class);
private Location previousLocation = new CurrentPosition().getLastKnownLocation();
@ -40,12 +41,12 @@ public class MockLocationProvider extends AbstractLocationProvider {
/**
* Interval between location updates, in milliseconds.
*/
private final int interval = 1000;
private static final int DEFAULT_INTERVAL = 1000;
/**
* Difference between location updates, in degrees.
*/
private final float coordDiff = 0.0002f;
private static final float COORD_DIFF = 0.0002f;
/**
* Whether the handler is running.
@ -54,50 +55,40 @@ public class MockLocationProvider extends AbstractLocationProvider {
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable locationUpdateRunnable = new Runnable() {
@Override
public void run() {
if (!running) {
return;
}
final Location newLocation = new Location(previousLocation);
newLocation.setLatitude(previousLocation.getLatitude() + coordDiff);
newLocation.setTime(System.currentTimeMillis());
newLocation.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos());
getLocationListener().onLocationChanged(newLocation);
previousLocation = newLocation;
if (running) {
handler.postDelayed(this, interval);
}
}
};
public MockLocationProvider(LocationListener locationListener) {
super(locationListener);
public MockLocationProvider(final Context context, final LocationListener locationListener) {
super(context, locationListener);
}
@Override
void start(final Context context) {
public void start(final int interval) {
LOG.info("Starting mock location provider");
running = true;
handler.postDelayed(locationUpdateRunnable, interval);
handler.postDelayed(new Runnable() {
@Override
public void run() {
if (!running) {
return;
}
final Location newLocation = new Location(previousLocation);
newLocation.setLatitude(previousLocation.getLatitude() + COORD_DIFF);
newLocation.setTime(System.currentTimeMillis());
newLocation.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos());
getLocationListener().onLocationChanged(newLocation);
previousLocation = newLocation;
if (running) {
handler.postDelayed(this, interval);
}
}
}, interval > 0 ? interval : DEFAULT_INTERVAL);
}
@Override
void start(final Context context, int minInterval) {
LOG.info("Starting mock location provider");
running = true;
handler.postDelayed(locationUpdateRunnable, interval);
}
@Override
void stop(final Context context) {
public void stop() {
LOG.info("Stopping mock location provider");
running = false;

View File

@ -14,7 +14,7 @@
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps;
package nodomain.freeyourgadget.gadgetbridge.externalevents.gps.providers;
import android.Manifest;
import android.content.Context;
@ -27,43 +27,38 @@ import android.widget.Toast;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProvider;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
/**
* A location provider that uses the phone GPS, using {@link LocationManager}.
*/
public class PhoneGpsLocationProvider extends AbstractLocationProvider {
private static final Logger LOG = LoggerFactory.getLogger(PhoneGpsLocationProvider.class);
public class PhoneLocationProvider extends GBLocationProvider {
private static final Logger LOG = LoggerFactory.getLogger(PhoneLocationProvider.class);
private final String provider;
private static final int INTERVAL_MIN_TIME = 1000;
private static final int INTERVAL_MIN_DISTANCE = 0;
public PhoneGpsLocationProvider(LocationListener locationListener) {
super(locationListener);
}
public PhoneGpsLocationProvider(LocationListener locationListener, int intervalTime) {
super(locationListener);
public PhoneLocationProvider(final Context context, final LocationListener locationListener, final String provider) {
super(context, locationListener);
this.provider = provider;
}
@Override
void start(final Context context) {
start(context, INTERVAL_MIN_TIME);
}
@Override
void start(Context context, int interval) {
public void start(final int interval) {
LOG.info("Starting phone gps location provider");
if (!GB.checkPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) && !GB.checkPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)) {
if (!GB.checkPermission(getContext(), Manifest.permission.ACCESS_FINE_LOCATION) && !GB.checkPermission(getContext(), Manifest.permission.ACCESS_COARSE_LOCATION)) {
GB.toast("Location permission not granted", Toast.LENGTH_SHORT, GB.ERROR);
return;
}
final LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
final LocationManager locationManager = (LocationManager) getContext().getSystemService(Context.LOCATION_SERVICE);
locationManager.removeUpdates(getLocationListener());
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
interval,
provider,
interval > 0 ? interval : 1_000,
INTERVAL_MIN_DISTANCE,
getLocationListener(),
Looper.getMainLooper()
@ -74,10 +69,10 @@ public class PhoneGpsLocationProvider extends AbstractLocationProvider {
}
@Override
void stop(final Context context) {
public void stop() {
LOG.info("Stopping phone gps location provider");
final LocationManager locationManager = (LocationManager) context.getSystemService(Context.LOCATION_SERVICE);
final LocationManager locationManager = (LocationManager) getContext().getSystemService(Context.LOCATION_SERVICE);
locationManager.removeUpdates(getLocationListener());
}
}

View File

@ -0,0 +1,16 @@
package nodomain.freeyourgadget.gadgetbridge.externalevents.sleepasandroid;
public class SleepAsAndroidAction {
public static final String START_TRACKING = "com.urbandroid.sleep.watch.START_TRACKING";
public static final String STOP_TRACKING = "com.urbandroid.sleep.watch.STOP_TRACKING";
public static final String SET_PAUSE = "com.urbandroid.sleep.watch.SET_PAUSE";
public static final String SET_SUSPENDED = "com.urbandroid.sleep.watch.SET_SUSPENDED";
public static final String SET_BATCH_SIZE = "com.urbandroid.sleep.watch.SET_BATCH_SIZE";
public static final String START_ALARM = "com.urbandroid.sleep.watch.START_ALARM";
public static final String STOP_ALARM = "com.urbandroid.sleep.watch.STOP_ALARM";
public static final String UPDATE_ALARM = "com.urbandroid.sleep.watch.UPDATE_ALARM";
public static final String SHOW_NOTIFICATION = "com.urbandroid.sleep.watch.SHOW_NOTIFICATION";
public static final String HINT = "com.urbandroid.sleep.watch.HINT";
public static final String CHECK_CONNECTED = "com.urbandroid.sleep.watch.CHECK_CONNECTED";
public static final String CONFIRM_CONNECTED = "com.urbandroid.sleep.watch.CONFIRM_CONNECTED";
}

View File

@ -0,0 +1,23 @@
package nodomain.freeyourgadget.gadgetbridge.externalevents.sleepasandroid;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
public class SleepAsAndroidReceiver extends BroadcastReceiver {
private static final Logger LOG = LoggerFactory.getLogger(SleepAsAndroidReceiver.class);
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (GBApplication.getPrefs().getBoolean("pref_key_sleepasandroid_enable", false)) {
GBApplication.deviceService().onSleepAsAndroidAction(action, intent.getExtras());
}
}
}

View File

@ -26,7 +26,7 @@ import android.content.Intent;
import android.database.Cursor;
import android.location.Location;
import android.net.Uri;
import android.os.Parcelable;
import android.os.Bundle;
import android.provider.ContactsContract;
import java.util.ArrayList;
@ -451,6 +451,7 @@ public class GBDeviceService implements DeviceService {
.putExtra(EXTRA_CALENDAREVENT_TIMESTAMP, calendarEventSpec.timestamp)
.putExtra(EXTRA_CALENDAREVENT_DURATION, calendarEventSpec.durationInSeconds)
.putExtra(EXTRA_CALENDAREVENT_ALLDAY, calendarEventSpec.allDay)
.putExtra(EXTRA_CALENDAREVENT_REMINDERS, calendarEventSpec.reminders)
.putExtra(EXTRA_CALENDAREVENT_TITLE, calendarEventSpec.title)
.putExtra(EXTRA_CALENDAREVENT_DESCRIPTION, calendarEventSpec.description)
.putExtra(EXTRA_CALENDAREVENT_CALNAME, calendarEventSpec.calName)
@ -547,4 +548,14 @@ public class GBDeviceService implements DeviceService {
intent.putExtra(EXTRA_GPS_LOCATION, location);
invokeService(intent);
}
@Override
public void onSleepAsAndroidAction(String action, Bundle extras) {
Intent intent = createIntent().setAction(ACTION_SLEEP_AS_ANDROID);
intent.putExtra(EXTRA_SLEEP_AS_ANDROID_ACTION, action);
if (extras != null) {
intent.putExtras(extras);
}
invokeService(intent);
}
}

View File

@ -17,6 +17,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.model;
import java.util.ArrayList;
public class CalendarEventSpec {
public static final byte TYPE_UNKNOWN = 0;
public static final byte TYPE_SUNRISE = 1;
@ -32,4 +34,5 @@ public class CalendarEventSpec {
public String calName;
public int color;
public boolean allDay;
public ArrayList<Long> reminders; // unix epoch millis
}

View File

@ -79,6 +79,9 @@ public interface DeviceService extends EventHandler {
String ACTION_SET_GPS_LOCATION = PREFIX + ".action.set_gps_location";
String ACTION_SET_LED_COLOR = PREFIX + ".action.set_led_color";
String ACTION_POWER_OFF = PREFIX + ".action.power_off";
String ACTION_SLEEP_AS_ANDROID = ".action.sleep_as_android";
String EXTRA_SLEEP_AS_ANDROID_ACTION = "sleepasandroid_action";
String EXTRA_NOTIFICATION_BODY = "notification_body";
String EXTRA_NOTIFICATION_FLAGS = "notification_flags";
String EXTRA_NOTIFICATION_ID = "notification_id";
@ -159,6 +162,7 @@ public interface DeviceService extends EventHandler {
String EXTRA_CALENDAREVENT_TIMESTAMP = "calendarevent_timestamp";
String EXTRA_CALENDAREVENT_DURATION = "calendarevent_duration";
String EXTRA_CALENDAREVENT_ALLDAY = "calendarevent_allday";
String EXTRA_CALENDAREVENT_REMINDERS = "calendarevent_reminders";
String EXTRA_CALENDAREVENT_TITLE = "calendarevent_title";
String EXTRA_CALENDAREVENT_DESCRIPTION = "calendarevent_description";
String EXTRA_CALENDAREVENT_LOCATION = "calendarevent_location";

View File

@ -49,6 +49,13 @@ import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBuds2ProDe
import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsLiveDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.galaxy_buds.GalaxyBudsProDeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.forerunner245.GarminForerunner245Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2s.GarminInstinct2SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.instinct2solar.GarminInstinct2SolarCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.venu3.GarminVenu3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive4s.GarminVivoActive4SCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivoactive5.GarminVivoActive5Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.vivomove.GarminVivomoveStyleCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.EXRIZUK8Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.HPlusCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.hplus.MakibesF68Coordinator;
@ -136,9 +143,9 @@ import nodomain.freeyourgadget.gadgetbridge.devices.lenovo.watchxplus.WatchXPlus
import nodomain.freeyourgadget.gadgetbridge.devices.liveview.LiveviewCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.makibeshr3.MakibesHR3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaMhoC303Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaLywsd02Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaLywsd03Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.mijia_lywsd.MijiaMhoC303Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.miscale2.MiScale2DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.no1f1.No1F1Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.nothing.Ear1Coordinator;
@ -160,11 +167,11 @@ import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWF1000XM4Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWF1000XM5Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWFSP800NCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWISP600NCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM4Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWH1000XM5Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.headphones.coordinators.SonyWISP600NCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sony.wena3.SonyWena3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.sonyswr12.SonySWR12DeviceCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.supercars.SuperCarsCoordinator;
@ -181,6 +188,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband7pro.MiBand7Pro
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8.MiBand8Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8active.MiBand8ActiveCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miband8pro.MiBand8ProCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatch.MiWatchLiteCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatchcolorsport.MiWatchColorSportCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartband2.RedmiSmartBand2Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmismartbandpro.RedmiSmartBandProCoordinator;
@ -194,7 +202,6 @@ import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs1pro.XiaomiWatc
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.watchs3.XiaomiWatchS3Coordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xwatch.XWatchCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.zetime.ZeTimeCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.miwatch.MiWatchLiteCoordinator;
import nodomain.freeyourgadget.gadgetbridge.devices.xiaomi.redmiwatch3active.RedmiWatch3ActiveCoordinator;
/**
@ -321,6 +328,13 @@ public enum DeviceType {
ITAG(ITagCoordinator.class),
NUTMINI(NutCoordinator.class),
VIVOMOVE_HR(VivomoveHrCoordinator.class),
GARMIN_FORERUNNER_245(GarminForerunner245Coordinator.class),
GARMIN_INSTINCT_2S(GarminInstinct2SCoordinator.class),
GARMIN_INSTINCT_2_SOLAR(GarminInstinct2SolarCoordinator.class),
GARMIN_VIVOMOVE_STYLE(GarminVivomoveStyleCoordinator.class),
GARMIN_VENU_3(GarminVenu3Coordinator.class),
GARMIN_VIVOACTIVE_4S(GarminVivoActive4SCoordinator.class),
GARMIN_VIVOACTIVE_5(GarminVivoActive5Coordinator.class),
VIBRATISSIMO(VibratissimoCoordinator.class),
SONY_SWR12(SonySWR12DeviceCoordinator.class),
LIVEVIEW(LiveviewCoordinator.class),

View File

@ -32,6 +32,7 @@ import android.graphics.BitmapFactory;
import android.location.Location;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.telephony.SmsManager;
import android.text.TextUtils;
@ -744,7 +745,7 @@ public abstract class AbstractDeviceSupport implements DeviceSupport {
LocalBroadcastManager.getInstance(context).sendBroadcast(messageIntent);
}
protected Prefs getDevicePrefs() {
public Prefs getDevicePrefs() {
return new Prefs(GBApplication.getDeviceSpecificSharedPrefs(gbDevice.getAddress()));
}
@ -1181,4 +1182,9 @@ public abstract class AbstractDeviceSupport implements DeviceSupport {
public void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec) {
}
@Override
public void onSleepAsAndroidAction(String action, Bundle extras) {
}
}

View File

@ -82,6 +82,8 @@ import nodomain.freeyourgadget.gadgetbridge.externalevents.SMSReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.SilentModeReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TimeChangeReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.TinyWeatherForecastGermanyReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService;
import nodomain.freeyourgadget.gadgetbridge.externalevents.sleepasandroid.SleepAsAndroidReceiver;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceService;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
@ -138,13 +140,15 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
}
}
private class FeatureSet{
private static class FeatureSet {
private boolean supportsWeather = false;
private boolean supportsActivityDataFetching = false;
private boolean supportsCalendarEvents = false;
private boolean supportsMusicInfo = false;
private boolean supportsNavigation = false;
private boolean supportsSleepAsAndroid = false;
public boolean supportsWeather() {
return supportsWeather;
}
@ -185,6 +189,12 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
this.supportsNavigation = supportsNavigation;
}
public boolean supportsSleepAsAndroid() { return supportsSleepAsAndroid; }
public void setSupportsSleepAsAndroid(boolean supportsSleepAsAndroid) {
this.supportsSleepAsAndroid = supportsSleepAsAndroid;
}
public void logicalOr(DeviceCoordinator operand){
if(operand.supportsCalendarEvents()){
setSupportsCalendarEvents(true);
@ -201,6 +211,9 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
if(operand.supportsNavigation()){
setSupportsNavigation(true);
}
if (operand.supportsSleepAsAndroid()) {
setSupportsSleepAsAndroid(true);
}
}
}
@ -243,7 +256,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
private AutoConnectIntervalReceiver mAutoConnectInvervalReceiver = null;
private AlarmReceiver mAlarmReceiver = null;
private List<CalendarReceiver> mCalendarReceiver = new ArrayList<>();
private final List<CalendarReceiver> mCalendarReceiver = new ArrayList<>();
private CMWeatherReceiver mCMWeatherReceiver = null;
private LineageOsWeatherReceiver mLineageOsWeatherReceiver = null;
private TinyWeatherForecastGermanyReceiver mTinyWeatherForecastGermanyReceiver = null;
@ -251,9 +264,12 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
private OmniJawsObserver mOmniJawsObserver = null;
private final DeviceSettingsReceiver deviceSettingsReceiver = new DeviceSettingsReceiver();
private final IntentApiReceiver intentApiReceiver = new IntentApiReceiver();
private GBLocationService locationService = null;
private OsmandEventReceiver mOsmandAidlHelper = null;
private SleepAsAndroidReceiver mSleepAsAndroidReceiver = null;
private HashMap<String, Long> deviceLastScannedTimestamps = new HashMap<>();
private final String[] mMusicActions = {
@ -845,6 +861,7 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
calendarEventSpec.timestamp = intent.getIntExtra(EXTRA_CALENDAREVENT_TIMESTAMP, -1);
calendarEventSpec.durationInSeconds = intent.getIntExtra(EXTRA_CALENDAREVENT_DURATION, -1);
calendarEventSpec.allDay = intent.getBooleanExtra(EXTRA_CALENDAREVENT_ALLDAY, false);
calendarEventSpec.reminders = (ArrayList<Long>) intent.getSerializableExtra(EXTRA_CALENDAREVENT_REMINDERS);
calendarEventSpec.title = intent.getStringExtra(EXTRA_CALENDAREVENT_TITLE);
calendarEventSpec.description = intent.getStringExtra(EXTRA_CALENDAREVENT_DESCRIPTION);
calendarEventSpec.location = intent.getStringExtra(EXTRA_CALENDAREVENT_LOCATION);
@ -1069,6 +1086,13 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
final Location location = intent.getParcelableExtra(EXTRA_GPS_LOCATION);
deviceSupport.onSetGpsLocation(location);
break;
case ACTION_SLEEP_AS_ANDROID:
if(device.getDeviceCoordinator().supportsSleepAsAndroid() && GBApplication.getPrefs().getString("sleepasandroid_device", new String()).equals(device.getAddress()))
{
final String sleepAsAndroidAction = intent.getStringExtra(EXTRA_SLEEP_AS_ANDROID_ACTION);
deviceSupport.onSleepAsAndroidAction(sleepAsAndroidAction, intent.getExtras());
}
break;
}
}
@ -1320,6 +1344,11 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
registerReceiver(mSilentModeReceiver, filter);
}
if (locationService == null) {
locationService = new GBLocationService(this);
LocalBroadcastManager.getInstance(this).registerReceiver(locationService, locationService.buildFilter());
}
if (mOsmandAidlHelper == null && features.supportsNavigation()) {
mOsmandAidlHelper = new OsmandEventReceiver(this.getApplication());
}
@ -1355,6 +1384,14 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
}
}
if (features.supportsSleepAsAndroid())
{
if (mSleepAsAndroidReceiver == null) {
mSleepAsAndroidReceiver = new SleepAsAndroidReceiver();
registerReceiver(mSleepAsAndroidReceiver, new IntentFilter());
}
}
if (GBApplication.getPrefs().getBoolean("auto_fetch_enabled", false) &&
features.supportsActivityDataFetching() && mGBAutoFetchReceiver == null) {
mGBAutoFetchReceiver = new GBAutoFetchReceiver();
@ -1394,6 +1431,11 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
unregisterReceiver(mSilentModeReceiver);
mSilentModeReceiver = null;
}
if (locationService != null) {
LocalBroadcastManager.getInstance(this).unregisterReceiver(locationService);
locationService.stopAll();
locationService = null;
}
if (mCMWeatherReceiver != null) {
unregisterReceiver(mCMWeatherReceiver);
mCMWeatherReceiver = null;
@ -1422,6 +1464,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere
unregisterReceiver(mGenericWeatherReceiver);
mGenericWeatherReceiver = null;
}
if (mSleepAsAndroidReceiver != null) {
unregisterReceiver(mSleepAsAndroidReceiver);
mSleepAsAndroidReceiver = null;
}
}
}

View File

@ -22,12 +22,12 @@ import android.bluetooth.BluetoothAdapter;
import android.content.Context;
import android.location.Location;
import android.net.Uri;
import android.os.Bundle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.UUID;
@ -512,4 +512,12 @@ public class ServiceDeviceSupport implements DeviceSupport {
}
delegate.onSetGpsLocation(location);
}
@Override
public void onSleepAsAndroidAction(String action, Bundle extras) {
if (checkBusy("sleep as android")) {
return;
}
delegate.onSleepAsAndroidAction(action, extras);
}
}

View File

@ -0,0 +1,573 @@
package nodomain.freeyourgadget.gadgetbridge.service;
import android.content.Context;
import android.content.Intent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.devices.SleepAsAndroidFeature;
import nodomain.freeyourgadget.gadgetbridge.externalevents.sleepasandroid.SleepAsAndroidAction;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class SleepAsAndroidSender {
private final Logger LOG = LoggerFactory.getLogger(SleepAsAndroidSender.class);
private final String PACKAGE_SLEEP_AS_ANDROID = "com.urbandroid.sleep";
private final String ACTION_EXTRA_DATA_UPDATE = "com.urbandroid.sleep.ACTION_EXTRA_DATA_UPDATE";
private final String ACTION_MOVEMENT_DATA_UPDATE = "com.urbandroid.sleep.watch.DATA_UPDATE";
private final String ACTION_HEART_RATE_DATA_UPDATE = "com.urbandroid.sleep.watch.HR_DATA_UPDATE";
private final String ACTION_RESUME_FROM_WATCH = "com.urbandroid.sleep.watch.RESUME_FROM_WATCH";
private final String ACTION_PAUSE_FROM_WATCH = "com.urbandroid.sleep.watch.PAUSE_FROM_WATCH";
private final String ACTION_SNOOZE_FROM_WATCH = "com.urbandroid.sleep.watch.SNOOZE_FROM_WATCH";
private final String ACTION_DISMISS_FROM_WATCH = "com.urbandroid.sleep.watch.DISMISS_FROM_WATCH";
private final String MAX_RAW_DATA = "MAX_RAW_DATA";
private final String DATA = "DATA";
private final String EXTRA_DATA_HR = "com.urbandroid.sleep.EXTRA_DATA_HR";
private final String EXTRA_DATA_RR = "com.urbandroid.sleep.EXTRA_DATA_RR";
private final String EXTRA_DATA_SPO2 = "com.urbandroid.sleep.EXTRA_DATA_SPO2";
private final String EXTRA_DATA_SDNN = "com.urbandroid.sleep.EXTRA_DATA_SDNN";
private final String EXTRA_DATA_TIMESTAMP = "com.urbandroid.sleep.EXTRA_DATA_TIMESTAMP";
private final String EXTRA_DATA_FRAMERATE = "com.urbandroid.sleep.EXTRA_DATA_FRAMERATE";
private final String EXTRA_DATA_BATCH = "com.urbandroid.sleep.EXTRA_DATA_BATCH";
private GBDevice device;
private boolean trackingOngoing = false;
private boolean trackingPaused = false;
private ScheduledExecutorService trackingPauseScheduler;
private long batchSize = 1;
private long lastRawDataMs = 0;
private float maxRawData = 0;
private long lastHrDataMs = 0;
private ArrayList<Float> hrData = new ArrayList<>();
private ArrayList<Float> accData = new ArrayList<>();
private ScheduledExecutorService accDataScheduler;
private Set<SleepAsAndroidFeature> features;
public SleepAsAndroidSender(GBDevice gbDevice) {
this.device = gbDevice;
this.features = gbDevice.getDeviceCoordinator().getSleepAsAndroidFeatures();
}
/**
* Check if a SleepAsAndroid feature is enabled
*
* @param feature the feature
* @return true if the feature is enabled
*/
public boolean hasFeature(SleepAsAndroidFeature feature) {
return this.features.contains(feature);
}
/**
* Check if a SleepAsAndroid feature is enabled
* @param feature
* @return
*/
public boolean isFeatureEnabled(SleepAsAndroidFeature feature) {
boolean enabled = isSleepAsAndroidEnabled();
if (enabled) {
switch (feature) {
case ACCELEROMETER:
enabled = GBApplication.getPrefs().getBoolean("pref_key_sleepasandroid_feat_movement", false);
break;
case HEART_RATE:
enabled = GBApplication.getPrefs().getBoolean("pref_key_sleepasandroid_feat_hr", false);
break;
case SPO2:
enabled = GBApplication.getPrefs().getBoolean("pref_key_sleepasandroid_feat_spo2", false);
break;
case OXIMETRY:
enabled = GBApplication.getPrefs().getBoolean("pref_key_sleepasandroid_feat_oximetry", false);
break;
case NOTIFICATIONS:
enabled = GBApplication.getPrefs().getBoolean("pref_key_sleepasandroid_feat_notifications", false);
break;
case ALARMS:
enabled = GBApplication.getPrefs().getBoolean("pref_key_sleepasandroid_feat_alarms", false);
break;
default:
break;
}
}
return enabled;
}
/**
* Get all enabled features
*
* @return all enabled features
*/
public Set<SleepAsAndroidFeature> getFeatures() {
return features;
}
/**
* Start tracking
*/
public void startTracking() {
if (!isDeviceDefault()) return;
stopTracking();
accDataScheduler = Executors.newSingleThreadScheduledExecutor();
accDataScheduler.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
aggregateAndSendAccelData();
}
}, 9999, 9999, TimeUnit.MILLISECONDS);
lastRawDataMs = System.currentTimeMillis();
lastHrDataMs = System.currentTimeMillis();
this.trackingOngoing = true;
}
/**
* Stop tracking
*/
public void stopTracking() {
if (!isDeviceDefault() || !trackingOngoing) return;
if (accDataScheduler != null) {
accDataScheduler.shutdownNow();
accDataScheduler = null;
}
this.trackingOngoing = false;
this.hrData = new ArrayList<>();
this.accData = new ArrayList<>();
this.lastHrDataMs = 0;
this.lastRawDataMs = 0;
}
/**
* Pause tracking
*
* @param timeout the timeout in milliseconds before resuming
*/
public void pauseTracking(long timeout) {
if (!isDeviceDefault() || !trackingOngoing) return;
if (timeout <= 0) {
resumeTracking();
return;
}
pauseTracking();
trackingPauseScheduler = setPauseTracking(timeout);
}
/**
* Same as {@link #pauseTracking(long)} but pausing and resuming is controlled by a toggle
*
* @param suspended true if the tracking should be paused, false if should be resumed
*/
public void pauseTracking(boolean suspended) {
if (!isDeviceDefault() || !trackingOngoing) return;
trackingPaused = suspended;
if (!trackingPaused) {
resumeTracking();
return;
}
}
/**
* Set a scheduler to resume tracking
*
* @param delay the delay
* @return the scheduler
*/
private ScheduledExecutorService setPauseTracking(long delay) {
trackingPaused = true;
trackingPauseScheduler = Executors.newSingleThreadScheduledExecutor();
trackingPauseScheduler.schedule(new Runnable() {
@Override
public void run() {
resumeTracking();
}
}, delay, TimeUnit.MILLISECONDS);
return trackingPauseScheduler;
}
/**
* Pause tracking
*/
private void pauseTracking() {
if (!isDeviceDefault() && trackingPaused) return;
trackingPaused = true;
stopTracking();
}
/**
* Resume tracking
*/
private void resumeTracking() {
if (!isDeviceDefault() && !trackingPaused) return;
if (trackingPauseScheduler != null) {
trackingPauseScheduler.shutdownNow();
trackingPauseScheduler = null;
}
trackingPaused = false;
startTracking();
}
/**
* Set the batch size
*
* @param batchSize the batch size
*/
public void setBatchSize(long batchSize) {
if (!isDeviceDefault()) return;
LOG.debug("Setting batch size to " + batchSize);
this.batchSize = batchSize;
}
/**
* Confirm that the device is connected
*/
public void confirmConnected() {
if (!isDeviceDefault()) return;
LOG.debug("Confirming connected");
Intent intent = new Intent(SleepAsAndroidAction.CONFIRM_CONNECTED);
broadcastToSleepAsAndroid(intent);
}
/**
* On accelerometer changed
*
* @param x the x value
* @param y the y value
* @param z the z value
*/
public void onAccelChanged(float x, float y, float z) {
if (!isDeviceDefault() || !isFeatureEnabled(SleepAsAndroidFeature.ACCELEROMETER) || !hasFeature(SleepAsAndroidFeature.ACCELEROMETER) || !trackingOngoing)
return;
if (trackingPaused)
return;
updateMaxRawData(x, y, z);
}
/**
* Aggregate and send the acceleration data
*/
private synchronized void aggregateAndSendAccelData() {
if (!trackingOngoing || trackingPaused) return;
if (maxRawData > 0) {
accData.add(maxRawData);
maxRawData = 0;
if (accData.size() == batchSize) {
sendAccelData();
}
}
}
/**
* Send the acceleration data
*/
private void sendAccelData() {
LOG.debug("Sending movement data: " + this.accData + " batch size: " + batchSize + " array size: " + accData.size());
Intent intent = new Intent(ACTION_MOVEMENT_DATA_UPDATE);
intent.putExtra(MAX_RAW_DATA, convertToFloatArray(this.accData));
accData.clear();
broadcastToSleepAsAndroid(intent);
}
/**
* Update the max raw data
* @param x the x value
* @param y the y value
* @param z the z value
*/
private void updateMaxRawData(float x, float y, float z) {
float maxRaw = calculateAccelerationMagnitude(x, y, z);
if (maxRaw > maxRawData) {
maxRawData = maxRaw;
}
}
/**
* Calculate the acceleration magnitude
* @param x the x value
* @param y the y value
* @param z the z value
* @return
*/
protected float calculateAccelerationMagnitude(float x, float y, float z) {
double sqrt = Math.sqrt((x * x) + (y * y) + (z * z));
return (float)sqrt;
}
/**
* On heart rate changed
*
* @param hr the heart rate
* @param sendDelay the send delay in ms. If 0 the data will be send right away. Anything bigger will gather all the data then send it all after the specified interval
*/
public void onHrChanged(float hr, long sendDelay) {
if (!isDeviceDefault() || !isFeatureEnabled(SleepAsAndroidFeature.HEART_RATE) || !hasFeature(SleepAsAndroidFeature.HEART_RATE) || !trackingOngoing)
return;
if (trackingPaused) return;
updateLastHrData(hr);
if (lastHrDataMs == 0) {
lastHrDataMs = System.currentTimeMillis();
}
long ms = System.currentTimeMillis();
if (ms - lastHrDataMs >= sendDelay) {
lastHrDataMs = ms;
sendHrData();
}
}
/**
* Send the heart rate data
*/
private synchronized void sendHrData() {
LOG.debug("Sending heart rate data: " + this.hrData);
Intent intent = new Intent(ACTION_HEART_RATE_DATA_UPDATE);
intent.putExtra(DATA, convertToFloatArray(this.hrData));
broadcastToSleepAsAndroid(intent);
this.hrData.clear();
}
/**
* Update the last heart rate data
* @param hr the heart rate
*/
private void updateLastHrData(float hr) {
this.hrData.add(hr);
}
/**
* This is a generic intent to carry data from various sensors.
* See Sleep As Android documentation for parameters values.
* <a href="https://docs.sleep.urbandroid.org/devs/wearable_api.html#send-various-body-sensors-data-hr-rr-spo2-sdnn">...</a>
*/
public synchronized void sendExtra(Float hr, Long extraDataTimestamp, Long extraDataFramerate, ArrayList<Float> extraDataRR, ArrayList<Float> spo2Batch, Float sdnn, ArrayList<Float> extraDataBatch) {
if (!isDeviceDefault() || !trackingOngoing) return;
if (trackingPaused) return;
Context context = GBApplication.getContext();
Intent intent = new Intent(ACTION_EXTRA_DATA_UPDATE);
// Heart Rate
if (hr != null && (hasFeature(SleepAsAndroidFeature.HEART_RATE) && isFeatureEnabled(SleepAsAndroidFeature.HEART_RATE))) {
intent.putExtra(EXTRA_DATA_HR, hr);
}
// SpO2
if (spo2Batch != null && (hasFeature(SleepAsAndroidFeature.SPO2) && isFeatureEnabled(SleepAsAndroidFeature.SPO2))) {
intent.putExtra(EXTRA_DATA_SPO2, true);
intent.putExtra(EXTRA_DATA_BATCH, convertToFloatArray(spo2Batch));
}
// SDNN
if (sdnn != null && (hasFeature(SleepAsAndroidFeature.HEART_RATE) && isFeatureEnabled(SleepAsAndroidFeature.HEART_RATE))) {
intent.putExtra(EXTRA_DATA_SDNN, sdnn);
}
// RR Intervals
if (extraDataRR != null && (hasFeature(SleepAsAndroidFeature.HEART_RATE) && isFeatureEnabled(SleepAsAndroidFeature.HEART_RATE))) {
intent.putExtra(EXTRA_DATA_RR, convertToFloatArray(extraDataRR));
}
if (extraDataBatch != null) {
if (isDeviceDefault()) {
for (int i = 0; i < extraDataBatch.size(); i++) {
extraDataBatch.set(i, 0.0f);
}
}
intent.putExtra(EXTRA_DATA_BATCH, convertToFloatArray(extraDataBatch));
}
if (extraDataTimestamp != null) {
intent.putExtra(EXTRA_DATA_TIMESTAMP, extraDataTimestamp);
}
if (extraDataFramerate != null) {
intent.putExtra(EXTRA_DATA_FRAMERATE, extraDataFramerate);
}
context.sendBroadcast(intent);
}
/**
* This is a generic intent to carry data from various sensors.
* See Sleep As Android documentation for parameters values.
* <a href="https://docs.sleep.urbandroid.org/devs/wearable_api.html#send-various-body-sensors-data-hr-rr-spo2-sdnn">...</a>
*/
public void sendExtra(Float hr, Float extraDataRR, Float spo2, Float sdnn, Long sdnnTimestamp) {
if (!isDeviceDefault() || !trackingOngoing) return;
if (trackingPaused) return;
Intent intent = new Intent(ACTION_EXTRA_DATA_UPDATE);
// Heart Rate
if (hr != null && (hasFeature(SleepAsAndroidFeature.HEART_RATE) && isFeatureEnabled(SleepAsAndroidFeature.HEART_RATE))) {
intent.putExtra(EXTRA_DATA_HR, hr);
}
// SpO2
if (spo2 != null && (hasFeature(SleepAsAndroidFeature.SPO2) && isFeatureEnabled(SleepAsAndroidFeature.SPO2))) {
intent.putExtra(EXTRA_DATA_SPO2, spo2);
}
// SDNN
if (sdnn != null && (hasFeature(SleepAsAndroidFeature.HEART_RATE) && isFeatureEnabled(SleepAsAndroidFeature.HEART_RATE))) {
intent.putExtra(EXTRA_DATA_SDNN, sdnn);
}
if (extraDataRR != null && (hasFeature(SleepAsAndroidFeature.HEART_RATE) && isFeatureEnabled(SleepAsAndroidFeature.HEART_RATE))) {
intent.putExtra(EXTRA_DATA_RR, extraDataRR);
}
if (sdnnTimestamp != null) {
intent.putExtra(EXTRA_DATA_TIMESTAMP, sdnnTimestamp);
}
broadcastToSleepAsAndroid(intent);
}
/**
* Adds 5 minutes of tracking pause time
*/
public void sendPauseTracking() {
if (!isDeviceDefault() || !trackingOngoing) return;
LOG.debug("Sending pause");
Intent intent = new Intent(ACTION_PAUSE_FROM_WATCH);
broadcastToSleepAsAndroid(intent);
}
/**
* Resume tracking
*/
public void sendResumeTracking() {
if (!isDeviceDefault() || !trackingOngoing) return;
LOG.debug("Sending resume");
Intent intent = new Intent(ACTION_RESUME_FROM_WATCH);
broadcastToSleepAsAndroid(intent);
}
/**
* Snooze the current alarm
*/
public void sendSnoozeAlarm() {
if (!isDeviceDefault()) return;
if (!hasFeature(SleepAsAndroidFeature.ALARMS) || !isFeatureEnabled(SleepAsAndroidFeature.ALARMS)) return;
LOG.debug("Sending snooze");
Intent intent = new Intent(ACTION_SNOOZE_FROM_WATCH);
broadcastToSleepAsAndroid(intent);
}
/**
* Dismiss the current alarm
*/
public void sendDismissAlarm() {
if (!isDeviceDefault()) return;
if (!hasFeature(SleepAsAndroidFeature.ALARMS) || !isFeatureEnabled(SleepAsAndroidFeature.ALARMS)) return;
LOG.debug("Sending dismiss");
Intent intent = new Intent(ACTION_DISMISS_FROM_WATCH);
broadcastToSleepAsAndroid(intent);
}
private void broadcastToSleepAsAndroid(Intent intent) {
if (!isDeviceDefault()) return;
intent.setPackage(PACKAGE_SLEEP_AS_ANDROID);
GBApplication.getContext().sendBroadcast(intent);
}
/**
* Check if the device is set as default provider for Sleep As Android
*
* @return true if the device is set as default
*/
public boolean isDeviceDefault() {
if (device == null || !device.isInitialized()) return false;
if (isSleepAsAndroidEnabled()) {
return device.getAddress().equals(GBApplication.getPrefs().getString("sleepasandroid_device", ""));
}
return false;
}
public boolean isSleepAsAndroidEnabled() {
return GBApplication.getPrefs().getBoolean("pref_key_sleepasandroid_enable", false);
}
/**
* Validate if the device is allowed to receive a specific action
* @param action the action send my the broadcast receiver
*/
public void validateAction(String action) {
if (isDeviceDefault()) {
switch (action) {
case SleepAsAndroidAction.HINT:
case SleepAsAndroidAction.SHOW_NOTIFICATION:
if (!hasFeature(SleepAsAndroidFeature.NOTIFICATIONS) || !isFeatureEnabled(SleepAsAndroidFeature.NOTIFICATIONS)) {
throw new UnsupportedOperationException("Action not valid");
}
break;
case SleepAsAndroidAction.START_ALARM:
case SleepAsAndroidAction.STOP_ALARM:
case SleepAsAndroidAction.UPDATE_ALARM:
if (!hasFeature(SleepAsAndroidFeature.ALARMS) || !isFeatureEnabled(SleepAsAndroidFeature.ALARMS)) {
throw new UnsupportedOperationException("Action not valid");
}
break;
case SleepAsAndroidAction.START_TRACKING:
case SleepAsAndroidAction.STOP_TRACKING:
case SleepAsAndroidAction.SET_BATCH_SIZE:
if (!hasFeature(SleepAsAndroidFeature.ACCELEROMETER) || !isFeatureEnabled(SleepAsAndroidFeature.ACCELEROMETER)) {
throw new UnsupportedOperationException("Action not valid");
}
break;
default:
break;
}
} else {
throw new UnsupportedOperationException("Action not valid");
}
}
/**
* Convert an ArrayList to a float array
*
* @param list the ArrayList
* @return the float array
*/
private float[] convertToFloatArray(ArrayList<Float> list) {
float[] result = new float[list.size()];
int i = 0;
for (float f : list) {
result[i++] = f;
}
return result;
}
/**
* Get configured alarm slot
*
* @return the alarm slot to be used for setting alarms on the watch
*/
public static int getAlarmSlot() {
Prefs prefs = GBApplication.getPrefs();
String slotString = prefs.getString("sleepasandroid_alarm_slot", "");
if (!slotString.isEmpty()) {
return Integer.parseInt(slotString);
}
return 0;
}
}

View File

@ -120,8 +120,8 @@ import nodomain.freeyourgadget.gadgetbridge.entities.CalendarSyncState;
import nodomain.freeyourgadget.gadgetbridge.entities.CalendarSyncStateDao;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.externalevents.CalendarReceiver;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationManager;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.LocationProviderType;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationProviderType;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.Alarm;
@ -215,7 +215,7 @@ public class BangleJSDeviceSupport extends AbstractBTLEDeviceSupport {
if (!gpsUpdateSetup)
return;
LOG.info("Stop location updates");
GBLocationManager.stop(getContext(), this);
GBLocationService.stop(getContext(), getDevice());
gpsUpdateSetup = false;
}
@ -1140,14 +1140,14 @@ public class BangleJSDeviceSupport extends AbstractBTLEDeviceSupport {
LOG.info("Using combined GPS and NETWORK based location: " + onlyUseNetworkGPS);
if (!onlyUseNetworkGPS) {
try {
GBLocationManager.start(getContext(), this, LocationProviderType.GPS, intervalLength);
GBLocationService.start(getContext(), getDevice(), GBLocationProviderType.GPS, intervalLength);
} catch (IllegalArgumentException e) {
LOG.warn("GPS provider could not be started", e);
}
}
try {
GBLocationManager.start(getContext(), this, LocationProviderType.NETWORK, intervalLength);
GBLocationService.start(getContext(), getDevice(), GBLocationProviderType.NETWORK, intervalLength);
} catch (IllegalArgumentException e) {
LOG.warn("NETWORK provider could not be started", e);
}

View File

@ -65,7 +65,6 @@ import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateA
import nodomain.freeyourgadget.gadgetbridge.service.serial.GBDeviceProtocol;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
import nodomain.freeyourgadget.gadgetbridge.util.MediaManager;
import nodomain.freeyourgadget.gadgetbridge.util.Prefs;
public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements CmfCharacteristic.Handler {
private static final Logger LOG = LoggerFactory.getLogger(CmfWatchProSupport.class);
@ -177,11 +176,6 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf
mediaManager = new MediaManager(context);
}
@Override
protected Prefs getDevicePrefs() {
return super.getDevicePrefs();
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt,
final BluetoothGattCharacteristic characteristic) {

View File

@ -0,0 +1,50 @@
/* Copyright (C) 2023-2024 Petr Kadlec
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 <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import java.nio.ByteBuffer;
public final class ChecksumCalculator {
private static final int[] CONSTANTS = {
0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
0xA001, 0x6C00,0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400
};
private ChecksumCalculator() {
}
public static int computeCrc(byte[] data, int offset, int length) {
return computeCrc(0, data, offset, length);
}
public static int computeCrc(ByteBuffer byteBuffer, int offset, int length) {
byteBuffer.rewind();
byte[] data = new byte[length];
byteBuffer.get(data);
return computeCrc(0, data, offset, length);
}
public static int computeCrc(int initialCrc, byte[] data, int offset, int length) {
int crc = initialCrc;
for (int i = offset; i < offset + length; ++i) {
int b = data[i];
crc = (((crc >> 4) & 4095) ^ CONSTANTS[crc & 15]) ^ CONSTANTS[b & 15];
crc = (((crc >> 4) & 4095) ^ CONSTANTS[crc & 15]) ^ CONSTANTS[(b >> 4) & 15];
}
return crc;
}
}

View File

@ -0,0 +1,358 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.FileDownloadedDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.DownloadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FileTransferDataMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.UploadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.CreateFileStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.DownloadRequestStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.FileTransferDataStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.UploadRequestStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
public class FileTransferHandler implements MessageHandler {
private static final Logger LOG = LoggerFactory.getLogger(FileTransferHandler.class);
private final GarminSupport deviceSupport;
private final Download download;
private final Upload upload;
private static final Set<FileType.FILETYPE> FILE_TYPES_TO_PROCESS = new HashSet<FileType.FILETYPE>() {{
add(FileType.FILETYPE.DIRECTORY);
add(FileType.FILETYPE.ACTIVITY);
add(FileType.FILETYPE.MONITOR);
add(FileType.FILETYPE.METRICS);
add(FileType.FILETYPE.CHANGELOG);
add(FileType.FILETYPE.SLEEP);
}};
public FileTransferHandler(GarminSupport deviceSupport) {
this.deviceSupport = deviceSupport;
this.download = new Download();
this.upload = new Upload();
}
public boolean isDownloading() {
return download.getCurrentlyDownloading() != null;
}
public boolean isUploading() {
return upload.getCurrentlyUploading() != null;
}
public GFDIMessage handle(GFDIMessage message) {
if (message instanceof DownloadRequestStatusMessage)
download.processDownloadRequestStatusMessage((DownloadRequestStatusMessage) message);
else if (message instanceof FileTransferDataMessage)
download.processDownloadChunkedMessage((FileTransferDataMessage) message);
else if (message instanceof CreateFileStatusMessage)
return upload.setCreateFileStatusMessage((CreateFileStatusMessage) message);
else if (message instanceof UploadRequestStatusMessage)
return upload.setUploadRequestStatusMessage((UploadRequestStatusMessage) message);
else if (message instanceof FileTransferDataStatusMessage)
return upload.processUploadProgress((FileTransferDataStatusMessage) message);
return null;
}
public DownloadRequestMessage downloadDirectoryEntry(DirectoryEntry directoryEntry) {
download.setCurrentlyDownloading(new FileFragment(directoryEntry));
return new DownloadRequestMessage(directoryEntry.getFileIndex(), 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
}
public DownloadRequestMessage initiateDownload() {
download.setCurrentlyDownloading(new FileFragment(new DirectoryEntry(0, FileType.FILETYPE.DIRECTORY, 0, 0, 0, 0, null)));
return new DownloadRequestMessage(0, 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
}
// public DownloadRequestMessage downloadSettings() {
// download.setCurrentlyDownloading(new FileFragment(new DirectoryEntry(0, FileType.FILETYPE.SETTINGS, 0, 0, 0, 0, null)));
// return new DownloadRequestMessage(0, 0, DownloadRequestMessage.REQUEST_TYPE.NEW, 0, 0);
// }
//
// public CreateFileMessage initiateUpload(byte[] fileAsByteArray, FileType.FILETYPE filetype) {
// upload.setCurrentlyUploading(new FileFragment(new DirectoryEntry(0, filetype, 0, 0, 0, fileAsByteArray.length, null), fileAsByteArray));
// return new CreateFileMessage(fileAsByteArray.length, filetype);
// }
public class Download {
private FileFragment currentlyDownloading;
public FileFragment getCurrentlyDownloading() {
return currentlyDownloading;
}
public void setCurrentlyDownloading(FileFragment currentlyDownloading) {
this.currentlyDownloading = currentlyDownloading;
}
private void processDownloadChunkedMessage(FileTransferDataMessage fileTransferDataMessage) {
if (!isDownloading())
throw new IllegalStateException("Received file transfer of unknown file");
currentlyDownloading.append(fileTransferDataMessage);
if (!currentlyDownloading.dataHolder.hasRemaining())
processCompleteDownload();
}
private void processCompleteDownload() {
currentlyDownloading.dataHolder.flip();
if (FileType.FILETYPE.DIRECTORY.equals(currentlyDownloading.directoryEntry.filetype)) { //is a directory
parseDirectoryEntries();
} else {
saveFileToExternalStorage();
}
currentlyDownloading = null;
}
public void processDownloadRequestStatusMessage(DownloadRequestStatusMessage downloadRequestStatusMessage) {
if (null == currentlyDownloading)
throw new IllegalStateException("Received file transfer of unknown file");
if (downloadRequestStatusMessage.canProceed())
currentlyDownloading.setSize(downloadRequestStatusMessage);
else
currentlyDownloading = null;
}
private void saveFileToExternalStorage() {
File dir;
try {
dir = deviceSupport.getWritableExportDirectory();
File outputFile = new File(dir, currentlyDownloading.getFileName());
FileUtils.copyStreamToFile(new ByteArrayInputStream(currentlyDownloading.dataHolder.array()), outputFile);
outputFile.setLastModified(currentlyDownloading.directoryEntry.fileDate.getTime());
} catch (IOException e) {
LOG.error("Failed to save file", e);
}
FileDownloadedDeviceEvent fileDownloadedDeviceEvent = new FileDownloadedDeviceEvent();
fileDownloadedDeviceEvent.directoryEntry = currentlyDownloading.directoryEntry;
deviceSupport.evaluateGBDeviceEvent(fileDownloadedDeviceEvent);
}
private void parseDirectoryEntries() {
if ((currentlyDownloading.getDataSize() % 16) != 0)
throw new IllegalArgumentException("Invalid directory data length");
final GarminByteBufferReader reader = new GarminByteBufferReader(currentlyDownloading.dataHolder.array());
reader.setByteOrder(ByteOrder.LITTLE_ENDIAN);
while (reader.remaining() > 0) {
final int fileIndex = reader.readShort();//2
final int fileDataType = reader.readByte();//3
final int fileSubType = reader.readByte();//4
final FileType.FILETYPE filetype = FileType.FILETYPE.fromDataTypeSubType(fileDataType, fileSubType);
final int fileNumber = reader.readShort();//6
final int specificFlags = reader.readByte();//7
final int fileFlags = reader.readByte();//8
final int fileSize = reader.readInt();//12
final Date fileDate = new Date(GarminTimeUtils.garminTimestampToJavaMillis(reader.readInt()));//16
final DirectoryEntry directoryEntry = new DirectoryEntry(fileIndex, filetype, fileNumber, specificFlags, fileFlags, fileSize, fileDate);
if (directoryEntry.filetype == null) //silently discard unsupported files
continue;
if (!FILE_TYPES_TO_PROCESS.contains(directoryEntry.filetype))
continue;
deviceSupport.addFileToDownloadList(directoryEntry);
}
currentlyDownloading = null;
}
}
public static class Upload {
private FileFragment currentlyUploading;
private UploadRequestMessage setCreateFileStatusMessage(CreateFileStatusMessage createFileStatusMessage) {
if (createFileStatusMessage.canProceed()) {
LOG.info("SENDING UPLOAD FILE");
return new UploadRequestMessage(createFileStatusMessage.getFileIndex(), currentlyUploading.getDataSize());
} else {
LOG.warn("Cannot proceed with upload");
this.currentlyUploading = null;
}
return null;
}
private FileTransferDataMessage setUploadRequestStatusMessage(UploadRequestStatusMessage uploadRequestStatusMessage) {
if (null == currentlyUploading)
throw new IllegalStateException("Received upload request status transfer of unknown file");
if (uploadRequestStatusMessage.canProceed()) {
if (uploadRequestStatusMessage.getDataOffset() != currentlyUploading.dataHolder.position())
throw new IllegalStateException("Received upload request with unaligned offset");
return currentlyUploading.take();
} else {
LOG.warn("Cannot proceed with upload");
this.currentlyUploading = null;
}
return null;
}
private GFDIMessage processUploadProgress(FileTransferDataStatusMessage fileTransferDataStatusMessage) {
if (currentlyUploading.getDataSize() <= fileTransferDataStatusMessage.getDataOffset()) {
this.currentlyUploading = null;
LOG.info("SENDING SYNC COMPLETE!!!");
return new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_COMPLETE, 0);
} else {
if (fileTransferDataStatusMessage.canProceed()) {
LOG.info("SENDING NEXT CHUNK!!!");
if (fileTransferDataStatusMessage.getDataOffset() != currentlyUploading.dataHolder.position())
throw new IllegalStateException("Received file transfer status with unaligned offset");
return currentlyUploading.take();
} else {
LOG.warn("Cannot proceed with upload");
this.currentlyUploading = null;
}
}
return null;
}
public FileFragment getCurrentlyUploading() {
return this.currentlyUploading;
}
public void setCurrentlyUploading(FileFragment currentlyUploading) {
this.currentlyUploading = currentlyUploading;
}
}
public static class FileFragment {
private final DirectoryEntry directoryEntry;
private final int maxBlockSize = 500;
private int dataSize;
private ByteBuffer dataHolder;
private int runningCrc;
FileFragment(DirectoryEntry directoryEntry) {
this.directoryEntry = directoryEntry;
this.setRunningCrc(0);
}
FileFragment(DirectoryEntry directoryEntry, byte[] contents) {
this.directoryEntry = directoryEntry;
this.setDataSize(contents.length);
this.dataHolder = ByteBuffer.wrap(contents);
this.dataHolder.flip(); //we'll be only reading from here on
this.dataHolder.compact();
this.setRunningCrc(0);
}
private int getMaxBlockSize() {
return Math.max(maxBlockSize, GFDIMessage.getMaxPacketSize());
}
public String getFileName() {
return directoryEntry.getFileName();
}
private void setSize(DownloadRequestStatusMessage downloadRequestStatusMessage) {
if (0 != getDataSize())
throw new IllegalStateException("Data size already set");
this.setDataSize(downloadRequestStatusMessage.getMaxFileSize());
this.dataHolder = ByteBuffer.allocate(getDataSize());
}
private void append(FileTransferDataMessage fileTransferDataMessage) {
if (fileTransferDataMessage.getDataOffset() != dataHolder.position())
throw new IllegalStateException("Received message that was already received");
final int dataCrc = ChecksumCalculator.computeCrc(getRunningCrc(), fileTransferDataMessage.getMessage(), 0, fileTransferDataMessage.getMessage().length);
if (fileTransferDataMessage.getCrc() != dataCrc)
throw new IllegalStateException("Received message with invalid CRC");
setRunningCrc(dataCrc);
this.dataHolder.put(fileTransferDataMessage.getMessage());
}
private FileTransferDataMessage take() {
final int currentOffset = this.dataHolder.position();
final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize())];
this.dataHolder.get(chunk);
setRunningCrc(ChecksumCalculator.computeCrc(getRunningCrc(), chunk, 0, chunk.length));
return new FileTransferDataMessage(chunk, currentOffset, getRunningCrc());
}
private int getDataSize() {
return dataSize;
}
private void setDataSize(int dataSize) {
this.dataSize = dataSize;
}
private int getRunningCrc() {
return runningCrc;
}
private void setRunningCrc(int runningCrc) {
this.runningCrc = runningCrc;
}
}
public static class DirectoryEntry {
private final int fileIndex;
private final FileType.FILETYPE filetype;
private final int fileNumber;
private final int specificFlags;
private final int fileFlags;
private final int fileSize;
private final Date fileDate;
public DirectoryEntry(int fileIndex, FileType.FILETYPE filetype, int fileNumber, int specificFlags, int fileFlags, int fileSize, Date fileDate) {
this.fileIndex = fileIndex;
this.filetype = filetype;
this.fileNumber = fileNumber;
this.specificFlags = specificFlags;
this.fileFlags = fileFlags;
this.fileSize = fileSize;
this.fileDate = fileDate;
}
public int getFileIndex() {
return fileIndex;
}
public FileType.FILETYPE getFiletype() {
return filetype;
}
public String getFileName() {
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
String dateString = dateFormat.format(fileDate);
return getFiletype().name() + "_" + getFileIndex() + "_" + dateString + (getFiletype().isFitFile() ? ".fit" : ".bin");
}
@NonNull
@Override
public String toString() {
return "DirectoryEntry{" +
"fileIndex=" + fileIndex +
", fileType=" + filetype.name() +
", fileNumber=" + fileNumber +
", specificFlags=" + specificFlags +
", fileFlags=" + fileFlags +
", fileSize=" + fileSize +
", fileDate=" + fileDate +
'}';
}
}
}

View File

@ -0,0 +1,102 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import androidx.annotation.Nullable;
public class FileType {
//common
//128/4: FIT_TYPE_4, -> garmin/activity
//128/32: FIT_TYPE_32, -> garmin/monitor
//128/44: FIT_TYPE_44, ->garmin/metrics
//128/41: FIT_TYPE_41, ->garmin/chnglog
//128/49: FIT_TYPE_49, -> garmin/sleep
//255/245: ErrorShutdownReports,
//Specific Instinct 2S:
//128/38: FIT_TYPE_38, -> garmin/SCORCRDS
//255/248: KPI,
//128/58: FIT_TYPE_58, -> outputFromUnit garmin/device????
//255/247: ULFLogs,
//128/68: FIT_TYPE_68, -> garmin/HRVSTATUS
//128/70: FIT_TYPE_70, -> garmin/HSA
//128/72: FIT_TYPE_72, -> garmin/FBTBACKUP
//128/74: FIT_TYPE_74
private final FILETYPE fileType;
private final String garminDeviceFileType;
public FileType(int fileDataType, int fileSubType, String garminDeviceFileType) {
this.fileType = FILETYPE.fromDataTypeSubType(fileDataType, fileSubType);
this.garminDeviceFileType = garminDeviceFileType;
}
public FILETYPE getFileType() {
return fileType;
}
public enum FILETYPE { //TODO: add specialized method to parse each file type to the enum?
// virtual/undocumented
DIRECTORY(0, 0),
// fit files
ACTIVITY(128, 4),
WORKOUTS(128, 5),
SCHEDULES(128, 7),
LOCATION(128, 8),
TOTALS(128, 10),
GOALS(128, 11),
SUMMARY(128, 20),
RECORDS(128, 29),
MONITOR(128, 32),
CLUBS(128, 37),
SCORE(128, 38),
ADJUSTMENTS(128, 39),
CHANGELOG(128, 41),
METRICS(128, 44),
SLEEP(128, 49),
MUSCLE_MAP(128, 59),
ECG(128, 61),
BENCHMARK(128, 62),
HRV_STATUS(128, 68),
HSA(128, 70),
FBT_BACKUP(128, 72),
SKIN_TEMP(128, 73),
FBT_PTD_BACKUP(128, 74),
// Other files
ERROR_SHUTDOWN_REPORTS(255, 245),
IQ_ERROR_REPORTS(255, 244),
ULF_LOGS(255, 247),
;
private final int type;
private final int subtype;
FILETYPE(final int type, final int subtype) {
this.type = type;
this.subtype = subtype;
}
@Nullable
public static FILETYPE fromDataTypeSubType(int dataType, int subType) {
for (FILETYPE ft :
FILETYPE.values()) {
if (ft.type == dataType && ft.subtype == subType)
return ft;
}
return null;
}
public int getType() {
return type;
}
public int getSubType() {
return subtype;
}
public boolean isFitFile() {
return type == 128;
}
}
}

View File

@ -0,0 +1,82 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
public class GarminByteBufferReader {
protected final ByteBuffer byteBuffer;
public GarminByteBufferReader(byte[] data) {
this.byteBuffer = ByteBuffer.wrap(data);
}
public int remaining() {
return byteBuffer.remaining();
}
public ByteBuffer asReadOnlyBuffer() {
return byteBuffer.asReadOnlyBuffer();
}
public void setByteOrder(ByteOrder byteOrder) {
this.byteBuffer.order(byteOrder);
}
public int readByte() {
return Byte.toUnsignedInt(byteBuffer.get());
}
public int getPosition() {
return byteBuffer.position();
}
public int readShort() {
return Short.toUnsignedInt(byteBuffer.getShort());
}
public int readInt() {
return byteBuffer.getInt();
}
public long readLong() {
return byteBuffer.getLong();
}
public float readFloat32() {
return byteBuffer.getFloat();
}
public double readFloat64() {
return byteBuffer.getDouble();
}
public String readString() {
final int size = readByte();
byte[] bytes = new byte[size];
byteBuffer.get(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
public String readNullTerminatedString() {
int position = byteBuffer.position();
int size = 0;
while (byteBuffer.hasRemaining()) {
if (byteBuffer.get() == 0)
break;
size++;
}
byteBuffer.position(position);
byte[] bytes = new byte[size];
byteBuffer.get(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
public byte[] readBytes(int size) {
byte[] bytes = new byte[size];
byteBuffer.get(bytes);
return bytes;
}
}

View File

@ -0,0 +1,620 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.location.Location;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
import java.io.IOException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Queue;
import java.util.Timer;
import java.util.TimerTask;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.R;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminPreferences;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.GarminCapability;
import nodomain.freeyourgadget.gadgetbridge.externalevents.gps.GBLocationService;
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;
import nodomain.freeyourgadget.gadgetbridge.model.Weather;
import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec;
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.GdiSettingsService;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiSmartProto;
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
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.FileDownloadedDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.NotificationSubscriptionDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.SupportedFileTypesDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents.WeatherRequestDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.PredefinedLocalMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ConfigurationMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.DownloadRequestMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MusicControlEntityUpdateMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.ProtobufMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetDeviceSettingsMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SetFileFlagsMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SupportedFileTypesMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.SystemEventMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationSubscriptionStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.FileUtils;
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;
import static nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSettingsPreferenceConst.PREF_SEND_APP_NOTIFICATIONS;
public class GarminSupport extends AbstractBTLEDeviceSupport implements ICommunicator.Callback {
private static final Logger LOG = LoggerFactory.getLogger(GarminSupport.class);
private final ProtocolBufferHandler protocolBufferHandler;
private final NotificationsHandler notificationsHandler;
private final FileTransferHandler fileTransferHandler;
private final Queue<FileTransferHandler.DirectoryEntry> filesToDownload;
private final List<MessageHandler> messageHandlers;
private ICommunicator communicator;
private MusicStateSpec musicStateSpec;
private Timer musicStateTimer;
private final List<FileType> supportedFileTypeList = new ArrayList<>();
public GarminSupport() {
super(LOG);
addSupportedService(CommunicatorV1.UUID_SERVICE_GARMIN_GFDI);
addSupportedService(CommunicatorV2.UUID_SERVICE_GARMIN_ML_GFDI);
protocolBufferHandler = new ProtocolBufferHandler(this);
fileTransferHandler = new FileTransferHandler(this);
filesToDownload = new LinkedList<>();
messageHandlers = new ArrayList<>();
notificationsHandler = new NotificationsHandler();
messageHandlers.add(fileTransferHandler);
messageHandlers.add(protocolBufferHandler);
messageHandlers.add(notificationsHandler);
}
@Override
public void dispose() {
LOG.info("Garmin dispose()");
GBLocationService.stop(getContext(), getDevice());
stopMusicTimer();
super.dispose();
}
private void stopMusicTimer() {
if (musicStateTimer != null) {
musicStateTimer.cancel();
musicStateTimer.purge();
musicStateTimer = null;
}
}
public void addFileToDownloadList(FileTransferHandler.DirectoryEntry directoryEntry) {
filesToDownload.add(directoryEntry);
}
@Override
public boolean useAutoConnect() {
return false;
}
@Override
protected TransactionBuilder initializeDevice(final TransactionBuilder builder) {
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext()));
if (getSupportedServices().contains(CommunicatorV2.UUID_SERVICE_GARMIN_ML_GFDI)) {
communicator = new CommunicatorV2(this);
} else if (getSupportedServices().contains(CommunicatorV1.UUID_SERVICE_GARMIN_GFDI)) {
communicator = new CommunicatorV1(this);
} else {
LOG.warn("Failed to find a known Garmin service");
builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.NOT_CONNECTED, getContext()));
return builder;
}
if (getDevicePrefs().getBoolean(PREF_ALLOW_HIGH_MTU, true)) {
builder.requestMtu(515);
}
communicator.initializeDevice(builder);
return builder;
}
@Override
public void onMtuChanged(final BluetoothGatt gatt, final int mtu, final int status) {
if (mtu < 23) {
LOG.warn("Ignoring mtu of {}, too low", mtu);
return;
}
if (!getDevicePrefs().getBoolean(PREF_ALLOW_HIGH_MTU, true)) {
LOG.warn("Ignoring mtu change to {} - high mtu is disabled", mtu);
return;
}
communicator.onMtuChanged(mtu);
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
final UUID characteristicUUID = characteristic.getUuid();
if (super.onCharacteristicChanged(gatt, characteristic)) {
LOG.debug("Change of characteristic {} handled by parent", characteristicUUID);
return true;
}
return communicator.onCharacteristicChanged(gatt, characteristic);
}
@Override
public void onMessage(final byte[] message) {
if (null == message) {
return; //message is not complete yet TODO check before calling
}
// LOG.debug("COBS decoded MESSAGE: {}", GB.hexdump(message));
GFDIMessage parsedMessage = GFDIMessage.parseIncoming(message);
if (null == parsedMessage) {
return; //message cannot be handled
}
/*
the handler elaborates the followup message but might change the status message since it does
check the integrity of the incoming message payload. Hence we let the handlers elaborate the
incoming message, then we send the status message of the incoming message, then the response
and finally we send the followup.
*/
GFDIMessage followup = null;
for (MessageHandler han : messageHandlers) {
followup = han.handle(parsedMessage);
if (followup != null) {
break;
}
}
final List<GBDeviceEvent> events = parsedMessage.getGBDeviceEvent();
for (final GBDeviceEvent event : events) {
evaluateGBDeviceEvent(event);
}
communicator.sendMessage(parsedMessage.getAckBytestream()); //send status message
sendOutgoingMessage(parsedMessage); //send reply if any
sendOutgoingMessage(followup); //send followup message if any
if (parsedMessage instanceof ConfigurationMessage) { //the last forced message exchange
completeInitialization();
}
processDownloadQueue();
}
@Override
public void onSetCallState(CallSpec callSpec) {
LOG.info("INCOMING CALLSPEC: {}", callSpec.command);
sendOutgoingMessage(notificationsHandler.onSetCallState(callSpec));
}
@Override
public void evaluateGBDeviceEvent(GBDeviceEvent deviceEvent) {
if (deviceEvent instanceof WeatherRequestDeviceEvent) {
WeatherSpec weather = Weather.getInstance().getWeatherSpec();
if (weather != null) {
sendWeatherConditions(weather);
}
} else if (deviceEvent instanceof NotificationSubscriptionDeviceEvent) {
final boolean enable = ((NotificationSubscriptionDeviceEvent) deviceEvent).enable;
notificationsHandler.setEnabled(enable);
final NotificationSubscriptionStatusMessage.NotificationStatus finalStatus;
if (getDevicePrefs().getBoolean(PREF_SEND_APP_NOTIFICATIONS, true)) {
finalStatus = NotificationSubscriptionStatusMessage.NotificationStatus.ENABLED;
} else {
finalStatus = NotificationSubscriptionStatusMessage.NotificationStatus.DISABLED;
}
LOG.info("NOTIFICATIONS ARE NOW enabled={}, status={}", enable, finalStatus);
sendOutgoingMessage(new NotificationSubscriptionStatusMessage(
GFDIMessage.Status.ACK,
finalStatus,
enable,
0
));
} else if (deviceEvent instanceof SupportedFileTypesDeviceEvent) {
this.supportedFileTypeList.clear();
this.supportedFileTypeList.addAll(((SupportedFileTypesDeviceEvent) deviceEvent).getSupportedFileTypes());
} else if (deviceEvent instanceof FileDownloadedDeviceEvent) {
LOG.debug("FILE DOWNLOAD COMPLETE {}", ((FileDownloadedDeviceEvent) deviceEvent).directoryEntry.getFileName());
if (!getKeepActivityDataOnDevice()) // delete file from watch upon successful download
sendOutgoingMessage(new SetFileFlagsMessage(((FileDownloadedDeviceEvent) deviceEvent).directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
}
super.evaluateGBDeviceEvent(deviceEvent);
}
private boolean getKeepActivityDataOnDevice() {
return getDevicePrefs().getBoolean("keep_activity_data_on_device", true); // TODO: change to default false once we are sure of the consequences
}
@Override
public void onFetchRecordedData(final int dataTypes) {
if (this.supportedFileTypeList.isEmpty()) {
LOG.warn("No known supported file types");
return;
}
// FIXME respect dataTypes?
sendOutgoingMessage(fileTransferHandler.initiateDownload());
}
@Override
public void onNotification(final NotificationSpec notificationSpec) {
sendOutgoingMessage(notificationsHandler.onNotification(notificationSpec));
}
@Override
public void onDeleteNotification(int id) {
sendOutgoingMessage(notificationsHandler.onDeleteNotification(id));
}
@Override
public void onSendWeather(final ArrayList<WeatherSpec> weatherSpecs) { //todo: find the closest one relative to the requested lat/long
sendWeatherConditions(weatherSpecs.get(0));
}
private void sendOutgoingMessage(GFDIMessage message) {
if (message == null)
return;
communicator.sendMessage(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)) {
// Device does not support sending weather as fit
return;
}
List<RecordData> weatherData = new ArrayList<>();
List<RecordDefinition> weatherDefinitions = new ArrayList<>(3);
weatherDefinitions.add(PredefinedLocalMessage.TODAY_WEATHER_CONDITIONS.getRecordDefinition());
weatherDefinitions.add(PredefinedLocalMessage.HOURLY_WEATHER_FORECAST.getRecordDefinition());
weatherDefinitions.add(PredefinedLocalMessage.DAILY_WEATHER_FORECAST.getRecordDefinition());
sendOutgoingMessage(new nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FitDefinitionMessage(weatherDefinitions));
try {
RecordData today = new RecordData(PredefinedLocalMessage.TODAY_WEATHER_CONDITIONS.getRecordDefinition());
today.setFieldByName("weather_report", 0); // 0 = current, 1 = hourly_forecast, 2 = daily_forecast
today.setFieldByName("timestamp", weather.timestamp);
today.setFieldByName("observed_at_time", weather.timestamp);
today.setFieldByName("temperature", weather.currentTemp);
today.setFieldByName("low_temperature", weather.todayMinTemp);
today.setFieldByName("high_temperature", weather.todayMaxTemp);
today.setFieldByName("condition", weather.currentConditionCode);
today.setFieldByName("wind_direction", weather.windDirection);
today.setFieldByName("precipitation_probability", weather.precipProbability);
today.setFieldByName("wind_speed", Math.round(weather.windSpeed));
today.setFieldByName("temperature_feels_like", weather.feelsLikeTemp);
today.setFieldByName("relative_humidity", weather.currentHumidity);
today.setFieldByName("observed_location_lat", weather.latitude);
today.setFieldByName("observed_location_long", weather.longitude);
today.setFieldByName("location", weather.location);
weatherData.add(today);
for (int hour = 0; hour <= 11; hour++) {
if (hour < weather.hourly.size()) {
WeatherSpec.Hourly hourly = weather.hourly.get(hour);
RecordData weatherHourlyForecast = new RecordData(PredefinedLocalMessage.HOURLY_WEATHER_FORECAST.getRecordDefinition());
weatherHourlyForecast.setFieldByName("weather_report", 1); // 0 = current, 1 = hourly_forecast, 2 = daily_forecast
weatherHourlyForecast.setFieldByName("timestamp", hourly.timestamp);
weatherHourlyForecast.setFieldByName("temperature", hourly.temp);
weatherHourlyForecast.setFieldByName("condition", hourly.conditionCode);
weatherHourlyForecast.setFieldByName("wind_direction", hourly.windDirection);
weatherHourlyForecast.setFieldByName("wind_speed", Math.round(hourly.windSpeed));
weatherHourlyForecast.setFieldByName("precipitation_probability", hourly.precipProbability);
weatherHourlyForecast.setFieldByName("relative_humidity", hourly.humidity);
// weatherHourlyForecast.setFieldByName("dew_point", 0); // dew_point sint8
weatherHourlyForecast.setFieldByName("uv_index", hourly.uvIndex);
// weatherHourlyForecast.setFieldByName("air_quality", 0); // air_quality enum
weatherData.add(weatherHourlyForecast);
}
}
//
RecordData todayDailyForecast = new RecordData(PredefinedLocalMessage.DAILY_WEATHER_FORECAST.getRecordDefinition());
todayDailyForecast.setFieldByName("weather_report", 2); // 0 = current, 1 = hourly_forecast, 2 = daily_forecast
todayDailyForecast.setFieldByName("timestamp", weather.timestamp);
todayDailyForecast.setFieldByName("low_temperature", weather.todayMinTemp);
todayDailyForecast.setFieldByName("high_temperature", weather.todayMaxTemp);
todayDailyForecast.setFieldByName("condition", weather.currentConditionCode);
todayDailyForecast.setFieldByName("precipitation_probability", weather.precipProbability);
todayDailyForecast.setFieldByName("day_of_week", weather.timestamp);
weatherData.add(todayDailyForecast);
for (int day = 0; day < 4; day++) {
if (day < weather.forecasts.size()) {
WeatherSpec.Daily daily = weather.forecasts.get(day);
int ts = weather.timestamp + (day + 1) * 24 * 60 * 60; //TODO: is this needed?
RecordData weatherDailyForecast = new RecordData(PredefinedLocalMessage.DAILY_WEATHER_FORECAST.getRecordDefinition());
weatherDailyForecast.setFieldByName("weather_report", 2); // 0 = current, 1 = hourly_forecast, 2 = daily_forecast
weatherDailyForecast.setFieldByName("timestamp", weather.timestamp);
weatherDailyForecast.setFieldByName("low_temperature", daily.minTemp);
weatherDailyForecast.setFieldByName("high_temperature", daily.maxTemp);
weatherDailyForecast.setFieldByName("condition", daily.conditionCode);
weatherDailyForecast.setFieldByName("precipitation_probability", daily.precipProbability);
weatherDailyForecast.setFieldByName("day_of_week", ts);
weatherData.add(weatherDailyForecast);
}
}
sendOutgoingMessage(new nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.FitDataMessage(weatherData));
} catch (Exception e) {
LOG.error(e.getMessage());
}
}
private void completeInitialization() {
onSetTime();
enableWeather();
//following is needed for vivomove style
sendOutgoingMessage(new SystemEventMessage(SystemEventMessage.GarminSystemEventType.SYNC_READY, 0));
enableBatteryLevelUpdate();
gbDevice.setState(GBDevice.State.INITIALIZED);
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) {
switch (config) {
case PREF_GARMIN_DEFAULT_REPLY_SUFFIX:
sendOutgoingMessage(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;
}
}
private void processDownloadQueue() {
if (!filesToDownload.isEmpty() && !fileTransferHandler.isDownloading()) {
if (!gbDevice.isBusy()) {
GB.updateTransferNotification(getContext().getString(R.string.busy_task_fetch_activity_data), "", true, 0, getContext());
getDevice().setBusyTask(getContext().getString(R.string.busy_task_fetch_activity_data));
getDevice().sendDeviceUpdateIntent(getContext());
}
try {
FileTransferHandler.DirectoryEntry directoryEntry = filesToDownload.remove();
while (checkFileExists(directoryEntry.getFileName())) {
LOG.debug("File: {} already downloaded, not downloading again.", directoryEntry.getFileName());
if (!getKeepActivityDataOnDevice()) // delete file from watch if already downloaded
sendOutgoingMessage(new SetFileFlagsMessage(directoryEntry.getFileIndex(), SetFileFlagsMessage.FileFlags.ARCHIVE));
directoryEntry = filesToDownload.remove();
}
DownloadRequestMessage downloadRequestMessage = fileTransferHandler.downloadDirectoryEntry(directoryEntry);
if (downloadRequestMessage != null) {
sendOutgoingMessage(downloadRequestMessage);
} else {
LOG.debug("File: {} already downloaded, not downloading again, from inside.", directoryEntry.getFileName());
}
} catch (NoSuchElementException e) {
// we ran out of files to download
// FIXME this is ugly
if (gbDevice.isBusy() && gbDevice.getBusyTask().equals(getContext().getString(R.string.busy_task_fetch_activity_data))) {
getDevice().unsetBusyTask();
GB.updateTransferNotification(null, "", false, 100, getContext());
getDevice().sendDeviceUpdateIntent(getContext());
}
}
} else if (filesToDownload.isEmpty() && !fileTransferHandler.isDownloading()) {
if (gbDevice.isBusy() && gbDevice.getBusyTask().equals(getContext().getString(R.string.busy_task_fetch_activity_data))) {
getDevice().unsetBusyTask();
GB.updateTransferNotification(null, "", false, 100, getContext());
getDevice().sendDeviceUpdateIntent(getContext());
}
}
}
private void enableBatteryLevelUpdate() {
final ProtobufMessage batteryLevelProtobufRequest = protocolBufferHandler.prepareProtobufRequest(GdiSmartProto.Smart.newBuilder()
.setDeviceStatusService(
GdiDeviceStatus.DeviceStatusService.newBuilder()
.setRemoteDeviceBatteryStatusRequest(
GdiDeviceStatus.DeviceStatusService.RemoteDeviceBatteryStatusRequest.newBuilder()
)
)
.build());
sendOutgoingMessage(batteryLevelProtobufRequest);
}
private void enableWeather() {
final Map<SetDeviceSettingsMessage.GarminDeviceSetting, Object> settings = new LinkedHashMap<>(3);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.AUTO_UPLOAD_ENABLED, false);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.WEATHER_CONDITIONS_ENABLED, true);
settings.put(SetDeviceSettingsMessage.GarminDeviceSetting.WEATHER_ALERTS_ENABLED, false);
sendOutgoingMessage(new SetDeviceSettingsMessage(settings));
}
@Override
public void onSetTime() {
sendOutgoingMessage(new SystemEventMessage(SystemEventMessage.GarminSystemEventType.TIME_UPDATED, 0));
}
@Override
public void onFindDevice(boolean start) {
final GdiFindMyWatch.FindMyWatchService.Builder a = GdiFindMyWatch.FindMyWatchService.newBuilder();
if (start) {
a.setFindRequest(
GdiFindMyWatch.FindMyWatchService.FindMyWatchRequest.newBuilder()
.setTimeout(60)
);
} else {
a.setCancelRequest(
GdiFindMyWatch.FindMyWatchService.FindMyWatchCancelRequest.newBuilder()
);
}
final ProtobufMessage findMyWatch = protocolBufferHandler.prepareProtobufRequest(
GdiSmartProto.Smart.newBuilder()
.setFindMyWatchService(a).build());
sendOutgoingMessage(findMyWatch);
}
@Override
public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) {
sendOutgoingMessage(protocolBufferHandler.setCannedMessages(cannedMessagesSpec));
}
@Override
public void onSetMusicInfo(MusicSpec musicSpec) {
Map<MusicControlEntityUpdateMessage.MusicEntity, String> attributes = new HashMap<>();
attributes.put(MusicControlEntityUpdateMessage.TRACK.ARTIST, musicSpec.artist);
attributes.put(MusicControlEntityUpdateMessage.TRACK.ALBUM, musicSpec.album);
attributes.put(MusicControlEntityUpdateMessage.TRACK.TITLE, musicSpec.track);
attributes.put(MusicControlEntityUpdateMessage.TRACK.DURATION, String.valueOf(musicSpec.duration));
sendOutgoingMessage(new MusicControlEntityUpdateMessage(attributes));
}
@Override
public void onSetMusicState(MusicStateSpec stateSpec) {
musicStateSpec = stateSpec;
stopMusicTimer();
musicStateTimer = new Timer();
int updatePeriod = 4000; //milliseconds
LOG.debug("onSetMusicState: {}", stateSpec.toString());
if (stateSpec.state == MusicStateSpec.STATE_PLAYING) {
musicStateTimer.schedule(new TimerTask() {
@Override
public void run() {
String playing = "1";
String playRate = "1.0";
String position = new DecimalFormat("#.000").format(musicStateSpec.position);
musicStateSpec.position += updatePeriod / 1000;
Map<MusicControlEntityUpdateMessage.MusicEntity, String> attributes = new HashMap<>();
attributes.put(MusicControlEntityUpdateMessage.PLAYER.PLAYBACK_INFO, StringUtils.join(",", playing, playRate, position).toString());
sendOutgoingMessage(new MusicControlEntityUpdateMessage(attributes));
}
}, 0, updatePeriod);
} else {
String playing = "0";
String playRate = "0.0";
String position = new DecimalFormat("#.###").format(stateSpec.position);
Map<MusicControlEntityUpdateMessage.MusicEntity, String> attributes = new HashMap<>();
attributes.put(MusicControlEntityUpdateMessage.PLAYER.PLAYBACK_INFO, StringUtils.join(",", playing, playRate, position).toString());
sendOutgoingMessage(new MusicControlEntityUpdateMessage(attributes));
}
}
private boolean checkFileExists(String fileName) {
File dir;
try {
dir = getWritableExportDirectory();
File outputFile = new File(dir, fileName);
if (outputFile.exists()) //do not download again already downloaded file
return true;
} catch (IOException e) {
LOG.error("IOException: " + e);
}
return false;
}
public File getWritableExportDirectory() throws IOException {
File dir;
dir = new File(FileUtils.getExternalFilesDir() + "/" + FileUtils.makeValidFileName(getDevice().getName() + "_" + getDevice().getAddress()));
if (!dir.isDirectory()) {
if (!dir.mkdir()) {
throw new IOException("Cannot create device specific directory for " + getDevice().getName());
}
}
return dir;
}
@Override
public void onSetGpsLocation(final Location location) {
final GdiCore.CoreService.LocationUpdatedNotification.Builder locationUpdatedNotification = GdiCore.CoreService.LocationUpdatedNotification.newBuilder()
.addLocationData(
GarminUtils.toLocationData(location, GdiCore.CoreService.DataType.REALTIME_TRACKING)
);
final ProtobufMessage locationUpdatedNotificationRequest = protocolBufferHandler.prepareProtobufRequest(
GdiSmartProto.Smart.newBuilder().setCoreService(
GdiCore.CoreService.newBuilder().setLocationUpdatedNotification(locationUpdatedNotification)
).build()
);
sendOutgoingMessage(locationUpdatedNotificationRequest);
}
}

View File

@ -0,0 +1,29 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import org.threeten.bp.Instant;
import org.threeten.bp.ZoneId;
public class GarminTimeUtils {
public static final int GARMIN_TIME_EPOCH = 631065600;
public static int unixTimeToGarminTimestamp(int unixTime) {
return unixTime - GARMIN_TIME_EPOCH;
}
public static int javaMillisToGarminTimestamp(long millis) {
return (int) (millis / 1000) - GARMIN_TIME_EPOCH;
}
public static long garminTimestampToJavaMillis(int timestamp) {
return (timestamp + GARMIN_TIME_EPOCH) * 1000L;
}
public static int garminTimestampToUnixTime(int timestamp) {
return timestamp + GARMIN_TIME_EPOCH;
}
public static int unixTimeToGarminDayOfWeek(int unixTime) {
return (Instant.ofEpochSecond(unixTime).atZone(ZoneId.systemDefault()).getDayOfWeek().getValue() % 7);
}
}

View File

@ -0,0 +1,35 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import android.location.Location;
import android.os.Build;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiCore;
public final class GarminUtils {
private GarminUtils() {
// utility class
}
public static GdiCore.CoreService.LocationData toLocationData(final Location location, final GdiCore.CoreService.DataType dataType) {
final GdiCore.CoreService.LatLon positionForWatch = GdiCore.CoreService.LatLon.newBuilder()
.setLat((int) ((location.getLatitude() * 2.147483648E9d) / 180.0d))
.setLon((int) ((location.getLongitude() * 2.147483648E9d) / 180.0d))
.build();
float vAccuracy = 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vAccuracy = location.getVerticalAccuracyMeters();
}
return GdiCore.CoreService.LocationData.newBuilder()
.setPosition(positionForWatch)
.setAltitude((float) location.getAltitude())
.setTimestamp(GarminTimeUtils.javaMillisToGarminTimestamp(location.getTime()))
.setHAccuracy(location.getAccuracy())
.setVAccuracy(vAccuracy)
.setPositionType(dataType)
.setBearing(location.getBearing())
.setSpeed(location.getSpeed())
.build();
}
}

View File

@ -0,0 +1,7 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
public interface MessageHandler {
GFDIMessage handle(GFDIMessage message);
}

View File

@ -0,0 +1,500 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin;
import android.util.SparseArray;
import org.apache.commons.lang3.EnumUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Queue;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventCallControl;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventNotificationControl;
import nodomain.freeyourgadget.gadgetbridge.model.CallSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec;
import nodomain.freeyourgadget.gadgetbridge.model.NotificationType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.GFDIMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationControlMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationDataMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.NotificationUpdateMessage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.status.NotificationDataStatusMessage;
import nodomain.freeyourgadget.gadgetbridge.util.LimitedQueue;
public class NotificationsHandler implements MessageHandler {
public static final SimpleDateFormat NOTIFICATION_DATE_FORMAT = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.ROOT);
private static final Logger LOG = LoggerFactory.getLogger(NotificationsHandler.class);
private final Queue<NotificationSpec> notificationSpecQueue;
private final Upload upload;
private boolean enabled = false;
// Keep track of Notification ID -> action handle, as BangleJSDeviceSupport.
// TODO: This needs to be simplified.
private final LimitedQueue<Integer, Long> mNotificationReplyAction = new LimitedQueue<>(16);
public NotificationsHandler() {
this.notificationSpecQueue = new LinkedList<>();
this.upload = new Upload();
}
private static void encodeNotificationAttribute(NotificationSpec notificationSpec, Map.Entry<NotificationAttribute, Integer> entry, MessageWriter messageWriter) {
messageWriter.writeByte(entry.getKey().code);
final byte[] bytes = entry.getKey().getNotificationSpecAttribute(notificationSpec, entry.getValue());
messageWriter.writeShort(bytes.length);
messageWriter.writeBytes(bytes);
// LOG.info("ATTRIBUTE:{} value:{}/{} length:{}", entry.getKey(), new String(bytes), GB.hexdump(bytes), bytes.length);
}
private boolean addNotificationToQueue(NotificationSpec notificationSpec) {
boolean found = false;
Iterator<NotificationSpec> iterator = notificationSpecQueue.iterator();
while (iterator.hasNext()) {
NotificationSpec e = iterator.next();
if (e.getId() == notificationSpec.getId()) {
found = true;
iterator.remove();
}
}
notificationSpecQueue.offer(notificationSpec); // Add the notificationSpec to the front of the queue
return found;
}
public NotificationUpdateMessage onSetCallState(CallSpec callSpec) {
if (!enabled)
return null;
if (callSpec.command == CallSpec.CALL_INCOMING) {
NotificationSpec callNotificationSpec = new NotificationSpec(callSpec.number.hashCode());
callNotificationSpec.phoneNumber = callSpec.number;
callNotificationSpec.sourceAppId = callSpec.sourceAppId;
callNotificationSpec.title = StringUtils.isEmpty(callSpec.name) ? callSpec.number : callSpec.name;
callNotificationSpec.type = NotificationType.GENERIC_PHONE;
callNotificationSpec.body = StringUtils.isEmpty(callSpec.name) ? callSpec.number : callSpec.name;
return onNotification(callNotificationSpec);
} else {
if (callSpec.number != null) // this happens in debug screen
return onDeleteNotification(callSpec.number.hashCode());
}
return null;
}
public NotificationUpdateMessage onNotification(NotificationSpec notificationSpec) {
if (!enabled)
return null;
final boolean isUpdate = addNotificationToQueue(notificationSpec);
NotificationUpdateMessage.NotificationUpdateType notificationUpdateType = isUpdate ? NotificationUpdateMessage.NotificationUpdateType.MODIFY : NotificationUpdateMessage.NotificationUpdateType.ADD;
if (notificationSpecQueue.size() > 10)
notificationSpecQueue.poll(); //remove the oldest notification TODO: should send a delete notification message to watch!
final boolean hasActions = (null != notificationSpec.attachedActions && !notificationSpec.attachedActions.isEmpty());
if (hasActions) {
for (int i = 0; i < notificationSpec.attachedActions.size(); i++) {
final NotificationSpec.Action action = notificationSpec.attachedActions.get(i);
if (action.type == NotificationSpec.Action.TYPE_WEARABLE_REPLY || action.type == NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR) {
mNotificationReplyAction.add(notificationSpec.getId(), action.handle);
}
}
}
return new NotificationUpdateMessage(notificationUpdateType, notificationSpec.type, getNotificationsCount(notificationSpec.type), notificationSpec.getId(), hasActions);
}
private int getNotificationsCount(NotificationType notificationType) {
int count = 0;
for (NotificationSpec e : notificationSpecQueue) {
count += e.type == notificationType ? 1 : 0;
}
return count;
}
private NotificationSpec getNotificationSpecFromQueue(int id) {
for (NotificationSpec e : notificationSpecQueue) {
if (e.getId() == id) {
return e;
}
}
return null;
}
public NotificationUpdateMessage onDeleteNotification(int id) {
if (!enabled)
return null;
Iterator<NotificationSpec> iterator = notificationSpecQueue.iterator();
while (iterator.hasNext()) {
NotificationSpec e = iterator.next();
if (e.getId() == id) {
iterator.remove();
return new NotificationUpdateMessage(NotificationUpdateMessage.NotificationUpdateType.REMOVE, e.type, getNotificationsCount(e.type), id, false);
}
}
return null;
}
public GFDIMessage handle(GFDIMessage message) {
if (!enabled)
return null;
if (message instanceof NotificationControlMessage) {
final NotificationSpec notificationSpec = getNotificationSpecFromQueue(((NotificationControlMessage) message).getNotificationId());
if (notificationSpec != null) {
switch (((NotificationControlMessage) message).getCommand()) {
case GET_NOTIFICATION_ATTRIBUTES:
return getNotificationDataMessage((NotificationControlMessage) message, notificationSpec);
case PERFORM_LEGACY_NOTIFICATION_ACTION:
LOG.info("Legacy Notification: {}", ((NotificationControlMessage) message).getLegacyNotificationAction());
break;
case PERFORM_NOTIFICATION_ACTION:
performNotificationAction((NotificationControlMessage) message, notificationSpec);
break;
default:
LOG.error("NOT SUPPORTED: {}", ((NotificationControlMessage) message).getCommand());
}
}
} else if (message instanceof NotificationDataStatusMessage) {
return upload.processUploadProgress((NotificationDataStatusMessage) message);
}
return null;
}
private void performNotificationAction(NotificationControlMessage message, NotificationSpec notificationSpec) {
final GBDeviceEventNotificationControl deviceEvtNotificationControl = new GBDeviceEventNotificationControl();
deviceEvtNotificationControl.handle = notificationSpec.getId();
final GBDeviceEventCallControl deviceEvtCallControl = new GBDeviceEventCallControl();
switch (message.getNotificationAction()) {
case REPLY_INCOMING_CALL:
case REPLY_MESSAGES:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.REPLY;
deviceEvtNotificationControl.reply = message.getActionString();
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE) || notificationSpec.type.equals(NotificationType.GENERIC_SMS)) {
deviceEvtNotificationControl.phoneNumber = notificationSpec.phoneNumber;
} else {
deviceEvtNotificationControl.handle = mNotificationReplyAction.lookup(notificationSpec.getId()); //handle of wearable action is needed
}
message.setDeviceEvent(deviceEvtNotificationControl);
break;
case ACCEPT_INCOMING_CALL:
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.ACCEPT;
message.setDeviceEvent(deviceEvtCallControl);
break;
case REJECT_INCOMING_CALL:
deviceEvtCallControl.event = GBDeviceEventCallControl.Event.REJECT;
message.setDeviceEvent(deviceEvtCallControl);
break;
case DISMISS_NOTIFICATION:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.DISMISS;
message.setDeviceEvent(deviceEvtNotificationControl);
break;
case BLOCK_APPLICATION:
deviceEvtNotificationControl.event = GBDeviceEventNotificationControl.Event.MUTE;
message.setDeviceEvent(deviceEvtNotificationControl);
break;
}
}
private NotificationDataMessage getNotificationDataMessage(NotificationControlMessage message, NotificationSpec notificationSpec) {
final MessageWriter messageWriter = new MessageWriter();
messageWriter.writeByte(NotificationCommand.GET_NOTIFICATION_ATTRIBUTES.code);
messageWriter.writeInt(message.getNotificationId());
Map.Entry<NotificationAttribute, Integer> lastEntry = null;
for (Map.Entry<NotificationAttribute, Integer> entry : message.getNotificationAttributesMap().entrySet()) {
if (!NotificationAttribute.MESSAGE_SIZE.equals(entry.getKey())) {
encodeNotificationAttribute(notificationSpec, entry, messageWriter);
} else {
lastEntry = entry;
}
}
if (lastEntry != null) {
encodeNotificationAttribute(notificationSpec, lastEntry, messageWriter);
}
NotificationFragment notificationFragment = new NotificationFragment(messageWriter.getBytes());
return upload.setCurrentlyUploading(notificationFragment);
}
public void setEnabled(boolean enable) {
this.enabled = enable;
}
public enum NotificationCommand { //was AncsCommand
GET_NOTIFICATION_ATTRIBUTES(0),
GET_APP_ATTRIBUTES(1), //unknown/untested
PERFORM_LEGACY_NOTIFICATION_ACTION(2),
PERFORM_NOTIFICATION_ACTION(128);
public final int code;
NotificationCommand(int code) {
this.code = code;
}
public static NotificationCommand fromCode(int code) {
for (NotificationCommand value : values()) {
if (value.code == code)
return value;
}
throw new IllegalArgumentException("Unknown notification command " + code);
}
}
public enum LegacyNotificationAction { //was AncsAction
ACCEPT,
REFUSE
}
public enum NotificationAttribute { //was AncsAttribute
APP_IDENTIFIER(0),
TITLE(1, true),
SUBTITLE(2, true),
MESSAGE(3, true),
MESSAGE_SIZE(4),
DATE(5),
// POSITIVE_ACTION_LABEL(6), //needed only for legacy notification actions
NEGATIVE_ACTION_LABEL(7), //needed only for legacy notification actions
// Garmin extensions
// PHONE_NUMBER(126, true),
ACTIONS(127, false, true),
;
private static final SparseArray<NotificationAttribute> valueByCode;
static {
final NotificationAttribute[] values = values();
valueByCode = new SparseArray<>(values.length);
for (NotificationAttribute value : values) {
valueByCode.append(value.code, value);
}
}
public final int code;
public final boolean hasLengthParam;
public final boolean hasAdditionalParams;
NotificationAttribute(int code) {
this(code, false, false);
}
NotificationAttribute(int code, boolean hasLengthParam) {
this(code, hasLengthParam, false);
}
NotificationAttribute(int code, boolean hasLengthParam, boolean hasAdditionalParams) {
this.code = code;
this.hasLengthParam = hasLengthParam;
this.hasAdditionalParams = hasAdditionalParams;
}
public static NotificationAttribute getByCode(int code) {
return valueByCode.get(code);
}
public byte[] getNotificationSpecAttribute(NotificationSpec notificationSpec, int maxLength) {
String toReturn = "";
switch (this) {
case DATE:
final long notificationTimestamp = notificationSpec.when == 0 ? System.currentTimeMillis() : notificationSpec.when;
toReturn = NOTIFICATION_DATE_FORMAT.format(new Date(notificationTimestamp));
break;
case TITLE:
if (NotificationType.GENERIC_SMS.equals(notificationSpec.type))
toReturn = notificationSpec.sender == null ? "" : notificationSpec.sender;
else
toReturn = notificationSpec.title == null ? "" : notificationSpec.title;
break;
case SUBTITLE:
toReturn = notificationSpec.subject == null ? "" : notificationSpec.subject;
break;
case APP_IDENTIFIER:
toReturn = notificationSpec.sourceAppId == null ? "" : notificationSpec.sourceAppId;
break;
case MESSAGE:
toReturn = notificationSpec.body == null ? "" : notificationSpec.body;
break;
case MESSAGE_SIZE:
toReturn = Integer.toString(notificationSpec.body == null ? "".length() : notificationSpec.body.length());
break;
case ACTIONS:
toReturn = encodeNotificationActionsString(notificationSpec);
break;
}
if (maxLength == 0)
return toReturn.getBytes(StandardCharsets.UTF_8);
return toReturn.substring(0, Math.min(toReturn.length(), maxLength)).getBytes(StandardCharsets.UTF_8);
}
private String encodeNotificationActionsString(NotificationSpec notificationSpec) {
final List<byte[]> garminActions = new ArrayList<>();
if (notificationSpec.type.equals(NotificationType.GENERIC_PHONE)) {
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
}
if (null != notificationSpec.attachedActions) {
for (NotificationSpec.Action action : notificationSpec.attachedActions) {
switch (action.type) {
case NotificationSpec.Action.TYPE_WEARABLE_REPLY:
case NotificationSpec.Action.TYPE_SYNTECTIC_REPLY_PHONENR:
garminActions.add(encodeNotificationAction(NotificationAction.REPLY_MESSAGES, action.title));
break;
case NotificationSpec.Action.TYPE_SYNTECTIC_DISMISS:
garminActions.add(encodeNotificationAction(NotificationAction.DISMISS_NOTIFICATION, action.title));
break;
case NotificationSpec.Action.TYPE_SYNTECTIC_MUTE:
garminActions.add(encodeNotificationAction(NotificationAction.BLOCK_APPLICATION, action.title));
break;
}
// LOG.info("Notification has action {} with title {}", action.type, action.title);
}
}
if (garminActions.isEmpty())
return new String(new byte[]{0x00, 0x00, 0x00, 0x00});
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream()) {
byteArrayOutputStream.write(garminActions.size());
for (byte[] item : garminActions) {
byteArrayOutputStream.write(item);
}
return byteArrayOutputStream.toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private byte[] encodeNotificationAction(NotificationAction notificationAction, String description) {
final ByteBuffer action = ByteBuffer.allocate(3 + description.getBytes(StandardCharsets.UTF_8).length);
action.put((byte) notificationAction.code);
if (null == notificationAction.notificationActionIconPosition)
action.put((byte) 0x00);
else
action.put((byte) EnumUtils.generateBitVector(NotificationActionIconPosition.class, notificationAction.notificationActionIconPosition));
action.put((byte) description.getBytes(StandardCharsets.UTF_8).length);
action.put(description.getBytes());
return action.array();
}
}
public enum NotificationAction {
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),
BLOCK_APPLICATION(99, null),
;
private final int code;
private final NotificationActionIconPosition notificationActionIconPosition;
NotificationAction(int code, NotificationActionIconPosition notificationActionIconPosition) {
this.code = code;
this.notificationActionIconPosition = notificationActionIconPosition;
}
public static NotificationAction fromCode(final int code) {
for (final NotificationAction notificationAction : NotificationAction.values()) {
if (notificationAction.code == code) {
return notificationAction;
}
}
throw new IllegalArgumentException("Unknown notification action code " + code);
}
}
enum NotificationActionIconPosition { //educated guesses based on the icons' positions on vĂ­vomove style
BOTTOM, //or is it reply?
RIGHT, //or is it accept?
LEFT, //or is it dismiss/refuse?
}
public static class Upload {
private NotificationFragment currentlyUploading;
public NotificationDataMessage setCurrentlyUploading(NotificationFragment currentlyUploading) {
this.currentlyUploading = currentlyUploading;
return currentlyUploading.take();
}
private GFDIMessage processUploadProgress(NotificationDataStatusMessage notificationDataStatusMessage) {
if (null == currentlyUploading) {
LOG.warn("Received Upload Progress but we are not sending any notification");
return null;
}
if (!currentlyUploading.dataHolder.hasRemaining()) {
this.currentlyUploading = null;
LOG.info("SENT ALL");
return new NotificationDataStatusMessage(GFDIMessage.GarminMessage.NOTIFICATION_DATA, GFDIMessage.Status.ACK, NotificationDataStatusMessage.TransferStatus.OK);
} else {
if (notificationDataStatusMessage.canProceed()) {
LOG.info("SENDING NEXT CHUNK!!!");
return currentlyUploading.take();
} else {
LOG.warn("Cannot proceed with upload"); //TODO: send the correct status message
this.currentlyUploading = null;
}
}
return null;
}
}
public static class NotificationFragment {
private final int dataSize;
private final ByteBuffer dataHolder;
private final int maxBlockSize = 300;
private int runningCrc;
NotificationFragment(byte[] contents) {
this.dataHolder = ByteBuffer.wrap(contents);
this.dataSize = contents.length;
this.dataHolder.flip();
this.dataHolder.compact();
this.setRunningCrc(0);
}
public int getDataSize() {
return dataSize;
}
private int getMaxBlockSize() {
return maxBlockSize;
}
private NotificationDataMessage take() {
final int currentOffset = this.dataHolder.position();
final byte[] chunk = new byte[Math.min(this.dataHolder.remaining(), getMaxBlockSize())];
this.dataHolder.get(chunk);
setRunningCrc(ChecksumCalculator.computeCrc(getRunningCrc(), chunk, 0, chunk.length));
return new NotificationDataMessage(chunk, getDataSize(), currentOffset, getRunningCrc());
}
private int getRunningCrc() {
return runningCrc;
}
private void setRunningCrc(int runningCrc) {
this.runningCrc = runningCrc;
}
}
}

View File

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

View File

@ -0,0 +1,134 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator;
import java.nio.ByteBuffer;
public class CobsCoDec {
private static final long BUFFER_TIMEOUT = 1500L; // turn this value up while debugging
private final ByteBuffer byteBuffer = ByteBuffer.allocate(10_000);
private long lastUpdate;
private byte[] cobsDecodedMessage;
/**
* Accumulates received bytes in a local buffer, clearing it after a timeout, and attempts to
* parse it.
*
* @param bytes
*/
public void receivedBytes(byte[] bytes) {
final long now = System.currentTimeMillis();
if ((now - lastUpdate) > BUFFER_TIMEOUT) {
reset();
}
lastUpdate = now;
byteBuffer.put(bytes);
decode();
}
private void reset() {
cobsDecodedMessage = null;
byteBuffer.clear();
}
public byte[] retrieveMessage() {
final byte[] resultPacket = cobsDecodedMessage;
cobsDecodedMessage = null;
return resultPacket;
}
/**
* COBS decoding algorithm variant, which relies on a leading and a trailing 0 byte (the former
* is not part of default implementations).
* This function removes the complete message from the internal buffer, if it could be decoded.
*/
private void decode() {
if (cobsDecodedMessage != null) {
// packet is waiting, unable to parse more
return;
}
if (byteBuffer.position() < 4) {
// minimal payload length including the padding
return;
}
if (0 != byteBuffer.get(byteBuffer.position() - 1))
return; //no 0x00 at the end, hence no full packet
byteBuffer.position(byteBuffer.position() - 1); //don't process the trailing 0
byteBuffer.flip();
if (0 != byteBuffer.get())
return; //no 0x00 at the start
ByteBuffer decodedBytesBuffer = ByteBuffer.allocate(byteBuffer.limit()); //leading and trailing 0x00 bytes
while (byteBuffer.hasRemaining()) {
byte code = byteBuffer.get();
if (code == 0) {
break;
}
int codeValue = code & 0xFF;
int payloadSize = codeValue - 1;
for (int i = 0; i < payloadSize; i++) {
decodedBytesBuffer.put(byteBuffer.get());
}
if (codeValue != 0xFF && byteBuffer.hasRemaining()) {
decodedBytesBuffer.put((byte) 0); // Append a zero byte after the payload
}
}
decodedBytesBuffer.flip();
cobsDecodedMessage = new byte[decodedBytesBuffer.remaining()];
decodedBytesBuffer.get(cobsDecodedMessage);
byteBuffer.compact();
}
// this implementation of COBS relies on a leading and a trailing 0 byte (the former is not part of default implementations)
public byte[] encode(byte[] data) {
ByteBuffer encodedBytesBuffer = ByteBuffer.allocate((data.length * 2) + 1); // Maximum expansion
encodedBytesBuffer.put((byte) 0);// Garmin initial padding
ByteBuffer buffer = ByteBuffer.wrap(data);
while (buffer.position() < buffer.limit()) {
int startPos = buffer.position();
int zeroIndex = buffer.position();
while (buffer.hasRemaining() && buffer.get() != 0) {
zeroIndex++;
}
int payloadSize = zeroIndex - startPos;
while (payloadSize > 0xFE) {
encodedBytesBuffer.put((byte) 0xFF); // Maximum payload size indicator
for (int i = 0; i < 0xFE; i++) {
encodedBytesBuffer.put(data[startPos + i]);
}
payloadSize -= 0xFE;
startPos += 0xFE;
}
encodedBytesBuffer.put((byte) (payloadSize + 1));
for (int i = startPos; i < zeroIndex; i++) {
encodedBytesBuffer.put(data[i]);
}
if (buffer.hasRemaining()) {
zeroIndex++; // Include the zero byte in the next block
}
if (!buffer.hasRemaining() && payloadSize == 0) {
break;
}
buffer.position(zeroIndex);
}
encodedBytesBuffer.put((byte) 0); // Append a zero byte to indicate end of encoding
encodedBytesBuffer.flip();
byte[] encodedBytes = new byte[encodedBytesBuffer.remaining()];
encodedBytesBuffer.get(encodedBytes);
return encodedBytes;
}
}

View File

@ -0,0 +1,20 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
public interface ICommunicator {
void sendMessage(byte[] message);
void onMtuChanged(final int mtu);
void initializeDevice(TransactionBuilder builder);
boolean onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic);
interface Callback {
void onMessage(byte[] message);
}
}

View File

@ -0,0 +1,41 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v1;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.devices.vivomovehr.VivomoveConstants;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator;
public class CommunicatorV1 implements ICommunicator {
public static final UUID UUID_SERVICE_GARMIN_GFDI = VivomoveConstants.UUID_SERVICE_GARMIN_GFDI;
private final GarminSupport mSupport;
public CommunicatorV1(final GarminSupport garminSupport) {
this.mSupport = garminSupport;
}
@Override
public void onMtuChanged(final int mtu) {
}
@Override
public void initializeDevice(final TransactionBuilder builder) {
}
@Override
public void sendMessage(final byte[] message) {
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
return false;
}
}

View File

@ -0,0 +1,177 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import org.apache.commons.lang3.ArrayUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.UUID;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.CobsCoDec;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator;
import nodomain.freeyourgadget.gadgetbridge.util.GB;
public class CommunicatorV2 implements ICommunicator {
public static final UUID UUID_SERVICE_GARMIN_ML_GFDI = UUID.fromString("6A4E2800-667B-11E3-949A-0800200C9A66"); //VivomoveConstants.UUID_SERVICE_GARMIN_ML_GFDI;
public static final UUID UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND = UUID.fromString("6a4e2822-667b-11e3-949a-0800200c9a66"); //VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND;
public static final UUID UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE = UUID.fromString("6a4e2812-667b-11e3-949a-0800200c9a66"); //VivomoveConstants.UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE;
public int maxWriteSize = 20; //VivomoveConstants.MAX_WRITE_SIZE
private static final Logger LOG = LoggerFactory.getLogger(CommunicatorV2.class);
public final CobsCoDec cobsCoDec;
private final GarminSupport mSupport;
private final long gadgetBridgeClientID = 2L;
private int gfdiHandle = 0;
public CommunicatorV2(final GarminSupport garminSupport) {
this.mSupport = garminSupport;
this.cobsCoDec = new CobsCoDec();
}
@Override
public void onMtuChanged(final int mtu) {
maxWriteSize = mtu - 3;
}
@Override
public void initializeDevice(final TransactionBuilder builder) {
builder.notify(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_RECEIVE), true);
builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), closeAllServices());
}
@Override
public void sendMessage(final byte[] message) {
if (null == message)
return;
if (0 == gfdiHandle) {
LOG.error("CANNOT SENT GFDI MESSAGE, HANDLE NOT YET SET. MESSAGE {}", message);
return;
}
final byte[] payload = cobsCoDec.encode(message);
// LOG.debug("SENDING MESSAGE: {} - COBS ENCODED: {}", GB.hexdump(message), GB.hexdump(payload));
final TransactionBuilder builder = new TransactionBuilder("sendMessage()");
int remainingBytes = payload.length;
if (remainingBytes > maxWriteSize - 1) {
int position = 0;
while (remainingBytes > 0) {
final byte[] fragment = Arrays.copyOfRange(payload, position, position + Math.min(remainingBytes, maxWriteSize - 1));
builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, fragment));
position += fragment.length;
remainingBytes -= fragment.length;
}
} else {
builder.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, payload));
}
builder.queue(this.mSupport.getQueue());
}
@Override
public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
ByteBuffer message = ByteBuffer.wrap(characteristic.getValue()).order(ByteOrder.LITTLE_ENDIAN);
// LOG.debug("RECEIVED: {}", GB.hexdump(message.array()));
final byte handle = message.get();
if (0x00 == handle) { //handle management message
final byte type = message.get();
final long incomingClientID = message.getLong();
if (incomingClientID != this.gadgetBridgeClientID) {
LOG.debug("Ignoring incoming message, client ID is not ours. Message: {}", GB.hexdump(message.array()));
}
RequestType requestType = RequestType.fromCode(type);
if (null == requestType) {
LOG.error("Unknown request type. Message: {}", message.array());
return true;
}
switch (requestType) {
case REGISTER_ML_REQ: //register service request
case CLOSE_HANDLE_REQ: //close handle request
case CLOSE_ALL_REQ: //close all handles request
case UNK_REQ: //unknown request
LOG.warn("Received handle request, expecting responses. Message: {}", message.array());
case REGISTER_ML_RESP: //register service response
LOG.debug("Received register response. Message: {}", message.array());
final short registeredService = message.getShort();
final byte status = message.get();
if (0 == status && 1 == registeredService) { //success
this.gfdiHandle = message.get();
}
break;
case CLOSE_HANDLE_RESP: //close handle response
LOG.debug("Received close handle response. Message: {}", message.array());
break;
case CLOSE_ALL_RESP: //close all handles response
LOG.debug("Received close all handles response. Message: {}", message.array());
new TransactionBuilder("open GFDI")
.write(this.mSupport.getCharacteristic(UUID_CHARACTERISTIC_GARMIN_ML_GFDI_SEND), registerGFDI())
.queue(this.mSupport.getQueue());
break;
case UNK_RESP: //unknown response
LOG.debug("Received unknown. Message: {}", message.array());
break;
}
return true;
} else if (this.gfdiHandle == handle) {
byte[] partial = new byte[message.remaining()];
message.get(partial);
this.cobsCoDec.receivedBytes(partial);
this.mSupport.onMessage(this.cobsCoDec.retrieveMessage());
return true;
}
return false;
}
protected byte[] closeAllServices() {
ByteBuffer toSend = ByteBuffer.allocate(13);
toSend.order(ByteOrder.BIG_ENDIAN);
toSend.putShort((short) RequestType.CLOSE_ALL_REQ.ordinal()); //close all services
toSend.order(ByteOrder.LITTLE_ENDIAN);
toSend.putLong(this.gadgetBridgeClientID);
toSend.putShort((short) 0);
return toSend.array();
}
protected byte[] registerGFDI() {
ByteBuffer toSend = ByteBuffer.allocate(13);
toSend.order(ByteOrder.BIG_ENDIAN);
toSend.putShort((short) RequestType.REGISTER_ML_REQ.ordinal()); //register service request
toSend.order(ByteOrder.LITTLE_ENDIAN);
toSend.putLong(this.gadgetBridgeClientID);
toSend.putShort((short) 1); //service GFDI
return toSend.array();
}
enum RequestType {
REGISTER_ML_REQ,
REGISTER_ML_RESP,
CLOSE_HANDLE_REQ,
CLOSE_HANDLE_RESP,
UNK_HANDLE,
CLOSE_ALL_REQ,
CLOSE_ALL_RESP,
UNK_REQ,
UNK_RESP;
public static RequestType fromCode(final int code) {
for (final RequestType requestType : RequestType.values()) {
if (requestType.ordinal() == code) {
return requestType;
}
}
return null;
}
}
}

View File

@ -0,0 +1,9 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileTransferHandler;
public class FileDownloadedDeviceEvent extends GBDeviceEvent {
public FileTransferHandler.DirectoryEntry directoryEntry;
}

View File

@ -0,0 +1,9 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
public class NotificationSubscriptionDeviceEvent extends GBDeviceEvent {
public boolean enable;
}

View File

@ -0,0 +1,19 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType;
public class SupportedFileTypesDeviceEvent extends GBDeviceEvent {
private final List<FileType> supportedFileTypes;
public SupportedFileTypesDeviceEvent(List<FileType> fileTypes) {
this.supportedFileTypes = fileTypes;
}
public List<FileType> getSupportedFileTypes() {
return supportedFileTypes;
}
}

View File

@ -0,0 +1,18 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.deviceevents;
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEvent;
public class WeatherRequestDeviceEvent extends GBDeviceEvent {
private final int format;
private final int latitude;
private final int longitude;
private final int hoursOfForecast;
public WeatherRequestDeviceEvent(int format, int latitude, int longitude, int hoursOfForecast) {
this.format = format;
this.latitude = latitude;
this.longitude = longitude;
this.hoursOfForecast = hoursOfForecast;
}
}

View File

@ -0,0 +1,60 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class DevFieldDefinition {
public final ByteBuffer valueHolder;
private final int fieldDefinitionNumber;
private final int size;
private final int developerDataIndex;
private BaseType baseType;
private String name;
public DevFieldDefinition(int fieldDefinitionNumber, int size, int developerDataIndex, String name) {
this.fieldDefinitionNumber = fieldDefinitionNumber;
this.size = size;
this.developerDataIndex = developerDataIndex;
this.name = name;
this.valueHolder = ByteBuffer.allocate(size);
}
public static DevFieldDefinition parseIncoming(GarminByteBufferReader garminByteBufferReader) {
int number = garminByteBufferReader.readByte();
int size = garminByteBufferReader.readByte();
int developerDataIndex = garminByteBufferReader.readByte();
return new DevFieldDefinition(number, size, developerDataIndex, "");
}
public BaseType getBaseType() {
return baseType;
}
public void setBaseType(BaseType baseType) {
this.baseType = baseType;
}
public int getDeveloperDataIndex() {
return developerDataIndex;
}
public int getFieldDefinitionNumber() {
return fieldDefinitionNumber;
}
public int getSize() {
return size;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

View File

@ -0,0 +1,82 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionTimestamp;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class FieldDefinition implements FieldInterface {
protected final BaseType baseType;
protected final int scale;
protected final int offset;
private final int number;
private final int size;
private final String name;
public FieldDefinition(int number, int size, BaseType baseType, String name, int scale, int offset) {
this.number = number;
this.size = size;
this.baseType = baseType;
this.name = name;
this.scale = scale;
this.offset = offset;
}
public FieldDefinition(int number, int size, BaseType baseType, String name) {
this(number, size, baseType, name, 1, 0);
}
public static FieldDefinition parseIncoming(GarminByteBufferReader garminByteBufferReader, GlobalFITMessage globalFITMessage) {
int number = garminByteBufferReader.readByte();
int size = garminByteBufferReader.readByte();
int baseTypeIdentifier = garminByteBufferReader.readByte();
BaseType baseType = BaseType.fromIdentifier(baseTypeIdentifier);
if (number == 253 && size == 4 && baseType.equals(BaseType.UINT32))
return new FieldDefinitionTimestamp(number, size, baseType, "253_timestamp");
FieldDefinition global = globalFITMessage.getFieldDefinition(number, size);
if (null != global && global.getBaseType().equals(baseType)) {
return global;
}
return new FieldDefinition(number, size, baseType, "");
}
public int getNumber() {
return number;
}
public int getSize() {
return size;
}
public BaseType getBaseType() {
return baseType;
}
public String getName() {
return name;
}
public void generateOutgoingPayload(MessageWriter writer) {
writer.writeByte(number);
writer.writeByte(size);
writer.writeByte(baseType.getIdentifier());
}
@Override
public Object decode(ByteBuffer byteBuffer) {
return baseType.decode(byteBuffer, scale, offset);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
baseType.encode(byteBuffer, o, scale, offset);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
baseType.invalidate(byteBuffer);
}
}

View File

@ -0,0 +1,63 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionAlarm;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionDayOfWeek;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionFileType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalSource;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionLanguage;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionTemperature;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionTimestamp;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionWeatherCondition;
public class FieldDefinitionFactory {
public static FieldDefinition create(int localNumber, int size, BaseType baseType, String name, int scale, int offset) {
return new FieldDefinition(localNumber, size, baseType, name, scale, offset);
}
public static FieldDefinition create(int localNumber, int size, FIELD field, BaseType baseType, String name, int scale, int offset) {
if (null == field) {
return new FieldDefinition(localNumber, size, baseType, name, scale, offset);
}
switch (field) {
case ALARM:
return new FieldDefinitionAlarm(localNumber, size, baseType, name);
case DAY_OF_WEEK:
return new FieldDefinitionDayOfWeek(localNumber, size, baseType, name);
case FILE_TYPE:
return new FieldDefinitionFileType(localNumber, size, baseType, name);
case GOAL_SOURCE:
return new FieldDefinitionGoalSource(localNumber, size, baseType, name);
case GOAL_TYPE:
return new FieldDefinitionGoalType(localNumber, size, baseType, name);
case MEASUREMENT_SYSTEM:
return new FieldDefinitionMeasurementSystem(localNumber, size, baseType, name);
case TEMPERATURE:
return new FieldDefinitionTemperature(localNumber, size, baseType, name);
case TIMESTAMP:
return new FieldDefinitionTimestamp(localNumber, size, baseType, name);
case WEATHER_CONDITION:
return new FieldDefinitionWeatherCondition(localNumber, size, baseType, name);
case LANGUAGE:
return new FieldDefinitionLanguage(localNumber, size, baseType, name);
default:
return new FieldDefinition(localNumber, size, baseType, name);
}
}
public enum FIELD {
ALARM,
DAY_OF_WEEK,
FILE_TYPE,
GOAL_SOURCE,
GOAL_TYPE,
MEASUREMENT_SYSTEM,
TEMPERATURE,
TIMESTAMP,
WEATHER_CONDITION,
LANGUAGE,
}
}

View File

@ -0,0 +1,12 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import java.nio.ByteBuffer;
public interface FieldInterface {
Object decode(ByteBuffer byteBuffer);
void encode(ByteBuffer byteBuffer, Object o);
void invalidate(ByteBuffer byteBuffer);
}

View File

@ -0,0 +1,217 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.ChecksumCalculator;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class FitFile {
protected static final Logger LOG = LoggerFactory.getLogger(FitFile.class);
private final Header header;
private final Map<RecordDefinition, List<RecordData>> dataRecords;
private final boolean canGenerateOutput;
public FitFile(Header header, Map<RecordDefinition, List<RecordData>> dataRecords) {
this.header = header;
this.dataRecords = dataRecords;
this.canGenerateOutput = false;
}
public FitFile(LinkedHashMap<RecordDefinition, List<RecordData>> dataRecords) {
this.dataRecords = dataRecords;
this.header = new Header(true, 16, 21117);
this.canGenerateOutput = true;
}
private static byte[] readFileToByteArray(File file) {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); InputStream inputStream = new FileInputStream(file)) {
byte[] buffer = new byte[1024];
int length;
while ((length = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, length);
}
return outputStream.toByteArray();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static FitFile parseIncoming(File file) {
return parseIncoming(readFileToByteArray(file));
}
//TODO: process file in chunks??
public static FitFile parseIncoming(byte[] fileContents) {
final GarminByteBufferReader garminByteBufferReader = new GarminByteBufferReader(fileContents);
garminByteBufferReader.setByteOrder(ByteOrder.LITTLE_ENDIAN);
final Header header = Header.parseIncomingHeader(garminByteBufferReader);
Map<RecordHeader, RecordDefinition> recordDefinitionMap = new HashMap<>(); //needed because the headers can be redefined in the file. The last header wins
Map<RecordDefinition, List<RecordData>> dataRecords = new LinkedHashMap<>();
Long referenceTimestamp = null;
while (garminByteBufferReader.getPosition() < header.getHeaderSize() + header.getDataSize()) {
byte rawRecordHeader = (byte) garminByteBufferReader.readByte();
RecordHeader recordHeader = new RecordHeader(rawRecordHeader);
if (recordHeader.isCompressedTimestamp()) {
referenceTimestamp += recordHeader.getTimeOffset();
recordHeader.setReferenceTimestamp(referenceTimestamp);
}
if (recordHeader.isDefinition()) {
final RecordDefinition recordDefinition = RecordDefinition.parseIncoming(garminByteBufferReader, recordHeader);
if (recordDefinition != null) {
if (recordHeader.isDeveloperData())
for (RecordDefinition rd : dataRecords.keySet()) {
if (GlobalFITMessage.FIELD_DESCRIPTION.equals(rd.getGlobalFITMessage()))
recordDefinition.populateDevFields(dataRecords.get(rd));
}
recordDefinitionMap.put(recordHeader, recordDefinition);
dataRecords.put(recordDefinition, new ArrayList<>());
}
} else {
final RecordDefinition referenceRecordDefinition = recordDefinitionMap.get(recordHeader);
final List<RecordData> myList = dataRecords.get(referenceRecordDefinition);
if (referenceRecordDefinition != null) {
final RecordData runningData = new RecordData(referenceRecordDefinition, recordHeader);
myList.add(runningData);
Long newTimestamp = runningData.parseDataMessage(garminByteBufferReader);
if (newTimestamp != null)
referenceTimestamp = newTimestamp;
}
}
}
garminByteBufferReader.setByteOrder(ByteOrder.LITTLE_ENDIAN);
int fileCrc = garminByteBufferReader.readShort();
if (fileCrc != ChecksumCalculator.computeCrc(fileContents, header.getHeaderSize(), fileContents.length - header.getHeaderSize() - 2)) {
throw new IllegalArgumentException("Wrong CRC for FIT file");
}
return new FitFile(header, dataRecords);
}
public List<RecordData> getRecordsByGlobalMessage(GlobalFITMessage globalFITMessage) {
final List<RecordData> filtered = new ArrayList<>();
for (RecordDefinition rd : dataRecords.keySet()) {
if (globalFITMessage.equals(rd.getGlobalFITMessage()))
filtered.addAll(dataRecords.get(rd));
}
return filtered;
}
public void generateOutgoingDataPayload(MessageWriter writer) {
if (!canGenerateOutput)
throw new IllegalArgumentException("Generation of previously parsed FIT file not supported.");
MessageWriter temporary = new MessageWriter();
temporary.setByteOrder(ByteOrder.LITTLE_ENDIAN);
for (Map.Entry<RecordDefinition, List<RecordData>> entry : dataRecords.entrySet()) {
RecordDefinition key = entry.getKey();
List<RecordData> valueList = entry.getValue();
key.generateOutgoingPayload(temporary);
for (RecordData rd :
valueList) {
rd.generateOutgoingDataPayload(temporary);
}
}
this.header.setDataSize(temporary.getSize());
this.header.generateOutgoingDataPayload(writer);
writer.writeBytes(temporary.getBytes());
writer.writeShort(ChecksumCalculator.computeCrc(writer.getBytes(), this.header.getHeaderSize(), writer.getBytes().length - this.header.getHeaderSize()));
}
@NonNull
@Override
public String toString() {
return dataRecords.toString();
}
static class Header {
public static final int MAGIC = 0x5449462E;
private final int headerSize;
private final int protocolVersion;
private final int profileVersion;
private final boolean hasCRC;
private int dataSize;
public Header(boolean hasCRC, int protocolVersion, int profileVersion) {
this(hasCRC, protocolVersion, profileVersion, 0);
}
public Header(boolean hasCRC, int protocolVersion, int profileVersion, int dataSize) {
this.hasCRC = hasCRC;
headerSize = hasCRC ? 14 : 12;
this.protocolVersion = protocolVersion;
this.profileVersion = profileVersion;
this.dataSize = dataSize;
}
static Header parseIncomingHeader(GarminByteBufferReader garminByteBufferReader) {
int headerSize = garminByteBufferReader.readByte();
if (headerSize < 12) {
throw new IllegalArgumentException("Too short header in FIT file.");
}
boolean hasCRC = headerSize == 14;
int protocolVersion = garminByteBufferReader.readByte();
int profileVersion = garminByteBufferReader.readShort();
int dataSize = garminByteBufferReader.readInt();
int magic = garminByteBufferReader.readInt();
if (magic != MAGIC) {
throw new IllegalArgumentException("Wrong magic header in FIT file");
}
if (hasCRC) {
int incomingCrc = garminByteBufferReader.readShort();
if (incomingCrc != ChecksumCalculator.computeCrc(garminByteBufferReader.asReadOnlyBuffer(), 0, headerSize - 2)) {
throw new IllegalArgumentException("Wrong CRC for header in FIT file");
}
// LOG.info("Fit File Header didn't have CRC, no check performed.");
}
return new Header(hasCRC, protocolVersion, profileVersion, dataSize);
}
public int getHeaderSize() {
return headerSize;
}
public int getDataSize() {
return dataSize;
}
public void setDataSize(int dataSize) {
this.dataSize = dataSize;
}
public void generateOutgoingDataPayload(MessageWriter writer) {
writer.setByteOrder(ByteOrder.LITTLE_ENDIAN);
writer.writeByte(headerSize);
writer.writeByte(protocolVersion);
writer.writeShort(profileVersion);
writer.writeInt(dataSize);
writer.writeInt(MAGIC);//magic
if (hasCRC)
writer.writeShort(ChecksumCalculator.computeCrc(writer.getBytes(), 0, writer.getBytes().length));
}
}
}

View File

@ -0,0 +1,256 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class GlobalFITMessage {
public static GlobalFITMessage FILE_ID = new GlobalFITMessage(0, "FILE_ID", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "type", FieldDefinitionFactory.FIELD.FILE_TYPE),
new FieldDefinitionPrimitive(1, BaseType.UINT16, "manufacturer"),
new FieldDefinitionPrimitive(2, BaseType.UINT16, "product"),
new FieldDefinitionPrimitive(3, BaseType.UINT32Z, "serial_number"),
new FieldDefinitionPrimitive(4, BaseType.UINT32, "time_created", FieldDefinitionFactory.FIELD.TIMESTAMP),
new FieldDefinitionPrimitive(5, BaseType.UINT16, "number"),
new FieldDefinitionPrimitive(6, BaseType.UINT16, "manufacturer_partner"),
new FieldDefinitionPrimitive(8, BaseType.STRING, 20, "product_name")
));
public static GlobalFITMessage DEVICE_SETTINGS = new GlobalFITMessage(2, "DEVICE_SETTINGS", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT8, "active_time_zone"),
new FieldDefinitionPrimitive(1, BaseType.UINT32, "utc_offset"),
new FieldDefinitionPrimitive(2, BaseType.UINT32, "time_offset"),
new FieldDefinitionPrimitive(4, BaseType.ENUM, "time_mode"),
new FieldDefinitionPrimitive(5, BaseType.SINT8, "time_zone_offset"),
new FieldDefinitionPrimitive(12, BaseType.ENUM, "backlight_mode"),
new FieldDefinitionPrimitive(36, BaseType.ENUM, "activity_tracker_enabled"),
new FieldDefinitionPrimitive(46, BaseType.ENUM, "move_alert_enabled"),
new FieldDefinitionPrimitive(47, BaseType.ENUM, "date_mode"),
new FieldDefinitionPrimitive(55, BaseType.ENUM, "display_orientation"),
new FieldDefinitionPrimitive(56, BaseType.ENUM, "mounting_side"),
new FieldDefinitionPrimitive(57, BaseType.UINT16, "default_page"),
new FieldDefinitionPrimitive(58, BaseType.UINT16, "autosync_min_steps"),
new FieldDefinitionPrimitive(59, BaseType.UINT16, "autosync_min_time"),
new FieldDefinitionPrimitive(86, BaseType.ENUM, "ble_auto_upload_enabled"),
new FieldDefinitionPrimitive(90, BaseType.UINT32, "auto_activity_detect")
));
public static GlobalFITMessage USER_PROFILE = new GlobalFITMessage(3, "USER_PROFILE", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.STRING, 8, "friendly_name"),
new FieldDefinitionPrimitive(1, BaseType.ENUM, "gender"),
new FieldDefinitionPrimitive(2, BaseType.UINT8, "age"),
new FieldDefinitionPrimitive(3, BaseType.UINT8, "height"),
new FieldDefinitionPrimitive(4, BaseType.UINT16, "weight", 10, 0),
new FieldDefinitionPrimitive(5, BaseType.ENUM, "language", FieldDefinitionFactory.FIELD.LANGUAGE),
new FieldDefinitionPrimitive(6, BaseType.ENUM, "elev_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(7, BaseType.ENUM, "weight_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(8, BaseType.UINT8, "resting_heart_rate"),
new FieldDefinitionPrimitive(10, BaseType.UINT8, "default_max_biking_heart_rate"),
new FieldDefinitionPrimitive(11, BaseType.UINT8, "default_max_heart_rate"),
new FieldDefinitionPrimitive(12, BaseType.ENUM, "hr_setting"),
new FieldDefinitionPrimitive(13, BaseType.ENUM, "speed_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(14, BaseType.ENUM, "dist_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(16, BaseType.ENUM, "power_setting"),
new FieldDefinitionPrimitive(17, BaseType.ENUM, "activity_class"),
new FieldDefinitionPrimitive(18, BaseType.ENUM, "position_setting"),
new FieldDefinitionPrimitive(21, BaseType.ENUM, "temperature_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(28, BaseType.UINT32, "wake_time"),
new FieldDefinitionPrimitive(29, BaseType.UINT32, "sleep_time"),
new FieldDefinitionPrimitive(30, BaseType.ENUM, "height_setting", FieldDefinitionFactory.FIELD.MEASUREMENT_SYSTEM),
new FieldDefinitionPrimitive(31, BaseType.UINT16, "user_running_step_length"),
new FieldDefinitionPrimitive(32, BaseType.UINT16, "user_walking_step_length")
));
public static GlobalFITMessage ZONES_TARGET = new GlobalFITMessage(7, "ZONES_TARGET", Arrays.asList(
new FieldDefinitionPrimitive(3, BaseType.UINT16, "functional_threshold_power"),
new FieldDefinitionPrimitive(1, BaseType.UINT8, "max_heart_rate"),
new FieldDefinitionPrimitive(2, BaseType.UINT8, "threshold_heart_rate"),
new FieldDefinitionPrimitive(5, BaseType.ENUM, "hr_calc_type"), //1=percent_max_hr
new FieldDefinitionPrimitive(7, BaseType.ENUM, "pwr_calc_type") //1=percent_ftp
));
public static GlobalFITMessage SPORT = new GlobalFITMessage(12, "SPORT", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "sport"),
new FieldDefinitionPrimitive(1, BaseType.ENUM, "sub_sport"),
new FieldDefinitionPrimitive(3, BaseType.STRING, 24, "name")
));
public static GlobalFITMessage GOALS = new GlobalFITMessage(15, "GOALS", Arrays.asList(
new FieldDefinitionPrimitive(4, BaseType.ENUM, "type", FieldDefinitionFactory.FIELD.GOAL_TYPE),
new FieldDefinitionPrimitive(7, BaseType.UINT32, "target_value"),
new FieldDefinitionPrimitive(11, BaseType.ENUM, "source", FieldDefinitionFactory.FIELD.GOAL_SOURCE)
));
public static GlobalFITMessage RECORD = new GlobalFITMessage(20, "RECORD", Arrays.asList(
new FieldDefinitionPrimitive(3, BaseType.UINT8, "heart_rate"),
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
));
public static GlobalFITMessage FILE_CREATOR = new GlobalFITMessage(49, "FILE_CREATOR", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT16, "software_version"),
new FieldDefinitionPrimitive(1, BaseType.UINT8, "hardware_version")
));
public static GlobalFITMessage CONNECTIVITY = new GlobalFITMessage(127, "CONNECTIVITY", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "bluetooth_enabled"),
new FieldDefinitionPrimitive(3, BaseType.STRING, 20, "name"),
new FieldDefinitionPrimitive(4, BaseType.ENUM, "live_tracking_enabled"),
new FieldDefinitionPrimitive(5, BaseType.ENUM, "weather_conditions_enabled"),
new FieldDefinitionPrimitive(6, BaseType.ENUM, "weather_alerts_enabled"),
new FieldDefinitionPrimitive(7, BaseType.ENUM, "auto_activity_upload_enabled"),
new FieldDefinitionPrimitive(8, BaseType.ENUM, "course_download_enabled"),
new FieldDefinitionPrimitive(9, BaseType.ENUM, "workout_download_enabled"),
new FieldDefinitionPrimitive(10, BaseType.ENUM, "gps_ephemeris_download_enabled")
));
public static GlobalFITMessage WEATHER = new GlobalFITMessage(128, "WEATHER", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "weather_report"),
new FieldDefinitionPrimitive(1, BaseType.SINT8, "temperature", FieldDefinitionFactory.FIELD.TEMPERATURE),
new FieldDefinitionPrimitive(2, BaseType.ENUM, "condition", FieldDefinitionFactory.FIELD.WEATHER_CONDITION),
new FieldDefinitionPrimitive(3, BaseType.UINT16, "wind_direction"),
new FieldDefinitionPrimitive(4, BaseType.UINT16, "wind_speed", 298, 0),
new FieldDefinitionPrimitive(5, BaseType.UINT8, "precipitation_probability"),
new FieldDefinitionPrimitive(6, BaseType.SINT8, "temperature_feels_like", FieldDefinitionFactory.FIELD.TEMPERATURE),
new FieldDefinitionPrimitive(7, BaseType.UINT8, "relative_humidity"),
new FieldDefinitionPrimitive(8, BaseType.STRING, 15, "location"),
new FieldDefinitionPrimitive(9, BaseType.UINT32, "observed_at_time", FieldDefinitionFactory.FIELD.TIMESTAMP),
new FieldDefinitionPrimitive(10, BaseType.SINT32, "observed_location_lat"),
new FieldDefinitionPrimitive(11, BaseType.SINT32, "observed_location_long"),
new FieldDefinitionPrimitive(12, BaseType.ENUM, "day_of_week", FieldDefinitionFactory.FIELD.DAY_OF_WEEK),
new FieldDefinitionPrimitive(13, BaseType.SINT8, "high_temperature", FieldDefinitionFactory.FIELD.TEMPERATURE),
new FieldDefinitionPrimitive(14, BaseType.SINT8, "low_temperature", FieldDefinitionFactory.FIELD.TEMPERATURE),
new FieldDefinitionPrimitive(15, BaseType.SINT8, "dew_point"),
new FieldDefinitionPrimitive(16, BaseType.FLOAT32, "uv_index"),
new FieldDefinitionPrimitive(17, BaseType.ENUM, "air_quality"),
new FieldDefinitionPrimitive(253, BaseType.UINT32, "timestamp", FieldDefinitionFactory.FIELD.TIMESTAMP)
));
public static GlobalFITMessage WATCHFACE_SETTINGS = new GlobalFITMessage(159, "WATCHFACE_SETTINGS", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.ENUM, "mode"), //1=analog
new FieldDefinitionPrimitive(1, BaseType.BASE_TYPE_BYTE, "layout")
));
public static GlobalFITMessage FIELD_DESCRIPTION = new GlobalFITMessage(206, "FIELD_DESCRIPTION", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT8, "developer_data_index"),
new FieldDefinitionPrimitive(1, BaseType.UINT8, "field_definition_number"),
new FieldDefinitionPrimitive(2, BaseType.UINT8, "fit_base_type_id"),
new FieldDefinitionPrimitive(3, BaseType.STRING, 64, "field_name"),
new FieldDefinitionPrimitive(8, BaseType.STRING, 16, "units")
));
public static GlobalFITMessage DEVELOPER_DATA = new GlobalFITMessage(207, "DEVELOPER_DATA", Arrays.asList(
new FieldDefinitionPrimitive(1, BaseType.BASE_TYPE_BYTE, 16, "application_id"),
new FieldDefinitionPrimitive(3, BaseType.UINT8, "developer_data_index")
));
// UNK_216(216, null), //activity
public static GlobalFITMessage ALARM_SETTINGS = new GlobalFITMessage(222, "ALARM_SETTINGS", Arrays.asList(
new FieldDefinitionPrimitive(0, BaseType.UINT16, "time", FieldDefinitionFactory.FIELD.ALARM)
));
public static Map<Integer, GlobalFITMessage> KNOWNMESSAGES = new HashMap<Integer, GlobalFITMessage>() {{
put(0, FILE_ID);
put(2, DEVICE_SETTINGS);
put(3, USER_PROFILE);
put(7, ZONES_TARGET);
put(12, SPORT);
put(15, GOALS);
put(20, RECORD);
put(49, FILE_CREATOR);
put(127, CONNECTIVITY);
put(128, WEATHER);
put(159, WATCHFACE_SETTINGS);
put(206, FIELD_DESCRIPTION);
put(207, DEVELOPER_DATA);
put(222, ALARM_SETTINGS);
}};
private final int number;
private final String name;
private final List<FieldDefinitionPrimitive> fieldDefinitionPrimitives;
GlobalFITMessage(int number, String name, List<FieldDefinitionPrimitive> fieldDefinitionPrimitives) {
this.number = number;
this.name = name;
this.fieldDefinitionPrimitives = fieldDefinitionPrimitives;
}
public static GlobalFITMessage fromNumber(final int number) {
final GlobalFITMessage found = KNOWNMESSAGES.get(number);
if (found != null) {
return found;
}
return new GlobalFITMessage(number, "UNK_" + number, null);
}
public String name() {
return this.name;
}
public int getNumber() {
return number;
}
@Nullable
public List<FieldDefinition> getFieldDefinitions(int... ids) {
if (null == fieldDefinitionPrimitives)
return null;
List<FieldDefinition> subset = new ArrayList<>(ids.length);
for (int id :
ids) {
for (FieldDefinitionPrimitive fieldDefinitionPrimitive :
fieldDefinitionPrimitives) {
if (fieldDefinitionPrimitive.number == id) {
subset.add(FieldDefinitionFactory.create(fieldDefinitionPrimitive.number, fieldDefinitionPrimitive.size, fieldDefinitionPrimitive.type, fieldDefinitionPrimitive.baseType, fieldDefinitionPrimitive.name, fieldDefinitionPrimitive.scale, fieldDefinitionPrimitive.offset));
}
}
}
return subset;
}
@Nullable
public FieldDefinition getFieldDefinition(int id, int size) {
if (null == fieldDefinitionPrimitives)
return null;
for (GlobalFITMessage.FieldDefinitionPrimitive fieldDefinitionPrimitive :
fieldDefinitionPrimitives) {
if (fieldDefinitionPrimitive.number == id) {
return FieldDefinitionFactory.create(fieldDefinitionPrimitive.number, size, fieldDefinitionPrimitive.type, fieldDefinitionPrimitive.baseType, fieldDefinitionPrimitive.name, fieldDefinitionPrimitive.scale, fieldDefinitionPrimitive.offset);
}
}
return null;
}
static class FieldDefinitionPrimitive {
private final int number;
private final BaseType baseType;
private final String name;
private final FieldDefinitionFactory.FIELD type;
private final int scale;
private final int offset;
private final int size;
public FieldDefinitionPrimitive(int number, BaseType baseType, int size, String name, FieldDefinitionFactory.FIELD type, int scale, int offset) {
this.number = number;
this.baseType = baseType;
this.size = size;
this.name = name;
this.type = type;
this.scale = scale;
this.offset = offset;
}
public FieldDefinitionPrimitive(int number, BaseType baseType, String name, FieldDefinitionFactory.FIELD type) {
this(number, baseType, baseType.getSize(), name, type, 1, 0);
}
public FieldDefinitionPrimitive(int number, BaseType baseType, String name) {
this(number, baseType, baseType.getSize(), name, null, 1, 0);
}
public FieldDefinitionPrimitive(int number, BaseType baseType, int size, String name) {
this(number, baseType, size, name, null, 1, 0);
}
public FieldDefinitionPrimitive(int number, BaseType baseType, String name, int scale, int offset) {
this(number, baseType, baseType.getSize(), name, null, scale, offset);
}
}
}

View File

@ -0,0 +1,54 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.Nullable;
import java.nio.ByteOrder;
import java.util.List;
public enum PredefinedLocalMessage {
TODAY_WEATHER_CONDITIONS(6, GlobalFITMessage.WEATHER,
new int[]{0, 253, 9, 1, 14, 13, 2, 3, 5, 4, 6, 7, 10, 11, 8}
),
HOURLY_WEATHER_FORECAST(9, GlobalFITMessage.WEATHER,
new int[]{0, 253, 1, 2, 3, 4, 5, 7, 15, 16, 17}
),
DAILY_WEATHER_FORECAST(10, GlobalFITMessage.WEATHER,
new int[]{0, 253, 14, 13, 2, 5, 12}
);
private final int type;
private final GlobalFITMessage globalFITMessage;
private final int[] globalDefinitionIds;
PredefinedLocalMessage(int type, GlobalFITMessage globalFITMessage, int[] globalDefinitionIds) {
this.type = type;
this.globalFITMessage = globalFITMessage;
this.globalDefinitionIds = globalDefinitionIds;
}
@Nullable
public static PredefinedLocalMessage fromType(int type) {
for (final PredefinedLocalMessage predefinedLocalMessage : PredefinedLocalMessage.values()) {
if (predefinedLocalMessage.getType() == type) {
return predefinedLocalMessage;
}
}
return null;
}
public List<FieldDefinition> getLocalFieldDefinitions() {
return globalFITMessage.getFieldDefinitions(globalDefinitionIds);
}
public RecordDefinition getRecordDefinition() {
return new RecordDefinition(ByteOrder.BIG_ENDIAN, this);
}
public int getType() {
return type;
}
public GlobalFITMessage getGlobalFITMessage() {
return globalFITMessage;
}
}

View File

@ -0,0 +1,279 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.NonNull;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionTimestamp;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
import nodomain.freeyourgadget.gadgetbridge.util.ArrayUtils;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType.STRING;
public class RecordData {
private final RecordHeader recordHeader;
private final GlobalFITMessage globalFITMessage;
private final List<FieldData> fieldDataList;
protected ByteBuffer valueHolder;
public RecordData(RecordDefinition recordDefinition, RecordHeader recordHeader) {
if (null == recordDefinition.getFieldDefinitions())
throw new IllegalArgumentException("Cannot create record data without FieldDefinitions " + recordDefinition);
fieldDataList = new ArrayList<>();
this.recordHeader = recordHeader;
this.globalFITMessage = recordDefinition.getGlobalFITMessage();
int totalSize = 0;
for (FieldDefinition fieldDef :
recordDefinition.getFieldDefinitions()) {
fieldDataList.add(new FieldData(fieldDef, totalSize));
totalSize += fieldDef.getSize();
}
if (recordDefinition.getDevFieldDefinitions() != null) {
for (DevFieldDefinition fieldDef :
recordDefinition.getDevFieldDefinitions()) {
FieldDefinition temp = new FieldDefinition(fieldDef.getFieldDefinitionNumber(), fieldDef.getSize(), fieldDef.getBaseType(), fieldDef.getName());
fieldDataList.add(new FieldData(temp, totalSize));
totalSize += fieldDef.getSize();
}
}
this.valueHolder = ByteBuffer.allocate(totalSize);
valueHolder.order(recordDefinition.getByteOrder());
for (FieldData fieldData :
fieldDataList) {
fieldData.invalidate();
}
}
public RecordData(RecordDefinition recordDefinition) {
this(recordDefinition, recordDefinition.getRecordHeader());
}
public GlobalFITMessage getGlobalFITMessage() {
return globalFITMessage;
}
public Long parseDataMessage(GarminByteBufferReader garminByteBufferReader) {
garminByteBufferReader.setByteOrder(valueHolder.order());
Long referenceTimestamp = null;
for (FieldData fieldData : fieldDataList) {
Long runningTimestamp = fieldData.parseDataMessage(garminByteBufferReader);
if (runningTimestamp != null)
referenceTimestamp = runningTimestamp;
}
return referenceTimestamp;
}
public void generateOutgoingDataPayload(MessageWriter writer) {
writer.writeByte(recordHeader.generateOutgoingDataPayload());
writer.writeBytes(valueHolder.array());
}
public void setFieldByNumber(int number, Object... value) {
boolean found = false;
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getNumber() == number) {
fieldData.encode(value);
found = true;
break;
}
}
if (!found) {
throw new IllegalArgumentException("Unknown field number " + number);
}
}
public void setFieldByName(String name, Object... value) {
boolean found = false;
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getName().equals(name)) {
fieldData.encode(value);
found = true;
break;
}
}
if (!found) {
throw new IllegalArgumentException("Unknown field name " + name);
}
}
public Object getFieldByNumber(int number) {
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getNumber() == number) {
return fieldData.decode();
}
}
throw new IllegalArgumentException("Unknown field number " + number);
}
public Object getFieldByName(String name) {
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getName().equals(name)) {
return fieldData.decode();
}
}
throw new IllegalArgumentException("Unknown field name " + name);
}
public int[] getFieldsNumbers() {
int[] arr = new int[fieldDataList.size()];
int count = 0;
for (FieldData fieldData : fieldDataList) {
int number = fieldData.getNumber();
arr[count++] = number;
}
return arr;
}
public Long getComputedTimestamp() {
for (FieldData fieldData : fieldDataList) {
if (fieldData.getNumber() == 253 || fieldData.fieldDefinition instanceof FieldDefinitionTimestamp)
return (long) fieldData.decode();
}
if (recordHeader.isCompressedTimestamp())
return (long) recordHeader.getResultingTimestamp();
return null;
}
@NonNull
public String toString() {
StringBuilder oBuilder = new StringBuilder();
oBuilder.append(System.lineSeparator());
for (FieldData fieldData :
fieldDataList) {
if (fieldData.getName() != null && !fieldData.getName().equals("")) {
oBuilder.append(fieldData.getName());
} else {
oBuilder.append("unknown_" + fieldData.getNumber());
}
oBuilder.append(fieldData);
oBuilder.append(": ");
Object o = fieldData.decode();
if (o instanceof Object[]) {
oBuilder.append("[");
oBuilder.append(org.apache.commons.lang3.StringUtils.join((Object[]) o, ","));
oBuilder.append("]");
} else {
oBuilder.append(o);
}
oBuilder.append(" ");
}
if (recordHeader.isCompressedTimestamp())
oBuilder.append("compressed_timestamp: " + getComputedTimestamp());
return oBuilder.toString();
}
public PredefinedLocalMessage getPredefinedLocalMessage() {
return recordHeader.getPredefinedLocalMessage();
}
private class FieldData {
private final FieldDefinition fieldDefinition;
private final int position;
private final int size;
private final int baseSize;
public FieldData(FieldDefinition fieldDefinition, int position) {
this.fieldDefinition = fieldDefinition;
this.position = position;
this.size = fieldDefinition.getSize();
this.baseSize = fieldDefinition.getBaseType().getSize();
}
private String getName() {
return fieldDefinition.getName();
}
private int getNumber() {
return fieldDefinition.getNumber();
}
private void invalidate() {
goToPosition();
if (STRING.equals(fieldDefinition.getBaseType())) {
for (int i = 0; i < size; i++) {
valueHolder.put((byte) 0);
}
return;
}
for (int i = 0; i < (size / baseSize); i++) {
fieldDefinition.invalidate(valueHolder);
}
}
private void goToPosition() {
valueHolder.position(position);
}
private Long parseDataMessage(GarminByteBufferReader garminByteBufferReader) {
goToPosition();
valueHolder.put(garminByteBufferReader.readBytes(size));
if (fieldDefinition instanceof FieldDefinitionTimestamp)
return (Long) decode();
return null;
}
private void encode(Object... objects) {
if (objects[0] instanceof boolean[] || objects[0] instanceof short[] || objects[0] instanceof int[] || objects[0] instanceof long[] || objects[0] instanceof float[] || objects[0] instanceof double[]) {
throw new IllegalArgumentException("Array of primitive types not supported, box them to objects");
}
goToPosition();
final int slots = size / baseSize;
int i = 0;
for (Object o : objects) {
if (i++ >= slots) {
throw new IllegalArgumentException("Number of elements in array was too big for the field");
}
if (STRING.equals(fieldDefinition.getBaseType())) {
final byte[] bytes = ((String) o).getBytes(StandardCharsets.UTF_8);
valueHolder.put(Arrays.copyOf(bytes, Math.min(this.size - 1, bytes.length)));
valueHolder.put((byte) 0);
return;
}
fieldDefinition.encode(valueHolder, o);
}
}
private Object decode() {
goToPosition();
if (STRING.equals(fieldDefinition.getBaseType())) {
final byte[] bytes = new byte[size];
valueHolder.get(bytes);
final int zero = ArrayUtils.indexOf((byte) 0, bytes);
if (zero < 0) {
return new String(bytes, StandardCharsets.UTF_8);
}
return new String(bytes, 0, zero, StandardCharsets.UTF_8);
}
if (size > baseSize) {
Object[] arr = new Object[size / baseSize];
for (int i = 0; i < arr.length; i++) {
arr[i] = fieldDefinition.decode(valueHolder);
}
return arr;
}
return fieldDefinition.decode(valueHolder);
}
public String toString() {
return "(" + fieldDefinition.getBaseType().name() + "/" + size + ")";
}
}
}

View File

@ -0,0 +1,145 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminByteBufferReader;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.messages.MessageWriter;
public class RecordDefinition {
private final RecordHeader recordHeader;
private final GlobalFITMessage globalFITMessage;
private final PredefinedLocalMessage predefinedLocalMessage;
private final java.nio.ByteOrder byteOrder;
private List<FieldDefinition> fieldDefinitions;
private List<DevFieldDefinition> devFieldDefinitions;
public RecordDefinition(RecordHeader recordHeader, ByteOrder byteOrder, PredefinedLocalMessage predefinedLocalMessage, GlobalFITMessage globalFITMessage, List<FieldDefinition> fieldDefinitions, List<DevFieldDefinition> devFieldDefinitions) {
this.recordHeader = recordHeader;
this.byteOrder = byteOrder;
this.predefinedLocalMessage = predefinedLocalMessage;
this.globalFITMessage = globalFITMessage;
this.fieldDefinitions = fieldDefinitions;
this.devFieldDefinitions = devFieldDefinitions;
}
public RecordDefinition(ByteOrder byteOrder, PredefinedLocalMessage predefinedLocalMessage) {
this(new RecordHeader(true, false, predefinedLocalMessage, null), byteOrder, predefinedLocalMessage, predefinedLocalMessage.getGlobalFITMessage(), predefinedLocalMessage.getLocalFieldDefinitions(), null);
}
public RecordDefinition(ByteOrder byteOrder, RecordHeader recordHeader, GlobalFITMessage globalFITMessage, List<FieldDefinition> fieldDefinitions) {
this(recordHeader, byteOrder, null, globalFITMessage, fieldDefinitions, null);
}
public static RecordDefinition parseIncoming(GarminByteBufferReader garminByteBufferReader, RecordHeader recordHeader) {
if (!recordHeader.isDefinition())
return null;
garminByteBufferReader.readByte();//ignore
ByteOrder byteOrder = garminByteBufferReader.readByte() == 0x01 ? ByteOrder.BIG_ENDIAN : ByteOrder.LITTLE_ENDIAN;
garminByteBufferReader.setByteOrder(byteOrder);
final int globalMesgNum = garminByteBufferReader.readShort();
final GlobalFITMessage globalFITMessage = GlobalFITMessage.fromNumber(globalMesgNum);
RecordDefinition definitionMessage = new RecordDefinition(byteOrder, recordHeader, globalFITMessage, null);
final int numFields = garminByteBufferReader.readByte();
List<FieldDefinition> fieldDefinitions = new ArrayList<>(numFields);
for (int i = 0; i < numFields; i++) {
fieldDefinitions.add(FieldDefinition.parseIncoming(garminByteBufferReader, globalFITMessage));
}
definitionMessage.setFieldDefinitions(fieldDefinitions);
if (recordHeader.isDeveloperData()) {
final int numDevFields = garminByteBufferReader.readByte();
List<DevFieldDefinition> devFieldDefinitions = new ArrayList<>(numDevFields);
for (int i = 0; i < numDevFields; i++) {
devFieldDefinitions.add(DevFieldDefinition.parseIncoming(garminByteBufferReader));
}
definitionMessage.setDevFieldDefinitions(devFieldDefinitions);
}
return definitionMessage;
}
public GlobalFITMessage getGlobalFITMessage() {
return globalFITMessage;
}
public ByteOrder getByteOrder() {
return byteOrder;
}
public List<DevFieldDefinition> getDevFieldDefinitions() {
return devFieldDefinitions;
}
public void setDevFieldDefinitions(List<DevFieldDefinition> devFieldDefinitions) {
this.devFieldDefinitions = devFieldDefinitions;
}
public RecordHeader getRecordHeader() {
return recordHeader;
}
@Nullable
public List<FieldDefinition> getFieldDefinitions() {
return fieldDefinitions;
}
public void setFieldDefinitions(List<FieldDefinition> fieldDefinitions) {
this.fieldDefinitions = fieldDefinitions;
}
public void generateOutgoingPayload(MessageWriter writer) {
writer.writeByte(recordHeader.generateOutgoingDefinitionPayload());
writer.writeByte(0);//ignore
writer.writeByte(byteOrder == ByteOrder.LITTLE_ENDIAN ? 0 : 1);
writer.setByteOrder(byteOrder);
writer.writeShort(globalFITMessage.getNumber());
if (fieldDefinitions != null) {
writer.writeByte(fieldDefinitions.size());
for (FieldDefinition fieldDefinition : fieldDefinitions) {
fieldDefinition.generateOutgoingPayload(writer);
}
}
}
public String getName() {
return predefinedLocalMessage != null ? predefinedLocalMessage.name() : "unknown_" + globalFITMessage;
}
@NonNull
public String toString() {
return System.lineSeparator() + recordHeader.toString() +
" Global Message Number: " + globalFITMessage.name();
}
public void populateDevFields(List<RecordData> developerFieldData) {
for (DevFieldDefinition devFieldDef :
getDevFieldDefinitions()) {
for (RecordData recordData :
developerFieldData) {
try {
if (devFieldDef.getFieldDefinitionNumber() == (int) recordData.getFieldByName("field_definition_number") &&
devFieldDef.getDeveloperDataIndex() == (int) recordData.getFieldByName("developer_data_index")) {
BaseType baseType = BaseType.fromIdentifier((int) recordData.getFieldByName("fit_base_type_id"));
devFieldDef.setBaseType(baseType);
devFieldDef.setName((String) recordData.getFieldByName("field_name"));
}
} catch (Exception e) {
//ignore
}
}
}
}
}

View File

@ -0,0 +1,121 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class RecordHeader {
private final boolean definition;
private final boolean developerData;
private final PredefinedLocalMessage predefinedLocalMessage;
private final int rawLocalMessageType;
private final Integer timeOffset;
private long referenceTimestamp;
public RecordHeader(boolean definition, boolean developerData, PredefinedLocalMessage predefinedLocalMessage, Integer timeOffset) {
this.definition = definition;
this.developerData = developerData;
this.predefinedLocalMessage = predefinedLocalMessage;
this.rawLocalMessageType = predefinedLocalMessage.getType();
this.timeOffset = timeOffset;
}
//see https://github.com/polyvertex/fitdecode/blob/master/fitdecode/reader.py#L512
public RecordHeader(byte header) {
this(header, false);
}
public RecordHeader(byte header, boolean inferLocalMessage) {
if ((header & 0x80) == 0x80) { //compressed timestamp TODO add support
definition = false;
developerData = false;
rawLocalMessageType = (header >> 5) & 0x3;
timeOffset = header & 0x1f;
} else {
definition = ((header & 0x40) == 0x40);
developerData = ((header & 0x20) == 0x20);
rawLocalMessageType = header & 0xf;
timeOffset = null;
}
if (inferLocalMessage)
predefinedLocalMessage = PredefinedLocalMessage.fromType(rawLocalMessageType);
else
predefinedLocalMessage = null;
}
public void setReferenceTimestamp(long referenceTimestamp) {
this.referenceTimestamp = referenceTimestamp;
}
public Integer getTimeOffset() {
return timeOffset;
}
public boolean isCompressedTimestamp() {
return timeOffset != null;
}
public Long getResultingTimestamp() {
return referenceTimestamp + timeOffset;
}
public boolean isDeveloperData() {
return developerData;
}
public boolean isDefinition() {
return definition;
}
@Nullable
public PredefinedLocalMessage getPredefinedLocalMessage() {
return predefinedLocalMessage;
}
public byte generateOutgoingDefinitionPayload() {
if (!definition && !developerData)
return (byte) (timeOffset | (((byte) predefinedLocalMessage.getType()) << 5));
byte base = (byte) (null == predefinedLocalMessage ? rawLocalMessageType : predefinedLocalMessage.getType());
if (definition)
base = (byte) (base | 0x40);
if (developerData)
base = (byte) (base | 0x20);
return base;
}
public byte generateOutgoingDataPayload() { //TODO: unclear if correct
if (!definition && !developerData)
return (byte) (timeOffset | (((byte) predefinedLocalMessage.getType()) << 5));
byte base = (byte) (null == predefinedLocalMessage ? rawLocalMessageType : predefinedLocalMessage.getType());
if (developerData)
base = (byte) (base | 0x20);
return base;
}
@NonNull
@Override
public String toString() {
return "Local Message: " + (null == predefinedLocalMessage ? "raw: " + rawLocalMessageType : "type: " + predefinedLocalMessage.name());
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
RecordHeader that = (RecordHeader) o;
if (rawLocalMessageType != that.rawLocalMessageType) return false;
return predefinedLocalMessage == that.predefinedLocalMessage;
}
@Override
public int hashCode() {
int result = (predefinedLocalMessage != null ? predefinedLocalMessage.hashCode() : 0);
result = 31 * result + rawLocalMessageType;
return result;
}
}

View File

@ -0,0 +1,62 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
//see https://github.com/dtcooper/python-fitparse/blob/master/fitparse/records.py
public enum BaseType {
ENUM(0x00, new BaseTypeByte(true, 0xFF)),
SINT8(0x01, new BaseTypeByte(false, 0x7F)),
UINT8(0x02, new BaseTypeByte(true, 0xFF)),
SINT16(0x83, new BaseTypeShort(false, 0x7FFF)),
UINT16(0x84, new BaseTypeShort(true, 0xFFFF)),
SINT32(0x85, new BaseTypeInt(false, 0x7FFFFFFF)),
UINT32(0x86, new BaseTypeInt(true, 0xFFFFFFFFL)),
STRING(0x07, new BaseTypeByte(true, 0x00)),
FLOAT32(0x88, new BaseTypeFloat()),
FLOAT64(0x89, new BaseTypeDouble()),
UINT8Z(0x0A, new BaseTypeByte(true, 0x00)),
UINT16Z(0x8B, new BaseTypeShort(true, 0)),
UINT32Z(0x8C, new BaseTypeInt(true, 0)),
BASE_TYPE_BYTE(0x0D, new BaseTypeByte(true, 0xFF)),
SINT64(0x8E, new BaseTypeLong(false, 0x7FFFFFFFFFFFFFFFL)),
UINT64(0x8F, new BaseTypeLong(true, 0xFFFFFFFFFFFFFFFFL)),
UINT64Z(0x8F, new BaseTypeLong(true, 0)),
;
private final int identifier;
private final BaseTypeInterface baseTypeInterface;
BaseType(int identifier, BaseTypeInterface byteBaseType) {
this.identifier = identifier;
this.baseTypeInterface = byteBaseType;
}
public static BaseType fromIdentifier(int identifier) {
for (final BaseType baseType : BaseType.values()) {
if (baseType.getIdentifier() == identifier) {
return baseType;
}
}
throw new IllegalArgumentException("Unknown type " + identifier);
}
public int getSize() {
return baseTypeInterface.getByteSize();
}
public int getIdentifier() {
return identifier;
}
public Object decode(ByteBuffer byteBuffer, int scale, int offset) {
return baseTypeInterface.decode(byteBuffer, scale, offset);
}
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
baseTypeInterface.encode(byteBuffer, o, scale, offset);
}
public void invalidate(ByteBuffer byteBuffer) {
baseTypeInterface.invalidate(byteBuffer);
}
}

View File

@ -0,0 +1,59 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeByte implements BaseTypeInterface {
private final int min;
private final int max;
private final int invalid;
private final boolean unsigned;
private final int size = 1;
BaseTypeByte(boolean unsigned, int invalid) {
if (unsigned) {
min = 0;
max = 0xff;
} else {
min = Byte.MIN_VALUE;
max = Byte.MAX_VALUE;
}
this.invalid = invalid;
this.unsigned = unsigned;
}
public int getByteSize() {
return size;
}
@Override
public Object decode(final ByteBuffer byteBuffer, int scale, int offset) {
int b = unsigned ? Byte.toUnsignedInt(byteBuffer.get()) : byteBuffer.get();
if (b < min || b > max)
return null;
if (b == invalid)
return null;
return (b + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
int i = ((Number) o).intValue() * scale - offset;
if (i < min || i > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.put((byte) i);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.put((byte) invalid);
}
}

View File

@ -0,0 +1,50 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeDouble implements BaseTypeInterface {
private final int size = 8;
private final double min;
private final double max;
private final double invalid;
BaseTypeDouble() {
this.min = -Double.MAX_VALUE;
this.max = Double.MAX_VALUE;
this.invalid = Double.longBitsToDouble(0xFFFFFFFFFFFFFFFFL);
}
public int getByteSize() {
return size;
}
@Override
public Object decode(final ByteBuffer byteBuffer, int scale, int offset) {
double d = byteBuffer.getDouble();
if (d < min || d > max) {
return null;
}
if (Double.isNaN(d) || d == invalid)
return null;
return (d + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
double d = ((Number) o).doubleValue() * scale - offset;
if (d < min || d > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.putDouble(d);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putDouble(invalid);
}
}

View File

@ -0,0 +1,51 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeFloat implements BaseTypeInterface {
private final int size = 4;
private final double min;
private final double max;
private final double invalid;
BaseTypeFloat() {
this.min = -Float.MAX_VALUE;
this.max = Float.MAX_VALUE;
this.invalid = Float.intBitsToFloat(0xFFFFFFFF);
}
public int getByteSize() {
return size;
}
@Override
public Object decode(ByteBuffer byteBuffer, int scale, int offset) {
float f = byteBuffer.getFloat();
if (f < min || f > max) {
return null;
}
if (Float.isNaN(f) || f == invalid)
return null;
return (f + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
float f = ((Number) o).floatValue() * scale - offset;
if (f < min || f > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.putFloat((float) f);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putFloat((float) invalid);
}
}

View File

@ -0,0 +1,57 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeInt implements BaseTypeInterface {
private final long min;
private final long max;
private final long invalid;
private final boolean unsigned;
private final int size = 4;
BaseTypeInt(boolean unsigned, long invalid) {
if (unsigned) {
this.min = 0;
this.max = 0xffffffffL;
} else {
this.min = Integer.MIN_VALUE;
this.max = Integer.MAX_VALUE;
}
this.invalid = invalid;
this.unsigned = unsigned;
}
public int getByteSize() {
return size;
}
@Override
public Object decode(final ByteBuffer byteBuffer, int scale, int offset) {
long i = unsigned ? Integer.toUnsignedLong(byteBuffer.getInt()) : byteBuffer.getInt();
if (i < min || i > max)
return null;
if (i == invalid)
return null;
return ((i + offset) / scale);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
long l = ((Number) o).longValue() * scale - offset;
if (l < min || l > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.putInt((int) l);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putInt((int) invalid);
}
}

View File

@ -0,0 +1,13 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public interface BaseTypeInterface {
int getByteSize();
Object decode(ByteBuffer byteBuffer, int scale, int offset);
void encode(ByteBuffer byteBuffer, Object o, int scale, int offset);
void invalidate(ByteBuffer byteBuffer);
}

View File

@ -0,0 +1,58 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.math.BigInteger;
import java.nio.ByteBuffer;
public class BaseTypeLong implements BaseTypeInterface {
private final int size = 8;
private final BigInteger min;
private final BigInteger max;
private final long invalid;
private final boolean unsigned;
BaseTypeLong(boolean unsigned, long invalid) {
if (unsigned) {
this.min = BigInteger.valueOf(0);
this.max = BigInteger.valueOf(0xFFFFFFFFFFFFFFFFL);
} else {
this.min = BigInteger.valueOf(Long.MIN_VALUE);
this.max = BigInteger.valueOf(Long.MAX_VALUE);
}
this.invalid = invalid;
this.unsigned = unsigned;
}
public int getByteSize() {
return size;
}
@Override
public Object decode(ByteBuffer byteBuffer, int scale, int offset) {
BigInteger i = unsigned ? BigInteger.valueOf(byteBuffer.getLong() & 0xFFFFFFFFFFFFFFFFL) : BigInteger.valueOf(byteBuffer.getLong());
if (!unsigned && (i.compareTo(min) < 0 || i.compareTo(max) > 0))
return null;
if (i.compareTo(BigInteger.valueOf(invalid)) == 0)
return null;
return (i.longValue() + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
BigInteger i = BigInteger.valueOf(((Number) o).longValue() * scale - offset);
if (!unsigned && (i.compareTo(min) < 0 || i.compareTo(max) > 0)) {
invalidate(byteBuffer);
return;
}
byteBuffer.putLong(i.longValue());
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putLong((long) invalid);
}
}

View File

@ -0,0 +1,56 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes;
import java.nio.ByteBuffer;
public class BaseTypeShort implements BaseTypeInterface {
private final int min;
private final int max;
private final int invalid;
private final boolean unsigned;
private final int size = 2;
BaseTypeShort(boolean unsigned, int invalid) {
if (unsigned) {
this.min = 0;
this.max = 0xffff;
} else {
this.min = Short.MIN_VALUE;
this.max = Short.MAX_VALUE;
}
this.invalid = invalid;
this.unsigned = unsigned;
}
public int getByteSize() {
return size;
}
@Override
public Object decode(final ByteBuffer byteBuffer, int scale, int offset) {
int s = unsigned ? Short.toUnsignedInt(byteBuffer.getShort()) : byteBuffer.getShort();
if (s < min || s > max)
return null;
if (s == invalid)
return null;
return (s + offset) / scale;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o, int scale, int offset) {
if (null == o) {
invalidate(byteBuffer);
return;
}
int i = ((Number) o).intValue() * scale - offset;
if (i < min || i > max) {
invalidate(byteBuffer);
return;
}
byteBuffer.putShort((short) i);
}
@Override
public void invalidate(ByteBuffer byteBuffer) {
byteBuffer.putShort((short) invalid);
}
}

View File

@ -0,0 +1,31 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import java.nio.ByteBuffer;
import java.util.Calendar;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionAlarm extends FieldDefinition {
public FieldDefinitionAlarm(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
Calendar calendar = Calendar.getInstance();
calendar.set(Calendar.HOUR_OF_DAY, Math.round(raw / 60));
calendar.set(Calendar.MINUTE, raw % 60);
return calendar;
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Calendar) {
baseType.encode(byteBuffer, ((Calendar) o).get(Calendar.HOUR_OF_DAY) * 60 + ((Calendar) o).get(Calendar.MINUTE), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
}

View File

@ -0,0 +1,32 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import org.threeten.bp.DayOfWeek;
import org.threeten.bp.Instant;
import org.threeten.bp.ZoneId;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionDayOfWeek extends FieldDefinition {
public FieldDefinitionDayOfWeek(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return DayOfWeek.of(raw == 0 ? 7 : raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof DayOfWeek) {
baseType.encode(byteBuffer, (((DayOfWeek) o).getValue() % 7), scale, offset);
return;
}
baseType.encode(byteBuffer, (Instant.ofEpochSecond((int) o).atZone(ZoneId.systemDefault()).getDayOfWeek().getValue() % 7), scale, offset);
}
}

View File

@ -0,0 +1,62 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionFileType extends FieldDefinition {
public FieldDefinitionFileType(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Type.fromId(raw) == null ? raw : Type.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Type) {
baseType.encode(byteBuffer, (((Type) o).getId()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
public enum Type {
settings(2),
activity(4), //FIT_TYPE_4 stands for activity directory
goals(11),
monitor(32), //FIT_TYPE_32
changelog(41), // FIT_TYPE_41 stands for changelog directory
metrics(44), //FIT_TYPE_41
sleep(49), //FIT_TYPE_49
;
private final int id;
Type(int i) {
this.id = i;
}
@Nullable
public static Type fromId(int id) {
for (Type type :
Type.values()) {
if (id == type.getId()) {
return type;
}
}
return null;
}
public int getId() {
return this.id;
}
}
}

View File

@ -0,0 +1,48 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionGoalSource extends FieldDefinition {
public FieldDefinitionGoalSource(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Source.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Source) {
baseType.encode(byteBuffer, (((Source) o).ordinal()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
public enum Source {
auto,
community,
manual,
;
@Nullable
public static Source fromId(int id) {
for (Source source :
Source.values()) {
if (id == source.ordinal()) {
return source;
}
}
return null;
}
}
}

View File

@ -0,0 +1,56 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionGoalType extends FieldDefinition {
public FieldDefinitionGoalType(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Type.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Type) {
baseType.encode(byteBuffer, (((Type) o).getId()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
public enum Type {
steps(4),
;
private final int id;
Type(int i) {
id = i;
}
@Nullable
public static Type fromId(int id) {
for (Type type :
Type.values()) {
if (id == type.getId()) {
return type;
}
}
return null;
}
public int getId() {
return id;
}
}
}

View File

@ -0,0 +1,57 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionLanguage extends FieldDefinition {
public FieldDefinitionLanguage(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Language.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Language) {
baseType.encode(byteBuffer, (((Language) o).getId()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
private enum Language {
english(0),
italian(2),
;
private final int id;
Language(int i) {
id = i;
}
@Nullable
public static Language fromId(int id) {
for (Language language :
Language.values()) {
if (id == language.getId()) {
return language;
}
}
return null;
}
public int getId() {
return id;
}
}
}

View File

@ -0,0 +1,44 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionMeasurementSystem extends FieldDefinition {
public FieldDefinitionMeasurementSystem(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int raw = (int) baseType.decode(byteBuffer, scale, offset);
return Type.fromId(raw) == null ? raw : Type.fromId(raw);
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Type) {
baseType.encode(byteBuffer, (((Type) o).ordinal()), scale, offset);
return;
}
baseType.encode(byteBuffer, o, scale, offset);
}
public enum Type {
metric,
;
public static Type fromId(int id) {
for (Type type :
Type.values()) {
if (type.ordinal() == id) {
return type;
}
}
return null;
}
}
}

View File

@ -0,0 +1,12 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionTemperature extends FieldDefinition {
public FieldDefinitionTemperature(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 273);
}
}

View File

@ -0,0 +1,26 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
import static nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminTimeUtils.GARMIN_TIME_EPOCH;
public class FieldDefinitionTimestamp extends FieldDefinition {
public FieldDefinitionTimestamp(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, GARMIN_TIME_EPOCH);
}
// @Override
// public Object decode(ByteBuffer byteBuffer) {
// return new Timestamp((long) baseType.decode(byteBuffer, scale, offset) * 1000L);
// }
//
// @Override
// public void encode(ByteBuffer byteBuffer, Object o) {
// if(o instanceof Timestamp) {
// baseType.encode(byteBuffer, (int) (((Timestamp) o).getTime() / 1000L), scale, offset);
// return;
// }
// baseType.encode(byteBuffer, o, scale, offset);
// }
}

View File

@ -0,0 +1,171 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions;
import java.nio.ByteBuffer;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.FieldDefinition;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.baseTypes.BaseType;
public class FieldDefinitionWeatherCondition extends FieldDefinition {
public FieldDefinitionWeatherCondition(int localNumber, int size, BaseType baseType, String name) {
super(localNumber, size, baseType, name, 1, 0);
}
@Override
public Object decode(ByteBuffer byteBuffer) {
int idx = (int) baseType.decode(byteBuffer, scale, offset);
return Condition.values()[idx];
}
@Override
public void encode(ByteBuffer byteBuffer, Object o) {
if (o instanceof Condition) {
baseType.encode(byteBuffer, ((Condition) o).ordinal(), scale, offset);
return;
}
baseType.encode(byteBuffer, openWeatherCodeToFitWeatherStatus((int) o), scale, offset);
}
private int openWeatherCodeToFitWeatherStatus(int openWeatherCode) {
switch (openWeatherCode) {
//Group 2xx: Thunderstorm
case 200: //thunderstorm with light rain: //11d
case 201: //thunderstorm with rain: //11d
case 202: //thunderstorm with heavy rain: //11d
case 210: //light thunderstorm:: //11d
case 211: //thunderstorm: //11d
case 212: //heavy thunderstorm: //11d
case 230: //thunderstorm with light drizzle: //11d
case 231: //thunderstorm with drizzle: //11d
case 232: //thunderstorm with heavy drizzle: //11d
return Condition.THUNDERSTORMS.ordinal();
case 221: //ragged thunderstorm: //11d
return Condition.SCATTERED_THUNDERSTORMS.ordinal();
//Group 3xx: Drizzle
case 300: //light intensity drizzle: //09d
case 310: //light intensity drizzle rain: //09d
case 313: //shower rain and drizzle: //09d
return Condition.LIGHT_RAIN.ordinal();
case 301: //drizzle: //09d
case 311: //drizzle rain: //09d
return Condition.RAIN.ordinal();
case 302: //heavy intensity drizzle: //09d
case 312: //heavy intensity drizzle rain: //09d
case 314: //heavy shower rain and drizzle: //09d
return Condition.HEAVY_RAIN.ordinal();
case 321: //shower drizzle: //09d
return Condition.SCATTERED_SHOWERS.ordinal();
//Group 5xx: Rain
case 500: //light rain: //10d
case 520: //light intensity shower rain: //09d
case 521: //shower rain: //09d
return Condition.LIGHT_RAIN.ordinal();
case 501: //moderate rain: //10d
case 531: //ragged shower rain: //09d
return Condition.RAIN.ordinal();
case 502: //heavy intensity rain: //10d
case 503: //very heavy rain: //10d
case 504: //extreme rain: //10d
case 522: //heavy intensity shower rain: //09d
return Condition.HEAVY_RAIN.ordinal();
case 511: //freezing rain: //13d
return Condition.UNKNOWN_PRECIPITATION.ordinal();
//Group 6xx: Snow
case 600: //light snow: //[[file:13d.png]]
return Condition.LIGHT_SNOW.ordinal();
case 601: //snow: //[[file:13d.png]]
case 620: //light shower snow: //[[file:13d.png]]
case 621: //shower snow: //[[file:13d.png]]
return Condition.SNOW.ordinal();
case 602: //heavy snow: //[[file:13d.png]]
case 622: //heavy shower snow: //[[file:13d.png]]
return Condition.HEAVY_SNOW.ordinal();
case 611: //sleet: //[[file:13d.png]]
case 612: //light shower sleet: //[[file:13d.png]]
case 613: //shower sleet: //[[file:13d.png]]
return Condition.WINTRY_MIX.ordinal();
case 615: //light rain and snow: //[[file:13d.png]]
return Condition.LIGHT_RAIN_SNOW.ordinal();
case 616: //rain and snow: //[[file:13d.png]]
return Condition.HEAVY_RAIN_SNOW.ordinal();
//Group 7xx: Atmosphere
case 701: //mist: //[[file:50d.png]]
case 711: //smoke: //[[file:50d.png]]
case 721: //haze: //[[file:50d.png]]
case 731: //sandcase dust whirls: //[[file:50d.png]]
case 751: //sand: //[[file:50d.png]]
case 761: //dust: //[[file:50d.png]]
case 762: //volcanic ash: //[[file:50d.png]]
return Condition.HAZY.ordinal();
case 741: //fog: //[[file:50d.png]]
return Condition.FOG.ordinal();
case 771: //squalls: //[[file:50d.png]]
case 781: //tornado: //[[file:50d.png]]
return Condition.WINDY.ordinal();
//Group 800: Clear
case 800: //clear sky: //[[file:01d.png]] [[file:01n.png]]
return Condition.CLEAR.ordinal();
//Group 80x: Clouds
case 801: //few clouds: //[[file:02d.png]] [[file:02n.png]]
case 802: //scattered clouds: //[[file:03d.png]] [[file:03d.png]]
return Condition.PARTLY_CLOUDY.ordinal();
case 803: //broken clouds: //[[file:04d.png]] [[file:03d.png]]
return Condition.MOSTLY_CLOUDY.ordinal();
case 804: //overcast clouds: //[[file:04d.png]] [[file:04d.png]]
return Condition.CLOUDY.ordinal();
//Group 90x: Extreme
case 901: //tropical storm
return Condition.THUNDERSTORMS.ordinal();
case 906: //hail
return Condition.HAIL.ordinal();
case 903: //cold
case 904: //hot
case 905: //windy
//Group 9xx: Additional
case 951: //calm
case 952: //light breeze
case 953: //gentle breeze
case 954: //moderate breeze
case 955: //fresh breeze
case 956: //strong breeze
case 957: //high windcase near gale
case 958: //gale
case 959: //severe gale
case 960: //storm
case 961: //violent storm
case 902: //hurricane
case 962: //hurricane
default:
throw new IllegalArgumentException("Unknown weather code " + openWeatherCode);
}
}
enum Condition {
CLEAR,
PARTLY_CLOUDY,
MOSTLY_CLOUDY,
RAIN,
SNOW,
WINDY,
THUNDERSTORMS,
WINTRY_MIX,
FOG,
UNK9,
UNK10,
HAZY,
HAIL,
SCATTERED_SHOWERS,
SCATTERED_THUNDERSTORMS,
UNKNOWN_PRECIPITATION,
LIGHT_RAIN,
HEAVY_RAIN,
LIGHT_SNOW,
HEAVY_SNOW,
LIGHT_RAIN_SNOW,
HEAVY_RAIN_SNOW,
CLOUDY,
;
}
}

View File

@ -0,0 +1,124 @@
package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.http;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.protobuf.ByteString;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayOutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.zip.GZIPOutputStream;
import nodomain.freeyourgadget.gadgetbridge.proto.vivomovehr.GdiHttpService;
import nodomain.freeyourgadget.gadgetbridge.util.HttpUtils;
public class HttpHandler {
private static final Logger LOG = LoggerFactory.getLogger(HttpHandler.class);
private static final Gson GSON = new GsonBuilder()
//.serializeNulls()
.create();
public static GdiHttpService.HttpService handle(final GdiHttpService.HttpService httpService) {
if (httpService.hasRawRequest()) {
final GdiHttpService.HttpService.RawResponse rawResponse = handleRawRequest(httpService.getRawRequest());
if (rawResponse != null) {
return GdiHttpService.HttpService.newBuilder()
.setRawResponse(rawResponse)
.build();
}
return null;
}
LOG.warn("Unsupported http service request {}", httpService);
return null;
}
public static GdiHttpService.HttpService.RawResponse handleRawRequest(final GdiHttpService.HttpService.RawRequest rawRequest) {
final String urlString = rawRequest.getUrl();
LOG.debug("Got rawRequest: {} - {}", rawRequest.getMethod(), urlString);
final URL url;
try {
url = new URL(urlString);
} catch (final MalformedURLException e) {
LOG.error("Failed to parse url", e);
return null;
}
final String path = url.getPath();
final Map<String, String> query = HttpUtils.urlQueryParameters(url);
final Map<String, String> requestHeaders = headersToMap(rawRequest.getHeaderList());
final byte[] responseBody;
final List<GdiHttpService.HttpService.Header> responseHeaders = new ArrayList<>();
if (path.startsWith("/weather/")) {
LOG.debug("Got weather request for {}", path);
final Object obj = WeatherHandler.handleWeatherRequest(path, query);
if (obj == null) {
return null;
}
final String json = GSON.toJson(obj);
LOG.debug("Weather response: {}", json);
final byte[] stringBytes = json.getBytes(StandardCharsets.UTF_8);
if ("gzip".equals(requestHeaders.get("accept-encoding"))) {
responseHeaders.add(
GdiHttpService.HttpService.Header.newBuilder()
.setKey("Content-Encoding")
.setValue("gzip")
.build()
);
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (GZIPOutputStream gzos = new GZIPOutputStream(baos)) {
gzos.write(stringBytes);
gzos.finish();
gzos.flush();
responseBody = baos.toByteArray();
} catch (final Exception e) {
LOG.error("Failed to compress response", e);
return null;
}
} else {
responseBody = stringBytes;
}
responseHeaders.add(
GdiHttpService.HttpService.Header.newBuilder()
.setKey("Content-Type")
.setValue("application/json")
.build()
);
} else {
LOG.warn("Unhandled path {}", urlString);
return null;
}
return GdiHttpService.HttpService.RawResponse.newBuilder()
.setStatus(GdiHttpService.HttpService.Status.OK)
.setHttpStatus(200)
.setBody(ByteString.copyFrom(responseBody))
.addAllHeader(responseHeaders)
.build();
}
private static Map<String, String> headersToMap(final List<GdiHttpService.HttpService.Header> headers) {
final Map<String, String> ret = new HashMap<>();
for (final GdiHttpService.HttpService.Header header : headers) {
ret.put(header.getKey().toLowerCase(Locale.ROOT), header.getValue());
}
return ret;
}
}

Some files were not shown because too many files have changed in this diff Show More