From 2190c82ed754a272cb42940f3be4af78cb7416be Mon Sep 17 00:00:00 2001 From: Marcel Alexandru Nitan Date: Sat, 6 Apr 2024 15:21:39 +0300 Subject: [PATCH] feature: Sleep as android support Implement support for Sleep As Android with an usable example for ZeppOs devices Sleep as Android documentation: https://docs.sleep.urbandroid.org/devs/wearable_api.html Signed-off-by: Marcel Alexandru Nitan --- .settings/org.eclipse.buildship.core.prefs | 2 - app/src/main/AndroidManifest.xml | 24 +- .../activities/SettingsActivity.java | 9 + .../SleepAsAndroidPreferencesActivity.java | 138 +++++ .../adapter/GBDeviceAdapterv2.java | 1 - .../devices/AbstractDeviceCoordinator.java | 11 + .../devices/DeviceCoordinator.java | 13 + .../gadgetbridge/devices/EventHandler.java | 3 + .../devices/SleepAsAndroidFeature.java | 11 + .../huami/zeppos/ZeppOsCoordinator.java | 14 + .../sleepasandroid/SleepAsAndroidAction.java | 16 + .../SleepAsAndroidReceiver.java | 23 + .../gadgetbridge/impl/GBDeviceService.java | 12 +- .../gadgetbridge/model/DeviceService.java | 3 + .../service/AbstractDeviceSupport.java | 6 + .../service/DeviceCommunicationService.java | 34 ++ .../service/ServiceDeviceSupport.java | 10 +- .../service/SleepAsAndroidSender.java | 573 ++++++++++++++++++ .../service/devices/huami/HuamiSupport.java | 10 +- .../devices/huami/zeppos/ZeppOsSupport.java | 175 +++++- .../serial/AbstractSerialDeviceSupport.java | 1 + app/src/main/res/values/strings.xml | 16 + app/src/main/res/xml/preferences.xml | 5 + .../res/xml/sleepasandroid_preferences.xml | 78 +++ 24 files changed, 1180 insertions(+), 8 deletions(-) delete mode 100644 .settings/org.eclipse.buildship.core.prefs create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAsAndroidPreferencesActivity.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SleepAsAndroidFeature.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidAction.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidReceiver.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/SleepAsAndroidSender.java create mode 100644 app/src/main/res/xml/sleepasandroid_preferences.xml diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs deleted file mode 100644 index e8895216f..000000000 --- a/.settings/org.eclipse.buildship.core.prefs +++ /dev/null @@ -1,2 +0,0 @@ -connection.project.dir= -eclipse.preferences.version=1 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 95f5ea8f3..b3736f4aa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -110,6 +110,24 @@ android:requestLegacyExternalStorage="true" android:theme="@style/GadgetbridgeTheme" tools:replace="android:label"> + + + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java index 4cb76457c..b2cac37b1 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SettingsActivity.java @@ -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"); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAsAndroidPreferencesActivity.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAsAndroidPreferencesActivity.java new file mode 100644 index 000000000..438f9181d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/activities/SleepAsAndroidPreferencesActivity.java @@ -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 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 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 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 devices = GBApplication.app().getDeviceManager().getDevices(); + List deviceMACs = new ArrayList<>(); + List 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])); + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java index 0b7ead89d..6bb683604 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/adapter/GBDeviceAdapterv2.java @@ -843,7 +843,6 @@ public class GBDeviceAdapterv2 extends ListAdapter getSleepAsAndroidFeatures() { + return Collections.emptySet(); + } + @Override public int[] getSupportedDeviceSpecificConnectionSettings() { int[] settings = new int[0]; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java index 1e57bfe38..1de36fa03 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/DeviceCoordinator.java @@ -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 getSleepAsAndroidFeatures(); + /** * Returns device specific settings related to connection * diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java index 8b5af212c..07be6adef 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/EventHandler.java @@ -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); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SleepAsAndroidFeature.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SleepAsAndroidFeature.java new file mode 100644 index 000000000..8092350fb --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/SleepAsAndroidFeature.java @@ -0,0 +1,11 @@ +package nodomain.freeyourgadget.gadgetbridge.devices; + + +public enum SleepAsAndroidFeature { + HEART_RATE, + ALARMS, + NOTIFICATIONS, + ACCELEROMETER, + OXIMETRY, + SPO2 +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsCoordinator.java index 3e80751a2..f7968bc78 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsCoordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/huami/zeppos/ZeppOsCoordinator.java @@ -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 getSleepAsAndroidFeatures() { + return EnumSet.of(SleepAsAndroidFeature.ACCELEROMETER, SleepAsAndroidFeature.HEART_RATE, SleepAsAndroidFeature.ALARMS, SleepAsAndroidFeature.NOTIFICATIONS); + } + @Override public int getWorldClocksSlotCount() { return 20; // as enforced by Zepp diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidAction.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidAction.java new file mode 100644 index 000000000..b63daf43d --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidAction.java @@ -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"; +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidReceiver.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidReceiver.java new file mode 100644 index 000000000..61974d150 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/externalevents/sleepasandroid/SleepAsAndroidReceiver.java @@ -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()); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java index 30e3e76e8..cdf79dfda 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/impl/GBDeviceService.java @@ -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; @@ -547,4 +547,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); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java index 758f0e441..8b5018662 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/model/DeviceService.java @@ -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"; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java index 5eefbe371..736366c7e 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/AbstractDeviceSupport.java @@ -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; @@ -1181,4 +1182,9 @@ public abstract class AbstractDeviceSupport implements DeviceSupport { public void onSetNavigationInfo(NavigationInfoSpec navigationInfoSpec) { } + + @Override + public void onSleepAsAndroidAction(String action, Bundle extras) { + + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java index f3f2c5596..a2f9117b5 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/DeviceCommunicationService.java @@ -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.sleepasandroid.SleepAsAndroidAction; +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; @@ -145,6 +147,8 @@ public class DeviceCommunicationService extends Service implements SharedPrefere 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); + } } } @@ -254,6 +267,8 @@ public class DeviceCommunicationService extends Service implements SharedPrefere private OsmandEventReceiver mOsmandAidlHelper = null; + private SleepAsAndroidReceiver mSleepAsAndroidReceiver = null; + private HashMap deviceLastScannedTimestamps = new HashMap<>(); private final String[] mMusicActions = { @@ -1069,6 +1084,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; } } @@ -1355,6 +1377,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(); @@ -1422,6 +1452,10 @@ public class DeviceCommunicationService extends Service implements SharedPrefere unregisterReceiver(mGenericWeatherReceiver); mGenericWeatherReceiver = null; } + if (mSleepAsAndroidReceiver != null) { + unregisterReceiver(mSleepAsAndroidReceiver); + mSleepAsAndroidReceiver = null; + } } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java index be5746a8d..23dbe0e98 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/ServiceDeviceSupport.java @@ -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); + } } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/SleepAsAndroidSender.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/SleepAsAndroidSender.java new file mode 100644 index 000000000..ec3762775 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/SleepAsAndroidSender.java @@ -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 hrData = new ArrayList<>(); + + private ArrayList accData = new ArrayList<>(); + private ScheduledExecutorService accDataScheduler; + private Set 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 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. + * ... + */ + public synchronized void sendExtra(Float hr, Long extraDataTimestamp, Long extraDataFramerate, ArrayList extraDataRR, ArrayList spo2Batch, Float sdnn, ArrayList 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. + * ... + */ + 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 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; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java index c1aeb8346..80ce3d4c6 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/HuamiSupport.java @@ -30,6 +30,7 @@ import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.location.Location; import android.net.Uri; +import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.widget.Toast; @@ -63,7 +64,6 @@ import java.util.LinkedList; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Queue; import java.util.Set; import java.util.SimpleTimeZone; import java.util.TimeZone; @@ -128,6 +128,7 @@ import nodomain.freeyourgadget.gadgetbridge.model.Alarm; import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes; import nodomain.freeyourgadget.gadgetbridge.model.SleepState; import nodomain.freeyourgadget.gadgetbridge.model.WearingState; +import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.AbstractFetchOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchStatisticsOperation; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.operations.fetch.FetchTemperatureOperation; @@ -346,6 +347,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements private final LinkedList fetchOperationQueue = new LinkedList<>(); + protected SleepAsAndroidSender sleepAsAndroidSender; public HuamiSupport() { this(LOG); } @@ -372,6 +374,7 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements public void setContext(final GBDevice gbDevice, final BluetoothAdapter btAdapter, final Context context) { super.setContext(gbDevice, btAdapter, context); this.mediaManager = new MediaManager(context); + } @Override @@ -406,6 +409,9 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements } else { new InitOperation(authenticate, authFlags, cryptFlags, this, builder).perform(); } + if (sleepAsAndroidSender == null) { + sleepAsAndroidSender = new SleepAsAndroidSender(gbDevice); + } characteristicHRControlPoint = getCharacteristic(GattCharacteristic.UUID_CHARACTERISTIC_HEART_RATE_CONTROL_POINT); characteristicChunked = getCharacteristic(HuamiService.UUID_CHARACTERISTIC_CHUNKEDTRANSFER); } catch (IOException e) { @@ -2612,6 +2618,8 @@ public abstract class HuamiSupport extends AbstractBTLEDeviceSupport implements MiBand2SampleProvider provider = new MiBand2SampleProvider(gbDevice, session); MiBandActivitySample sample = createActivitySample(device, user, ts, provider); sample.setHeartRate(getHeartrateBpm()); + sleepAsAndroidSender.onHrChanged(sample.getHeartRate(), 0); + // sample.setSteps(getSteps()); sample.setRawIntensity(ActivitySample.NOT_MEASURED); sample.setRawKind(HuamiConst.TYPE_ACTIVITY); // to make it visible in the charts TODO: add a MANUAL kind for that? diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java index 2698f6b77..dfd3000a0 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/huami/zeppos/ZeppOsSupport.java @@ -17,6 +17,7 @@ package nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos; import static org.apache.commons.lang3.ArrayUtils.subarray; +import static java.lang.Thread.sleep; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service.*; import static nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiService.SUCCESS; import static nodomain.freeyourgadget.gadgetbridge.model.ActivityUser.PREF_USER_NAME; @@ -41,6 +42,7 @@ import static nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos. import android.content.Context; import android.location.Location; import android.net.Uri; +import android.os.Bundle; import android.os.Handler; import android.widget.Toast; @@ -56,6 +58,7 @@ import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; @@ -67,6 +70,8 @@ import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import nodomain.freeyourgadget.gadgetbridge.GBApplication; @@ -79,6 +84,7 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventScreenshot; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventSilentMode; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; +import nodomain.freeyourgadget.gadgetbridge.service.SleepAsAndroidSender; import nodomain.freeyourgadget.gadgetbridge.devices.huami.HuamiFWHelper; import nodomain.freeyourgadget.gadgetbridge.devices.huami.zeppos.ZeppOsCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.huami.Huami2021Service; @@ -91,6 +97,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandConst; import nodomain.freeyourgadget.gadgetbridge.devices.miband.MiBandCoordinator; import nodomain.freeyourgadget.gadgetbridge.devices.miband.VibrationProfile; import nodomain.freeyourgadget.gadgetbridge.externalevents.CalendarReceiver; +import nodomain.freeyourgadget.gadgetbridge.externalevents.sleepasandroid.SleepAsAndroidAction; import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceApp; import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind; import nodomain.freeyourgadget.gadgetbridge.model.ActivityUser; @@ -139,6 +146,7 @@ import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.service import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsPhoneService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWatchfaceService; import nodomain.freeyourgadget.gadgetbridge.service.devices.huami.zeppos.services.ZeppOsWifiService; +import nodomain.freeyourgadget.gadgetbridge.util.AlarmUtils; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; import nodomain.freeyourgadget.gadgetbridge.util.GBPrefs; @@ -151,9 +159,10 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer // Tracks whether realtime HR monitoring is already started, so we can just // send CONTINUE commands private boolean heartRateRealtimeStarted; - + private ScheduledExecutorService heartRateRealtimeScheduler; // Keep track of whether the rawSensor is enabled private boolean rawSensor = false; + private ScheduledExecutorService rawSensorScheduler; // Services private final ZeppOsServicesService servicesService = new ZeppOsServicesService(this); @@ -867,6 +876,169 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer } } + @Override + public void onSleepAsAndroidAction(String action, Bundle extras) { + // Validate if our device can work with an action + try { + sleepAsAndroidSender.validateAction(action); + } catch (UnsupportedOperationException e) { + return; + } + + // Consult the SleepAsAndroid documentation for a set of actions and their extra + // https://docs.sleep.urbandroid.org/devs/wearable_api.html + switch (action) { + case SleepAsAndroidAction.CHECK_CONNECTED: + sleepAsAndroidSender.confirmConnected(); + break; + // Received when the app starts sleep tracking + case SleepAsAndroidAction.START_TRACKING: + enableRealtimeHeartRateMeasurement(true); + enableRawSensor(true); + sleepAsAndroidSender.startTracking(); + break; + // Received when the app stops sleep tracking + case SleepAsAndroidAction.STOP_TRACKING: + enableRealtimeHeartRateMeasurement(false); + enableRawSensor(false); + sleepAsAndroidSender.stopTracking(); + break; + // Received when the app pauses sleep tracking +// case SleepAsAndroidAction.SET_PAUSE: +// long pauseTimestamp = extras.getLong("TIMESTAMP"); +// long delay = pauseTimestamp > 0 ? pauseTimestamp - System.currentTimeMillis() : 0; +// setRawSensor(delay > 0); +// enableRealtimeSamplesTimer(delay > 0); +// sleepAsAndroidSender.pauseTracking(delay); +// break; + // Same as above but controlled by a boolean value + case SleepAsAndroidAction.SET_SUSPENDED: + boolean suspended = extras.getBoolean("SUSPENDED", false); + setRawSensor(!suspended); + enableRealtimeSamplesTimer(!suspended); + sleepAsAndroidSender.pauseTracking(suspended); + // Received when the app changes the batch size for the movement data + case SleepAsAndroidAction.SET_BATCH_SIZE: + long batchSize = extras.getLong("SIZE", 12L); + sleepAsAndroidSender.setBatchSize(batchSize); + break; + // Received when the app requests the wearable to vibrate + case SleepAsAndroidAction.HINT: + int repeat = extras.getInt("REPEAT"); + for (int i = 0; i < repeat; i++) { + sendFindDeviceCommand(true); + try { + sleep(500); + sendFindDeviceCommand(false); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + break; + // Received when the app sends a notificaation + case SleepAsAndroidAction.SHOW_NOTIFICATION: + NotificationSpec notificationSpec = new NotificationSpec(); + notificationSpec.title = extras.getString("TITLE"); + notificationSpec.body = extras.getString("BODY"); + notificationService.sendNotification(notificationSpec); + break; + // Received when the app updates an alarm (Snoozing included too) + // It's better to use SleepAsAndroidAction.START_ALARM and .STOP_ALARM where possible to have more control over the alarm. + // Using .UPDATE_ALARM will let Gadgetbridge know when an alarm was set but not when it was dismissed. + case SleepAsAndroidAction.UPDATE_ALARM: + long alarmTimestamp = extras.getLong("TIMESTAMP"); + + // Sets the alarm at a giver hour and minute + // Snoozing from the app will create a new alarm in the future + setSleepAsAndroidAlarm(alarmTimestamp); + break; + // Received when an app alarm is stopped + case SleepAsAndroidAction.STOP_ALARM: + // Manually stop an alarm + break; + // Received when an app alarm starts + case SleepAsAndroidAction.START_ALARM: + // Manually start an alarm + break; + default: + LOG.warn("Received unsupported " + action); + break; + } + } + + private void setSleepAsAndroidAlarm(long alarmTimestamp) { + + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(new Timestamp(alarmTimestamp).getTime()); + Alarm alarm = AlarmUtils.createSingleShot(SleepAsAndroidSender.getAlarmSlot(), false, false, calendar); + ArrayList alarms = new ArrayList<>(1); + alarms.add(alarm); + + GBApplication.deviceService(gbDevice).onSetAlarms(alarms); + } + + private ScheduledExecutorService startRealtimeHeartRateMeasurement() { + ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor(); + service.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + if (heartRateRealtimeStarted) { + onEnableRealtimeHeartRateMeasurement(true); + } + } + }, 0, 1000, TimeUnit.MILLISECONDS); + return service; + } + + private void stopRealtimeHeartRateMeasurement() { + if (heartRateRealtimeScheduler != null) { + heartRateRealtimeScheduler.shutdown(); + heartRateRealtimeScheduler = null; + } + } + + private void enableRealtimeHeartRateMeasurement(boolean enable) { + onEnableRealtimeHeartRateMeasurement(enable); + if (enable) { + heartRateRealtimeScheduler = startRealtimeHeartRateMeasurement(); + } + else { + stopRealtimeHeartRateMeasurement(); + } + + } + + private void stopRawSensors() { + if (rawSensorScheduler != null) { + rawSensorScheduler.shutdown(); + rawSensorScheduler = null; + } + } + + private ScheduledExecutorService startRawSensors() { + ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor(); + service.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + if (rawSensor) { + setRawSensor(true); + } + } + }, 0, 10000, TimeUnit.MILLISECONDS); + return service; + } + + private void enableRawSensor(boolean enable) { + setRawSensor(enable); + if (enable) { + rawSensorScheduler = startRawSensors(); + } + else { + stopRawSensors(); + } + + } + @Override protected ZeppOsSupport setTimeFormat(final TransactionBuilder builder) { final GBPrefs gbPrefs = new GBPrefs(getDevicePrefs()); @@ -1136,6 +1308,7 @@ public class ZeppOsSupport extends HuamiSupport implements ZeppOsFileTransferSer final float gx = (x * gravity) / scaleFactor; final float gy = (y * gravity) / scaleFactor; final float gz = (z * gravity) / scaleFactor; + sleepAsAndroidSender.onAccelChanged(gx, gy, gz); LOG.info("Raw sensor g: x={} y={} z={}", gx, gy, gz); } diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java index 0900e19c3..17d8c7303 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/serial/AbstractSerialDeviceSupport.java @@ -18,6 +18,7 @@ package nodomain.freeyourgadget.gadgetbridge.service.serial; import android.location.Location; +import android.os.Bundle; import java.util.ArrayList; import java.util.UUID; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f907d1705..29e75c227 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2796,4 +2796,20 @@ Switch between main screens only using horizontal swiping Midnight at bottom In 24h mode, draw midnight at the bottom, midday at the top of the chart + + Sleep As Android + Enable Sleep As Android integration + Provider device + Select device as Sleep As Android data provider + Features + Support differs from device to device + Alarms + Alarms slot + Which alarm slot to use when setting alarms + Alarm slot has been set to default + Notifications + Accelerometer + Heart rate + Oximetry + SPO2 diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 40ae1f9e5..c05ec9fd7 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -63,6 +63,11 @@ android:title="@string/bottom_nav_dashboard" app:iconSpaceReserved="false" /> + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file