Compare commits
50 Commits
3ac478e163
...
81ea814681
Author | SHA1 | Date |
---|---|---|
José Rebelo | 81ea814681 | |
Daniele Gobbetti | 2605dff105 | |
Daniele Gobbetti | 39aea72ef7 | |
a0z | 60c3834e0b | |
Daniele Gobbetti | 6e0d7edfa7 | |
José Rebelo | 025f15e0fa | |
José Rebelo | db378faf47 | |
José Rebelo | 7bf73c6b41 | |
José Rebelo | f6754df211 | |
Daniele Gobbetti | b4414f0799 | |
José Rebelo | ce0a61e211 | |
José Rebelo | 3386e86158 | |
Daniele Gobbetti | c5a94d2927 | |
kuhy | 18a6a6b1c7 | |
Daniele Gobbetti | f1c7c97558 | |
myxor | 3baeb2b14e | |
Daniele Gobbetti | d6ade723d3 | |
Daniele Gobbetti | 863c1a5657 | |
Daniele Gobbetti | 16f5890a95 | |
hrdl | 10c5286ec1 | |
Daniele Gobbetti | 6b821a2f1f | |
José Rebelo | bcfaf7b3e8 | |
José Rebelo | 2f2b95beee | |
José Rebelo | 6569cd74ba | |
José Rebelo | 49d4792677 | |
José Rebelo | 418bb7d37a | |
José Rebelo | 044ac1e917 | |
José Rebelo | bad5cd4045 | |
Daniele Gobbetti | b3da377b34 | |
Daniele Gobbetti | 790019fa5a | |
Daniele Gobbetti | c96d1da1e5 | |
Daniele Gobbetti | 293449b5e0 | |
Daniele Gobbetti | 57db0c7c33 | |
Daniele Gobbetti | 379b8912cb | |
Daniele Gobbetti | 2aa8667998 | |
Daniele Gobbetti | 3a3ff5bc6a | |
Daniele Gobbetti | eff233d93a | |
Daniele Gobbetti | e73d4a7130 | |
Daniele Gobbetti | 40d064cf7f | |
Daniele Gobbetti | 719a104811 | |
Daniele Gobbetti | fb3338f099 | |
Daniele Gobbetti | fe1f610546 | |
Daniele Gobbetti | 1d1c6146a7 | |
José Rebelo | 500e930237 | |
José Rebelo | 3799ffb72c | |
José Rebelo | 13d6c49bb5 | |
Vitaliy Tomin | 67cf9b2f00 | |
Daniele Gobbetti | 173e2d29b0 | |
Marcel Alexandru Nitan | 2190c82ed7 | |
Arjan Schrijver | f186053dab |
|
@ -1,2 +0,0 @@
|
|||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
|
@ -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.**
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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]));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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";
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
package nodomain.freeyourgadget.gadgetbridge.devices;
|
||||
|
||||
|
||||
public enum SleepAsAndroidFeature {
|
||||
HEART_RATE,
|
||||
ALARMS,
|
||||
NOTIFICATIONS,
|
||||
ACCELEROMETER,
|
||||
OXIMETRY,
|
||||
SPO2
|
||||
}
|
|
@ -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()));
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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";
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 + ")";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
// }
|
||||
}
|
|
@ -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,
|
||||
;
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue